#!/usr/bin/env bash
# Copyright © 2026 David Villalobos Cambronero. All rights reserved.
# SPDX-License-Identifier: LicenseRef-Caliope-Proprietary

# ==============================================================================
#  Caliope Testbed — self-extracting launcher
# ==============================================================================
#  Single-file distribution of the Docker test environment for Caliope.
#  (Assembled from scripts/testbed/ of the Calíope repository by
#  scripts/testbed-build.py: edit the parts there, never this file.)
#  Double-click from Finder or run from Terminal:
#
#      ./caliope-testbed.command            # in a terminal, a wizard asks what to
#                                             start and which sample datasets to
#                                             load; without one, everything
#      ./caliope-testbed.command --all      # everything, without asking
#      ./caliope-testbed.command --fresh    # wipe everything and rebuild
#      ./caliope-testbed.command --stop     # stop the cluster (keep volumes)
#      ./caliope-testbed.command --status   # show container status
#      ./caliope-testbed.command --logs     # follow container logs
#      ./caliope-testbed.command --help     # print usage
#
#      ./caliope-testbed.command --remote   # the same, on the Docker of another
#                                             machine of your network (asks for
#                                             its IP the first time and sets it up)
#      ./caliope-testbed.command --local    # back to the Docker of this Mac
#
#  Topology (all containers run on your Mac via Docker Desktop, or on the remote
#  machine with --remote, where every localhost below is that machine's IP):
#
#      Mac (Caliope)
#        ├── localhost:3311 ──► mariadb-primary     (read / write, GTID master)
#        ├── localhost:3312 ──► mariadb-replica1    (read-only)
#        ├── localhost:3313 ──► mariadb-replica2    (read-only)
#        ├── localhost:3314 ──► mariadb-replica3    (read-only, source of replica4)
#        ├── localhost:3315 ──► mariadb-replica4    (read-only, reads from replica3: the cascade)
#        ├── localhost:3316 ──► mariadb-standalone  (independent, read / write)
#        ├── localhost:3320 ──► mysql-latest        (MySQL latest, seeded)
#        ├── localhost:3321 ──► mysql8-gis          (MySQL 8.0, seeded + espacial)
#        ├── localhost:3322 ──► mysql-primary       (MySQL 8.4 LTS, GTID source, seeded)
#        ├── localhost:3323 ──► mysql-replica1      (MySQL 8.4 LTS, read-only)
#        ├── localhost:3324 ──► mysql-replica2      (MySQL 8.4 LTS, read-only)
#        ├── localhost:3330 ──► postgres-gis        (PostGIS 17.6/3.5, working node)
#        ├── localhost:3331 ──► postgres-primary    (Postgres 17, streaming source)
#        ├── localhost:3332 ──► postgres-replica    (Postgres 17, physical standby)
#        ├── localhost:3340 ──► sqlserver           (SQL Server 2025, amd64 bajo Rosetta)
#        ├── localhost:3341 ──► sqlserver-restore   (SQL Server 2025, vacío: destino de restauración)
#        ├── localhost:1521 ──► oracle              (Oracle 26ai Free, FREEPDB1, seeded)
#        ├── localhost:2484 ──► oracle-tls          (Oracle 26ai Free, sólo TCPS, CA del testbed)
#        ├── localhost:8123 ──► caliope-clickhouse  (ClickHouse 26.8 LTS, HTTP; 9000 sólo interno)
#        ├── localhost:8443 ──► caliope-clickhouse-tls (ClickHouse 26.8, sólo HTTPS, CA propia)
#        ├── localhost:8124 ──► caliope-clickhouse-r1 (ClickHouse 26.8, réplica r1 del clúster caliope_par)
#        ├── localhost:8125 ──► caliope-clickhouse-r2 (ClickHouse 26.8, réplica r2)
#        ├── localhost:8126 ──► caliope-clickhouse-r3 (ClickHouse 26.8, shard 02 de caliope_2x2)
#        ├── localhost:8127 ──► caliope-clickhouse-r4 (ClickHouse 26.8, shard 02 de caliope_2x2)
#        │                 └──► caliope-clickhouse-keeper (Keeper de un nodo, 9181 sólo interno)
#        ├── localhost:27017 ─► caliope-mongo       (MongoDB 8, authenticated)
#        ├── localhost:27018 ─► caliope-mongo-rs    (MongoDB 8, replica set rs0, miembro 1)
#        ├── localhost:27020 ─► caliope-mongo-rs2   (MongoDB 8, rs0, miembro 2)
#        ├── localhost:27021 ─► caliope-mongo-rs3   (MongoDB 8, rs0, miembro 3)
#        ├── localhost:27019 ─► caliope-mongo-tls   (MongoDB 8, sólo TLS + SCRAM, CA propia generada dentro)
#        ├── localhost:6379 ──► caliope-redis       (Redis 7.4, sólo contraseña; base 0 sembrada, 266 claves)
#        ├── localhost:6380 ──► caliope-redis-tls   (Redis 7.4, sólo TLS, CA propia generada dentro)
#        ├── localhost:7001 ──► caliope-redis-cluster-1 (Redis 7.4, clúster: primario; lo forma redis-cluster-setup)
#        ├── localhost:7002 ──► caliope-redis-cluster-2 (Redis 7.4, clúster: primario; lo forma redis-cluster-setup)
#        ├── localhost:7003 ──► caliope-redis-cluster-3 (Redis 7.4, clúster: primario; lo forma redis-cluster-setup)
#        ├── localhost:7004 ──► caliope-redis-cluster-4 (Redis 7.4, clúster: réplica; lo forma redis-cluster-setup)
#        ├── localhost:7005 ──► caliope-redis-cluster-5 (Redis 7.4, clúster: réplica; lo forma redis-cluster-setup)
#        ├── localhost:7006 ──► caliope-redis-cluster-6 (Redis 7.4, clúster: réplica; lo forma redis-cluster-setup)
#        └── localhost:2222 ──► SSH bastion ──► db  (replica5, tunnel-only)
#                                           ├──► clickhouse-tunnel:8123 (ClickHouse 26.8, tunnel-only)
#                                           ├──► redis-tunnel:6379 (Redis 7.4, tunnel-only)
#                                           └──► oracle:1521 (el mismo nodo `oracle`, también por túnel)
#
#  Los doce nodos MySQL/MariaDB registran consultas lentas en `mysql.slow_log`
#  (umbral 0,1 s, `log_output=TABLE`). El generador de MariaDB las produce en
#  ocho —todos los suyos menos `db`, que sólo recibe replicación, y los dos MySQL
#  sueltos—, y `mysql-loadgen` en el primario del grupo de MySQL 8.4.
#
#  Los tres Postgres no tienen `mysql.slow_log`: lo consultable es
#  `pg_stat_statements`, que va en `shared_preload_libraries`, y el umbral es
#  `log_min_duration_statement=250`. La carga lenta la produce `pg-loadgen`
#  contra `postgres-gis`, que es el único que monta el seed. Y ese umbral lo
#  fijan de dos maneras distintas a propósito: en `postgres-gis` por `ALTER
#  SYSTEM` desde el seed, para que la app pueda cambiarlo; en los otros dos por
#  `-c`, que gana sobre `ALTER SYSTEM` en silencio y es el caso que la
#  herramienta tiene que declarar como no escribible.
#
#  Y los nodos Mongo llevan el *profiler* en nivel 1 con `slowms` 100: lo que
#  se consulta es `system.profile`, una colección capada por base. La carga la
#  produce `mongo-loadgen` contra `mongo`, y es lenta por construcción —un
#  `sleep()` dentro de la consulta—, no por volumen.
#
#  El nodo Redis lleva `slowlog-log-slower-than` 10000 µs. `redis-seed` siembra la
#  base 0 una vez (266 claves, una de cada tipo y con nombres jerárquicos), y
#  `redis-loadgen` lee de la 0 —aciertos y fallos—, escribe en la 1 y lanza una
#  lenta cada 4 s con `EVAL`, por tiempo y no por volumen.
#
#  Requirements:
#      • macOS 12 or later
#      • Docker Desktop 4.x (or Docker Engine + compose plugin)
#      • With everything up, ~15 GB of RAM for Docker (SQL Server and Oracle need
#        2 GB free each)
#        and ~39 GB of disk (14 of them Oracle's image, 4 each of its two volumes); the wizard says what a
#        smaller choice needs
#
#  Extraction target:  $HOME/CaliopeTestbed/
#  Run log:            $HOME/CaliopeTestbed/logs/run-<fecha>.log (las diez últimas)
#  All embedded files (docker-compose.yml, Dockerfiles, seed SQL, helper
#  scripts) are written on the first run. Subsequent runs reuse them; pass
#  --fresh to force a clean extraction and image rebuild.
# ==============================================================================

set -euo pipefail

# ── Paths ────────────────────────────────────────────────────────────────────
WORKDIR="${HOME}/CaliopeTestbed"
COMPOSE_FILE="${WORKDIR}/docker-compose.yml"
SCRIPTS_DIR="${WORKDIR}/scripts"
SEED_DIR="${SCRIPTS_DIR}/seed"
GIS_SEED_DIR="${SCRIPTS_DIR}/gis-seed"
MONGO_SEED_DIR="${SCRIPTS_DIR}/mongo-seed"
PG_SETUP_DIR="${SCRIPTS_DIR}/pg-setup"
PG_SEED_DIR="${SCRIPTS_DIR}/pg-seed"
MSSQL_SEED_DIR="${SCRIPTS_DIR}/mssql-seed"
ORACLE_SEED_DIR="${SCRIPTS_DIR}/oracle-seed"
ORACLE_TLS_DIR="${SCRIPTS_DIR}/oracle-tls"
ORACLE_TLS_STARTUP_DIR="${SCRIPTS_DIR}/oracle-tls-startup"
CLICKHOUSE_SEED_DIR="${SCRIPTS_DIR}/clickhouse-seed"
CLICKHOUSE_TLS_DIR="${SCRIPTS_DIR}/clickhouse-tls"
CLICKHOUSE_TLS_CA_DIR="${WORKDIR}/clickhouse-tls-ca"
CLICKHOUSE_KEEPER_DIR="${SCRIPTS_DIR}/clickhouse-keeper"
CLICKHOUSE_REPLICA_SEED_DIR="${SCRIPTS_DIR}/clickhouse-replica-seed"
CLICKHOUSE_R1_DIR="${SCRIPTS_DIR}/clickhouse-r1"
CLICKHOUSE_R2_DIR="${SCRIPTS_DIR}/clickhouse-r2"
CLICKHOUSE_R3_DIR="${SCRIPTS_DIR}/clickhouse-r3"
CLICKHOUSE_R4_DIR="${SCRIPTS_DIR}/clickhouse-r4"
CLICKHOUSE_TUNNEL_DIR="${SCRIPTS_DIR}/clickhouse-tunnel"
MYSQL8_GIS_SEED_DIR="${SCRIPTS_DIR}/mysql8-gis-seed"
LOG_DIR="${WORKDIR}/logs"
RUN_LOG=""          # se fija en do_up, cuando el directorio ya existe

# El directorio del propio launcher se resuelve AQUI, antes del primer `cd`.
# `write_connection_import_file` corre despues de `cd "${WORKDIR}"`, asi que un
# `$0` relativo —`./scripts/caliope-testbed.command`, que es como se lanza desde
# el repo— se resolvia contra el directorio de trabajo y el archivo de conexiones
# acababa en `~/CaliopeTestbed/scripts/`: ese directorio existe y se puede
# escribir, asi que la caida al WORKDIR tampoco saltaba y el archivo aparecia
# donde nadie lo busca, sin aviso. Pasó el 2026-08-18.
LAUNCHER_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd || true)"

# ── Presentation helpers ─────────────────────────────────────────────────────
# Color sólo donde se ve (David, 2026-10-13): con la salida en una terminal y sin
# `NO_COLOR` —la convención para apagarlo—. Redirigida a un archivo, como en la
# nube, en `corrida.sh` o en la QA, ninguna de estas variables lleva nada y sale
# texto limpio; y el registro de la corrida no recibe nunca un código de color,
# porque lo escriben `logged` y `run_with_progress`, que no los usan.
#
# El ámbar es el de la paleta de Calíope (`caliopeAmber`, #F59E0B) con color de
# 24 bits, y el 214 de la paleta de 256 —el más cercano— donde la terminal no lo
# anuncia. El estado nunca va sólo en el color: cada línea lleva su glifo
# (✓ listo, ⚠ aviso, ✗ fallo), como pide la regla de accesibilidad de la app.
C_RESET=""; C_BOLD=""; C_DIM=""; C_AMBER=""; C_GREEN=""; C_RED=""; C_YELLOW=""
if [[ -t 1 && -z "${NO_COLOR:-}" && "${TERM:-dumb}" != "dumb" ]]; then
  C_RESET=$'\e[0m'; C_BOLD=$'\e[1m'; C_DIM=$'\e[2m'
  C_GREEN=$'\e[32m'; C_RED=$'\e[31m'; C_YELLOW=$'\e[33m'
  case "${COLORTERM:-}" in
    truecolor|24bit) C_AMBER=$'\e[38;2;245;158;11m' ;;
    *)               C_AMBER=$'\e[38;5;214m' ;;
  esac
fi

line() { printf '%s%s%s\n' "$C_DIM" "──────────────────────────────────────────────────────────────────────" "$C_RESET"; }

# Una línea de progreso. Lo que se ve mientras esto corre son sólo estas líneas:
# todo lo demás —el informe— se imprime una vez, al final y de una pieza.
step() { printf "  %s·%s  %s\n" "$C_DIM" "$C_RESET" "$*"; }
ok()   { printf "  %s✓%s  %s\n" "$C_GREEN" "$C_RESET" "$*"; }
warn() { printf "  %s⚠%s  %s\n" "$C_YELLOW" "$C_RESET" "$*"; }
fail() { printf "  %s✗%s  %s\n" "$C_RED" "$C_RESET" "$*"; }

# Una notificación de macOS para lo que termina mientras nadie mira la terminal
# (David, 2026-10-13): un arranque entero son minutos, y Sakila, Pagila o los
# datos de Atlas acaban en segundo plano. Sólo con terminal y en el Mac —el
# lanzador corre aquí también con `--remote`—, y nunca en la nube ni en una
# corrida: allí no hay nadie a quien avisar y `osascript` no existe o estorba.
notify_os() {
  [[ -t 1 ]] || return 0
  [[ "$(uname -s)" == "Darwin" ]] || return 0
  command -v osascript >/dev/null 2>&1 || return 0
  local msg
  msg="$(printf '%s' "$1" | tr '"\\' "'/")"
  osascript -e "display notification \"${msg}\" with title \"Caliope testbed\"" >/dev/null 2>&1 || true
}

# Lo que termina, dicho una vez: una línea con su glifo y la notificación.
announce() {
  local kind="$1"; shift
  case "$kind" in
    ok)   ok "$*" ;;
    warn) warn "$*" ;;
    fail) fail "$*" ;;
    *)    step "$*" ;;
  esac
  notify_os "$*"
}

# Los trabajos de segundo plano (las descargas y cargas de los datos de ejemplo)
# no escriben en la terminal: dejan una línea `tipo|texto` en `EVENTS_FILE`, y la
# imprime el proceso de delante cuando le toca —entre dos pasos, o dentro del
# bucle de `run_with_progress`, borrando antes su línea viva—. Un solo proceso
# escribe la terminal, así que nada se descoloca.
EVENTS_FILE=""
EVENTS_SHOWN=0
emit_event() { [[ -n "$EVENTS_FILE" ]] && printf '%s|%s\n' "$1" "$2" >> "$EVENTS_FILE"; return 0; }
drain_events() {
  [[ -n "$EVENTS_FILE" && -f "$EVENTS_FILE" ]] || return 0
  local total kind text
  total=$(wc -l < "$EVENTS_FILE" | tr -d ' ')
  (( total > EVENTS_SHOWN )) || return 0
  [[ -t 1 ]] && printf '\r%-110s\r' " "
  while IFS='|' read -r kind text; do
    announce "$kind" "$text"
  done < <(sed -n "$(( EVENTS_SHOWN + 1 )),${total}p" "$EVENTS_FILE")
  EVENTS_SHOWN=$total
}

# El asistente puede levantar sólo una parte del compose (`SELECTED_SERVICES`,
# separada por espacios). Vacía es «todo», que es lo que hace el lanzador sin
# terminal y con `--all`.
SELECTED_SERVICES=""
selected_service() {
  [[ -z "$SELECTED_SERVICES" ]] && return 0
  [[ " $SELECTED_SERVICES " == *" $1 "* ]]
}

# Todo lo que escriben Docker y los contenedores va a un archivo, no a la
# pantalla. Son cientos de líneas y sepultaban el informe final, que es lo único
# que hay que leer cuando el arranque termina: el volcado de `docker compose up`
# se metía dentro de la comprobación de puertos y el resumen del contenedor de
# replicación traía su propia tabla de perfiles, con la IP y los puertos que el
# lanzador ya imprime al final. El archivo vive en el directorio de trabajo del
# testbed (`~/CaliopeTestbed/logs`), nunca dentro del repositorio: el lanzador se
# edita en `scripts/` y lo que una corrida escribe no tiene nada que hacer ahí.
logged() {
  printf '\n$ %s\n' "$*" >> "$RUN_LOG"
  "$@" >> "$RUN_LOG" 2>&1
}

# Lo mismo que `logged`, pero para lo que tarda: bajar catorce imágenes,
# construir tres y arrancar dieciocho contenedores son varios minutos, y con el
# volcado en el registro la pantalla se quedaba en una línea quieta. Una espera
# sin señal no se distingue de un cuelgue, así que mientras el comando corre se
# enseña el tiempo transcurrido y **la última línea que Docker escribió** —que es
# la que dice si está bajando una imagen, construyendo o arrancando—.
#
# Dos detalles que no son de adorno:
#
#   · Sin terminal (la salida redirigida a un archivo, que es como corren las
#     tandas de QA) no se puede reescribir la línea con `\r`, así que ahí se
#     imprime una línea nueva cada 15 s en vez de sesenta por minuto.
#   · Docker escribe su progreso con retornos de carro, así que la última línea
#     se saca traduciendo `\r` a saltos: sin eso «la última línea» es el bloque
#     entero de la barra de descarga.
run_with_progress() {
  local label="$1"; shift
  printf '\n$ %s\n' "$*" >> "$RUN_LOG"
  "$@" >> "$RUN_LOG" 2>&1 &
  local pid=$! frames='|/-\' i=0 start now elapsed last tty=0 ticks=0
  [[ -t 1 ]] && tty=1
  start=$(date +%s)
  while kill -0 "$pid" 2>/dev/null; do
    now=$(date +%s)
    elapsed=$(( now - start ))
    last="$(tail -c 8000 "$RUN_LOG" 2>/dev/null | tr '\r' '\n' \
            | grep -vE '^[[:space:]]*$' | tail -1 | cut -c1-58)"
    # Lo que terminó en segundo plano mientras tanto sale encima de la línea viva.
    drain_events
    if [[ "$tty" == "1" ]]; then
      printf '\r  %s%s%s  %-28s %3dm%02ds  %s%-58s%s' \
        "$C_AMBER" "${frames:$(( i % 4 )):1}" "$C_RESET" "$label" "$(( elapsed / 60 ))" "$(( elapsed % 60 ))" \
        "$C_DIM" "$last" "$C_RESET"
    elif (( ticks % 15 == 0 )); then
      printf '  ·  %-28s %3dm%02ds  %s\n' "$label" "$(( elapsed / 60 ))" "$(( elapsed % 60 ))" "$last"
    fi
    i=$(( i + 1 ))
    ticks=$(( ticks + 1 ))
    sleep 1
  done
  # `wait` devuelve el código del comando, y es lo que decide si esto falló.
  local rc=0
  wait "$pid" || rc=$?
  if [[ "$tty" == "1" ]]; then
    printf '\r%-100s\r' " "
  fi
  now=$(date +%s)
  elapsed=$(( now - start ))
  if [[ "$rc" == "0" ]]; then
    printf '  %s✓%s  %-28s %3dm%02ds  done\n' "$C_GREEN" "$C_RESET" "$label" "$(( elapsed / 60 ))" "$(( elapsed % 60 ))"
  fi
  return "$rc"
}

# Un fallo con la salida escondida es un fallo mudo, así que cuando algo falla se
# enseña la cola del registro y dónde está el archivo entero.
show_log_tail() {
  printf "\n  %s✗%s  %s\n" "$C_RED" "$C_RESET" "$1"
  printf "     Last lines of %s:\n\n" "$RUN_LOG"
  tail -n 25 "$RUN_LOG" 2>/dev/null | sed 's/^/       /'
  printf "\n"
}

# Lo que el informe final cuenta de los datos de ejemplo. Cada trabajo de fondo
# deja su resultado y `finish_dataset_jobs` lo trae aquí: ninguno imprime su
# propio resumen a mitad de la salida.
DATASET_LINES=()
DATASET_COUNT=0
dataset_report() {
  DATASET_LINES+=("$1|$2")
  DATASET_COUNT=$(( DATASET_COUNT + 1 ))
}

# Lo que el informe final cuenta del archivo de perfiles y de la replicación.
CONN_FILE=""
CONN_GROUP=""
CONN_PROFILE_COUNT=0
CONN_FALLBACK=0
REPLICATION_LINES=()

usage() {
  cat <<'USAGE'
Caliope Testbed — a Docker playground for Caliope: MariaDB, MySQL, PostgreSQL,
SQL Server, ClickHouse, MongoDB and Redis, with replication groups, TLS and SSH
tunnels.

Usage:
  caliope-testbed.command                 In a terminal: a short wizard asks which engines to
                                          start, how much of each (minimum, full or custom) and
                                          which sample datasets to load (Sakila, Pagila, the
                                          MongoDB Atlas sample data). Each answer defaults to the
                                          one of the last run, and Enter repeats it whole.
                                          Without a terminal it starts everything, as before.
  caliope-testbed.command --all           Start everything without asking.
  caliope-testbed.command --fresh         System-wide Docker cleanup + rebuild (asks confirmation),
                                          then the same wizard.
  caliope-testbed.command --only a,b,c    Start ONLY the listed services (comma-separated
                                          compose names, e.g. primary, replica1 … replica4, db,
                                          standalone, mysql-latest, mysql8-gis, mysql-primary,
                                          mysql-replica1, mysql-replica2, postgres-gis,
                                          postgres-primary, postgres-replica, sqlserver, oracle,
                                          oracle-tls, clickhouse, clickhouse-r1 … clickhouse-r4, mongo,
                                          mongo-rs, mongo-tls, redis, redis-cluster-1 …), with
                                          --no-deps: nothing else is started, so list every node
                                          the task needs (mongo-rs brings its two other members).
                                          Needs one previous full start (seeds + replication).
  caliope-testbed.command --stop [a,b]    Stop containers (volumes preserved): all of them, or
                                          only the listed services
  caliope-testbed.command --status        Show container status, and the state of every replication
                                          group that is running
  caliope-testbed.command --health        Only the replication groups: one line each, and exit 1 if
                                          any running group is broken (the video recorder checks it)
  caliope-testbed.command --write-files   Only rewrite the node registry and the connections file
                                          of the target, without touching Docker. In local mode the
                                          LAN address comes from HOST_IP (empty: no alternative).
  caliope-testbed.command --logs          Follow the logs of every service
  caliope-testbed.command --help          Show this help

Where it runs (combine with any option above):
  --local                                 The Docker of this Mac (the default the first time)
  --remote                                The Docker of another machine of your network, over
                                          SSH. The first time it asks for that machine's IP and
                                          user, installs an SSH key, installs Docker there if it
                                          is missing, and creates the Docker context
                                          caliope-testbed-remote.
  Without either, the last one used. Both can run at the same time; while they do,
  every command needs --local or --remote (in a terminal it asks).

The full topology:
  MariaDB     primary + replica1, replica2 + the cascade replica3 → replica4 +
              db (reachable only through the SSH bastion) + a standalone node
  MySQL       mysql-latest, mysql8-gis (8.0, spatial) and an 8.4 LTS group:
              mysql-primary + two replicas
  PostgreSQL  postgres-gis (PostGIS) + a streaming pair (primary, standby)
  SQL Server  sqlserver + an empty restore target
  Oracle      oracle (26ai Free, service FREEPDB1, schemas DEMO and RRHH, also
              through the SSH bastion) and oracle-tls (TCPS only)
  ClickHouse  clickhouse, clickhouse-tls, clickhouse-tunnel, the pair caliope_par
              (r1, r2) and the second shard of caliope_2x2 (r3, r4), with Keeper
  MongoDB     mongo, mongo-tls and the replica set rs0 with three members
  Redis       redis, redis-tls, redis-tunnel and a six-node cluster
plus load generators on MariaDB, MySQL 8.4, PostgreSQL, MongoDB and Redis.
About 47 containers and 15 GB of memory with everything up; the wizard says
what your choice needs before starting it.

All extracted files live in $HOME/CaliopeTestbed/. Everything Docker and the
containers print goes to $HOME/CaliopeTestbed/logs/run-<date>.log, and the last
ten runs are kept: the terminal shows progress lines while it works and then one
report at the end, with the addresses, the node table, the credentials and what
was seeded. Colours only in a terminal; NO_COLOR turns them off.

Every run also writes caliope-testbed-connections.json next to this launcher (with
--remote, caliope-testbed-connections-remote.json): every node a client talks to,
as one importable group (the run prints how many profiles), for Settings >
Connections > Import in Caliope. Each target
has its own file and its own group, so the two testbeds never mix.
USAGE
}

MODE="up"
ALL_FLAG=0
ONLY_SERVICES=""
STOP_SERVICES=""
CLI_TARGET=""
# Las opciones van en cualquier orden, y `--local`/`--remote` se combinan con
# todas: `--remote --only sqlserver`, `--local --stop postgres-gis`.
while [[ $# -gt 0 ]]; do
  case "$1" in
    --help|-h) usage; exit 0 ;;
    --local)   CLI_TARGET="local" ;;
    --remote)  CLI_TARGET="remote" ;;
    --fresh)   MODE="fresh" ;;
    --all)     ALL_FLAG=1 ;;
    --only)
      MODE="only"
      if [[ $# -gt 1 ]]; then ONLY_SERVICES="$2"; shift; fi
      ;;
    --stop)
      MODE="stop"
      # La lista es opcional: sin ella se para todo, como siempre.
      if [[ $# -gt 1 && "$2" != --* ]]; then STOP_SERVICES="$2"; shift; fi
      ;;
    --status)  MODE="status" ;;
    --health)  MODE="health" ;;
    --write-files) MODE="files" ;;
    --logs)    MODE="logs" ;;
    *)
      printf "Unknown option: %s\n\n" "$1"
      usage
      exit 2
      ;;
  esac
  shift
done

# ── Docker availability check ────────────────────────────────────────────────
require_docker() {
  if ! command -v docker >/dev/null 2>&1; then
    cat <<'EOF' >&2
ERROR: Docker was not found on this Mac.

Install Docker Desktop from:
    https://docs.docker.com/desktop/install/mac-install/

Then launch Docker Desktop and re-run this script.
EOF
    exit 127
  fi

  if ! docker info >/dev/null 2>&1; then
    cat <<'EOF' >&2
ERROR: Docker is installed but is not running.

Open Docker Desktop (or start the Docker daemon) and wait for the whale
icon to stop animating. Re-run this script once Docker reports "running".
EOF
    exit 1
  fi

  if ! docker compose version >/dev/null 2>&1; then
    cat <<'EOF' >&2
ERROR: The "docker compose" plugin is missing.

Update Docker Desktop to a recent version (4.10 or newer) or install
docker-compose-plugin manually.
EOF
    exit 1
  fi
}

# ── Extraction guard ─────────────────────────────────────────────────────────
mkdir -p "${WORKDIR}" "${SCRIPTS_DIR}" "${SEED_DIR}" "${GIS_SEED_DIR}" "${MONGO_SEED_DIR}" \
         "${PG_SETUP_DIR}" "${PG_SEED_DIR}" "${MSSQL_SEED_DIR}" "${ORACLE_SEED_DIR}" "${MYSQL8_GIS_SEED_DIR}" \
         "${CLICKHOUSE_SEED_DIR}" "${CLICKHOUSE_TLS_DIR}/config.d" "${CLICKHOUSE_TLS_CA_DIR}" \
         "${CLICKHOUSE_KEEPER_DIR}" "${CLICKHOUSE_REPLICA_SEED_DIR}" \
         "${CLICKHOUSE_R1_DIR}/config.d" "${CLICKHOUSE_R2_DIR}/config.d" \
         "${CLICKHOUSE_R3_DIR}/config.d" "${CLICKHOUSE_R4_DIR}/config.d" \
         "${CLICKHOUSE_TUNNEL_DIR}/config.d"

# ── Certificados del nodo `clickhouse-tls` (plan 34, tarea 3) ────────────────
# Una CA propia y un certificado de servidor firmado por ella, generados aquí y
# NO en el contenedor: el cliente (la app, `curl --cacert`) necesita la CA, y la
# CA sale de esta máquina. Se generan una vez y se reutilizan; sólo se rehacen si
# cambia la lista de nombres (la IP de la PC en `remote.conf`) o si el de
# servidor caduca en menos de 30 días.
#
# Dos niveles y no uno autofirmado, por Apple: un certificado de servidor TLS
# que valida `SecTrust` no puede durar más de 825 días y tiene que llevar
# `serverAuth`; la CA no tiene ese tope. La CA dura 3650 días y el de servidor
# 825. La llave de la CA vive fuera de `scripts/`, que es lo único que entra en
# el contexto de construcción (`.dockerignore`), así que nunca viaja a la imagen.
#
# `scripts/clickhouse-tls/` entra entero en `/etc/clickhouse-server/` por
# `Dockerfile.clickhouse`: `server.crt`, `server.key`, `ca.crt` y el fragmento
# `config.d/caliope-tls.xml`. La CA para el cliente es
# `~/CaliopeTestbed/scripts/clickhouse-tls/ca.crt` de la máquina que corre el
# lanzador. La configuración por `-config` de un archivo propio, y no `-addext`,
# para que valga igual con el OpenSSL de Homebrew y con el LibreSSL de macOS.
generate_clickhouse_tls() {
  local dir="${CLICKHOUSE_TLS_DIR}" cadir="${CLICKHOUSE_TLS_CA_DIR}"
  local pc_ip=""
  [[ -f "${WORKDIR}/remote.conf" ]] && pc_ip="$(sed -n 's/^host=//p' "${WORKDIR}/remote.conf")"
  local san="DNS:localhost,DNS:clickhouse-tls,DNS:caliope-clickhouse-tls,IP:127.0.0.1${pc_ip:+,IP:${pc_ip}}"

  if ! command -v openssl >/dev/null 2>&1; then
    echo "ERROR: openssl is needed to generate the certificates of clickhouse-tls." >&2
    exit 1
  fi

  if [[ ! -f "${cadir}/ca.key" || ! -f "${dir}/ca.crt" ]]; then
    cat > "${cadir}/ca.cnf" <<'CH_CA_CNF_EOF'
[req]
distinguished_name = dn
x509_extensions    = v3_ca
prompt             = no
[dn]
CN = Caliope testbed CA
[v3_ca]
basicConstraints     = critical,CA:TRUE
keyUsage             = critical,keyCertSign,cRLSign
subjectKeyIdentifier = hash
CH_CA_CNF_EOF
    openssl req -x509 -newkey rsa:2048 -nodes -days 3650 -sha256 \
      -config "${cadir}/ca.cnf" \
      -keyout "${cadir}/ca.key" -out "${dir}/ca.crt" 2>/dev/null
    chmod 600 "${cadir}/ca.key"
    rm -f "${dir}/server.crt"
  fi

  if [[ ! -f "${dir}/server.crt" || ! -f "${dir}/server.key" ]] \
     || [[ "$(cat "${dir}/.san" 2>/dev/null)" != "$san" ]] \
     || ! openssl x509 -checkend 2592000 -noout -in "${dir}/server.crt" >/dev/null 2>&1; then
    printf '[req]\ndistinguished_name = dn\nprompt = no\n[dn]\nCN = localhost\n' > "${cadir}/server.cnf"
    printf 'basicConstraints = CA:FALSE\nkeyUsage = critical,digitalSignature,keyEncipherment\nextendedKeyUsage = serverAuth\nsubjectAltName = %s\n' \
      "$san" > "${cadir}/server.ext"
    openssl req -new -newkey rsa:2048 -nodes -config "${cadir}/server.cnf" \
      -keyout "${dir}/server.key" -out "${cadir}/server.csr" 2>/dev/null
    openssl x509 -req -in "${cadir}/server.csr" -CA "${dir}/ca.crt" -CAkey "${cadir}/ca.key" \
      -CAserial "${cadir}/ca.srl" -CAcreateserial -days 825 -sha256 -extfile "${cadir}/server.ext" \
      -out "${dir}/server.crt" 2>/dev/null
    printf '%s' "$san" > "${dir}/.san"
  fi
  # El servidor corre como `clickhouse`; `Dockerfile.clickhouse` copia con
  # `--chown`, pero la llave sale legible igual por si se copia sin él.
  chmod 644 "${dir}/server.key"

  # Sólo HTTPS: el puerto sin cifrar se QUITA, no se deja al lado. Si se queda
  # abierto, una prueba de TLS puede salir en verde sin haber negociado TLS.
  cat > "${dir}/config.d/caliope-tls.xml" <<'CH_TLS_XML_EOF'
<clickhouse>
    <http_port remove="remove"/>
    <https_port>8443</https_port>
    <openSSL>
        <server>
            <certificateFile>/etc/clickhouse-server/server.crt</certificateFile>
            <privateKeyFile>/etc/clickhouse-server/server.key</privateKeyFile>
            <verificationMode>none</verificationMode>
            <loadDefaultCAFile>true</loadDefaultCAFile>
            <cacheSessions>true</cacheSessions>
            <disableProtocols>sslv2,sslv3,tlsv1,tlsv1_1</disableProtocols>
            <preferServerCiphers>true</preferServerCiphers>
        </server>
    </openSSL>
</clickhouse>
CH_TLS_XML_EOF
}

# ── El par replicado de ClickHouse (plan 34, tarea 4) ────────────────────────
# Tres carpetas que entran DENTRO de sus imágenes, nunca montadas (con el Docker
# en otra máquina un montaje se resuelve en el disco de ésa):
#
#   clickhouse-keeper/       keeper_config.xml de UN nodo, que sustituye al de la
#                            imagen: el de la imagen escucha sólo en localhost y
#                            escribe en /var/lib/clickhouse, que aquí no existe.
#   clickhouse-r1/ … -r4/    config.d/caliope-replica.xml: el Keeper, las macros
#                            `shard`/`replica` que ReplicatedMergeTree usa en su
#                            ruta, y los clústeres en <remote_servers>: el par
#                            `caliope_par` (r1 y r2) y `caliope_2x2` (los cuatro,
#                            dos shards; 2026-10-13). El `default` de un nodo lo
#                            declara el servidor solo.
#   clickhouse-replica-seed/ sólo las cuentas (30-cuentas.sql): r1 es el destino de
#                            restauración de la capa 1, y con las tablas del seed
#                            la restauración chocaría con ellas.
generate_clickhouse_replicas() {
  cat > "${CLICKHOUSE_KEEPER_DIR}/keeper_config.xml" <<'CH_KEEPER_XML_EOF'
<clickhouse>
    <logger>
        <level>information</level>
        <console>1</console>
    </logger>
    <listen_host>0.0.0.0</listen_host>
    <max_connections>4096</max_connections>
    <keeper_server>
        <tcp_port>9181</tcp_port>
        <server_id>1</server_id>
        <log_storage_path>/var/lib/clickhouse-keeper/coordination/logs</log_storage_path>
        <snapshot_storage_path>/var/lib/clickhouse-keeper/coordination/snapshots</snapshot_storage_path>
        <coordination_settings>
            <operation_timeout_ms>10000</operation_timeout_ms>
            <session_timeout_ms>30000</session_timeout_ms>
            <raft_logs_level>warning</raft_logs_level>
        </coordination_settings>
        <raft_configuration>
            <server>
                <id>1</id>
                <hostname>localhost</hostname>
                <port>9234</port>
            </server>
        </raft_configuration>
    </keeper_server>
</clickhouse>
CH_KEEPER_XML_EOF

  # Los dos clústeres en un solo bloque de <remote_servers> por nodo. `caliope_par`
  # sólo lo declaran r1 y r2, como siempre; `caliope_2x2`, los cuatro.
  local par_xml='        <caliope_par>
            <shard>
                <internal_replication>true</internal_replication>
                <replica><host>clickhouse-r1</host><port>9000</port><user>caliope</user><password>caliopepass</password></replica>
                <replica><host>clickhouse-r2</host><port>9000</port><user>caliope</user><password>caliopepass</password></replica>
            </shard>
        </caliope_par>'
  local x2_xml='        <caliope_2x2>
            <shard>
                <internal_replication>true</internal_replication>
                <replica><host>clickhouse-r1</host><port>9000</port><user>caliope</user><password>caliopepass</password></replica>
                <replica><host>clickhouse-r2</host><port>9000</port><user>caliope</user><password>caliopepass</password></replica>
            </shard>
            <shard>
                <internal_replication>true</internal_replication>
                <replica><host>clickhouse-r3</host><port>9000</port><user>caliope</user><password>caliopepass</password></replica>
                <replica><host>clickhouse-r4</host><port>9000</port><user>caliope</user><password>caliopepass</password></replica>
            </shard>
        </caliope_2x2>'

  local n dir shard clusters
  for n in 1 2 3 4; do
    case "$n" in
      1) dir="${CLICKHOUSE_R1_DIR}"; shard=01; clusters="${par_xml}
${x2_xml}" ;;
      2) dir="${CLICKHOUSE_R2_DIR}"; shard=01; clusters="${par_xml}
${x2_xml}" ;;
      3) dir="${CLICKHOUSE_R3_DIR}"; shard=02; clusters="${x2_xml}" ;;
      4) dir="${CLICKHOUSE_R4_DIR}"; shard=02; clusters="${x2_xml}" ;;
    esac
    cat > "${dir}/config.d/caliope-replica.xml" <<CH_REPLICA_XML_EOF
<clickhouse>
    <zookeeper>
        <node>
            <host>clickhouse-keeper</host>
            <port>9181</port>
        </node>
    </zookeeper>
    <macros>
        <shard>${shard}</shard>
        <replica>r${n}</replica>
    </macros>
    <remote_servers>
${clusters}
    </remote_servers>
</clickhouse>
CH_REPLICA_XML_EOF
  done

  # Las cuatro tablas de registro que el servidor escribe solo, cada pocos
  # segundos, se apagan en los nodos secundarios (David, 2026-10-13): r1 a r4,
  # `clickhouse-tls` y `clickhouse-tunnel`. Ninguna pantalla de Calíope las lee
  # —sólo `query_log`, para las consultas lentas, y ésa se queda—, y eran casi
  # todo el 12 % de un núcleo que cada ClickHouse gastaba sin que nadie le
  # pidiera nada (medido un minuto entero en la PC). El nodo principal,
  # `clickhouse`, las conserva: quien explore el testbed encuentra ahí un
  # ClickHouse con todas sus tablas de sistema. Las tablas que ya existían en un
  # volumen se quedan, quietas: `remove` deja de escribirlas, no las borra.
  local quiet='<clickhouse>
    <metric_log remove="1"/>
    <asynchronous_metric_log remove="1"/>
    <trace_log remove="1"/>
    <text_log remove="1"/>
</clickhouse>'
  for dir in "${CLICKHOUSE_R1_DIR}" "${CLICKHOUSE_R2_DIR}" "${CLICKHOUSE_R3_DIR}" \
             "${CLICKHOUSE_R4_DIR}" "${CLICKHOUSE_TLS_DIR}" "${CLICKHOUSE_TUNNEL_DIR}"; do
    printf '%s\n' "$quiet" > "${dir}/config.d/caliope-quiet-logs.xml"
  done

  cp "${CLICKHOUSE_SEED_DIR}/30-cuentas.sql" "${CLICKHOUSE_REPLICA_SEED_DIR}/30-cuentas.sql"
}

# ── Certificados y arranque del nodo `oracle-tls` (plan 63, tarea 3) ─────────
# Firmado por la MISMA CA del testbed que `clickhouse-tls` (`generate_clickhouse_tls`,
# `30-config-clickhouse.sh`): la llave en `${CLICKHOUSE_TLS_CA_DIR}/ca.key`, fuera de
# `scripts/` y por eso fuera de la imagen, y el certificado en
# `${CLICKHOUSE_TLS_DIR}/ca.crt`. Si aún no existe, se crea con aquella función.
#
# Los tres archivos van con los nombres que pide `/opt/oracle/configTcps.sh` de la
# imagen cuando `TCPS_CERTS_LOCATION` apunta a una carpeta: `ca.crt`, `cert.crt` y
# `client.key` (este último es la llave DEL SERVIDOR, pese al nombre). Entran DENTRO de
# la imagen por `Dockerfile.oracle` (`/opt/oracle/tls`), nunca montados: con el Docker
# en la PC un montaje se resuelve en el disco de ésa.
#
# Como en ClickHouse, el certificado que se sirve es el de quien construyó la imagen la
# última vez (el Mac y la VM tienen cada uno su CA): el cliente usa la copia que
# `copy_oracle_tls_ca` saca del contenedor, `${WORKDIR}/oracle-tls-ca.pem`.
#
# La imagen sólo llama a `configTcps.sh` desde la rama que CREA la base
# (`ENABLE_TCPS=true`), y Free la trae creada: nunca correría (medido el 2026-10-15,
# plan 63, tarea 1). Lo llama `startup/10-tcps.sh`, que la imagen carga en cada
# arranque después de `00-caliope-seed.sh`.
generate_oracle_tls() {
  local dir="${ORACLE_TLS_DIR}" cadir="${CLICKHOUSE_TLS_CA_DIR}"
  local pc_ip=""
  [[ -f "${WORKDIR}/remote.conf" ]] && pc_ip="$(sed -n 's/^host=//p' "${WORKDIR}/remote.conf")"
  local san="DNS:oracle-tls,DNS:localhost,IP:127.0.0.1${pc_ip:+,IP:${pc_ip}}"

  mkdir -p "$dir" "${ORACLE_TLS_STARTUP_DIR}"
  if [[ ! -f "${cadir}/ca.key" || ! -f "${CLICKHOUSE_TLS_DIR}/ca.crt" ]]; then
    generate_clickhouse_tls
  fi
  cp "${CLICKHOUSE_TLS_DIR}/ca.crt" "${dir}/ca.crt"

  # Se rehace si cambió la lista de nombres, si cambió la CA o si caduca en menos de
  # 30 días; 825 días de vida, `serverAuth` y SAN: lo que `SecTrust` exige.
  if [[ ! -f "${dir}/cert.crt" || ! -f "${dir}/client.key" ]] \
     || [[ "$(cat "${dir}/.san" 2>/dev/null)" != "$san" ]] \
     || ! openssl verify -CAfile "${dir}/ca.crt" "${dir}/cert.crt" >/dev/null 2>&1 \
     || ! openssl x509 -checkend 2592000 -noout -in "${dir}/cert.crt" >/dev/null 2>&1; then
    printf '[req]\ndistinguished_name = dn\nprompt = no\n[dn]\nCN = oracle-tls\n' > "${cadir}/oracle-server.cnf"
    printf 'basicConstraints = CA:FALSE\nkeyUsage = critical,digitalSignature,keyEncipherment\nextendedKeyUsage = serverAuth\nsubjectAltName = %s\n' \
      "$san" > "${cadir}/oracle-server.ext"
    openssl req -new -newkey rsa:2048 -nodes -config "${cadir}/oracle-server.cnf" \
      -keyout "${dir}/client.key" -out "${cadir}/oracle-server.csr" 2>/dev/null
    openssl x509 -req -in "${cadir}/oracle-server.csr" -CA "${CLICKHOUSE_TLS_DIR}/ca.crt" -CAkey "${cadir}/ca.key" \
      -CAserial "${cadir}/ca.srl" -CAcreateserial -days 825 -sha256 -extfile "${cadir}/oracle-server.ext" \
      -out "${dir}/cert.crt" 2>/dev/null
    printf '%s' "$san" > "${dir}/.san"
  fi
  # `configTcps.sh` corre como `oracle` y empaqueta la llave en un PKCS#12:
  # `Dockerfile.oracle` copia con `--chown`, y la llave sale legible igual por si no.
  chmod 644 "${dir}/client.key"

  # Lo carga `runUserScripts.sh` con `.` dentro de su bucle (variable `f`): ni `exit`
  # ni `f` aquí. `configTcps.sh` va en su propio `bash`, porque usa `set -e` y `exit`.
  # Idempotente: el listener y el wallet viven en el volumen
  # (`/opt/oracle/oradata/dbconfig/FREE/`), y a partir del segundo arranque el
  # listener ya sale escuchando TCPS y esto no hace nada. `ORACLE_PDB` sólo le da
  # nombre al alias de `tnsnames.ora` (sin él escribe `ORCLPDB1`).
  #
  # Pero «ya escucha TCPS» no basta: el certificado de la imagen cambia cuando cambia el
  # `.san` (la IP de la PC, 2026-10-16) y el wallet del volumen seguía sirviendo el viejo
  # («IP address mismatch»). Por eso se sella en el volumen el SHA-256 del `cert.crt` con
  # que se armó el wallet, y si el de la imagen es otro se vuelve a correr `configTcps.sh`,
  # que rehace el wallet y reconfigura el listener (plan 63, tarea 79). Probado forzando el
  # `.san`: el nodo sale `healthy` con el certificado viejo y `configTcps.sh` tarda unos dos
  # minutos más en servir el nuevo (orapki es lento), así que un cliente que verifique la IP
  # justo al arrancar puede ver todavía el viejo.
  cat > "${ORACLE_TLS_STARTUP_DIR}/10-tcps.sh" <<'ORACLE_TLS_STARTUP_EOF'
# 10-tcps.sh — TCPS en 2484 con los certificados de /opt/oracle/tls (nodo `oracle-tls`).
caliope_tls_stamp="${ORACLE_BASE:-/opt/oracle}/oradata/dbconfig/FREE/.caliope-tls-cert.sha256"
caliope_tls_cert_sha="$(sha256sum /opt/oracle/tls/cert.crt 2>/dev/null | cut -d' ' -f1)"
if lsnrctl status 2>/dev/null | grep -qi 'PROTOCOL=tcps' \
   && [ -n "$caliope_tls_cert_sha" ] \
   && [ "$(cat "$caliope_tls_stamp" 2>/dev/null)" = "$caliope_tls_cert_sha" ]; then
  echo "[caliope] oracle-tls: el listener ya escucha TCPS con el certificado de la imagen"
else
  echo "[caliope] oracle-tls: se configura TCPS en 2484 con la CA del testbed"
  if ORACLE_PDB=FREEPDB1 bash /opt/oracle/configTcps.sh 2484 oracle-tls; then
    printf '%s' "$caliope_tls_cert_sha" > "$caliope_tls_stamp"
  else
    echo "[caliope] oracle-tls: configTcps.sh falló; el healthcheck lo dirá"
  fi
fi
ORACLE_TLS_STARTUP_EOF
}
extract_all() {
  step "Extracting the embedded files into ${WORKDIR}…"

  # ── docker-compose.yml ────────────────────────────────────────────────────
  cat > "${COMPOSE_FILE}" <<'COMPOSE_EOF'
# Caliope test environment — full topology.
#
# All configuration is passed via command-line flags to mariadb; helper
# scripts live inside their own images (Dockerfile.setup, Dockerfile.loadgen,
# Dockerfile.bastion) so Docker Desktop does not need extra file-sharing
# permissions beyond the user's home directory.
#
# Y las semillas también van DENTRO de imágenes (`Dockerfile.seeded`), nunca
# montadas desde una carpeta del Mac: con el destino remoto (`--remote`) el
# Docker que arranca los contenedores está en otra máquina, y un montaje
# `./scripts/seed:...` se resuelve en el disco de ESA máquina, donde la carpeta
# no existe. Docker no falla por eso: monta un directorio vacío, el nodo arranca
# sin semilla y se parece a un fallo de la app. Lo que sí viaja es el contexto
# de construcción, que el cliente sube por la conexión.
#
# Los doce nodos MariaDB/MySQL llevan estas cuatro banderas de log
# lento, y van en el `command:` a propósito y no por `SET GLOBAL`:
#
#   --slow-query-log=ON            enciende el registro
#   --log-output=TABLE             a `mysql.slow_log`, que un cliente sí lee
#   --long-query-time=0.1          umbral bajo: el testbed tiene que producir
#   --log-slow-admin-statements=ON MariaDB ya lo trae ON; MySQL viene en OFF
#
# `--log-output=TABLE` es lo que hace la herramienta posible: con el valor por
# omisión (FILE) las entradas van a un archivo del contenedor y ningún cliente
# MySQL puede leerlo — no hay sentencia que lo devuelva. Y el umbral va en el
# arranque porque `SET GLOBAL long_query_time` **no alcanza a las sesiones ya
# abiertas** (medido el 2026-09-03): con las conexiones persistentes del
# generador de carga y el pool de Calíope ya en pie, escribirlo después deja
# fuera justo a quien produce la carga.

services:

  # ────────────────────── PRIMARY NODE ─────────────────────────
  primary:
    image: mariadb:latest
    container_name: mariadb-primary
    command:
      - --server-id=1
      - --log-bin=mariadb-bin
      - --binlog-format=ROW
      - --gtid-strict-mode=ON
      - --log-slave-updates=ON
      - --expire-logs-days=7
      - --event-scheduler=ON
      - --max-connections=500
      - --thread-cache-size=100
      - --innodb-buffer-pool-size=256M
      - --innodb-log-file-size=64M
      - --table-open-cache=2000
      - --slow-query-log=ON
      - --log-output=TABLE
      - --long-query-time=0.1
      - --log-slow-admin-statements=ON
    environment:
      MARIADB_ROOT_PASSWORD: rootpass
      MARIADB_DATABASE:      demo
      MARIADB_USER:          caliope
      MARIADB_PASSWORD:      caliopepass
    volumes:
      - primary-data:/var/lib/mysql
    ports:
      - "3311:3306"
    networks:
      - replication
    healthcheck:
      test: ["CMD", "mariadb-admin", "--user=root", "--password=rootpass", "ping"]
      interval: 5s
      timeout: 5s
      retries: 30
      start_period: 20s
    restart: unless-stopped

  # ────────────────────── REPLICAS ─────────────────────────────
  replica1:
    image: mariadb:latest
    container_name: mariadb-replica1
    command:
      - --server-id=2
      - --log-bin=mariadb-bin
      - --binlog-format=ROW
      - --relay-log=relay-bin
      - --gtid-strict-mode=ON
      - --log-slave-updates=ON
      - --read-only=ON
      - --max-connections=500
      - --thread-cache-size=100
      - --innodb-buffer-pool-size=256M
      - --innodb-log-file-size=64M
      - --table-open-cache=2000
      - --slow-query-log=ON
      - --log-output=TABLE
      - --long-query-time=0.1
      - --log-slow-admin-statements=ON
    environment:
      MARIADB_ROOT_PASSWORD: rootpass
    volumes:
      - replica1-data:/var/lib/mysql
    ports:
      - "3312:3306"
    networks:
      - replication
    healthcheck:
      test: ["CMD", "mariadb-admin", "--user=root", "--password=rootpass", "ping"]
      interval: 5s
      timeout: 5s
      retries: 30
      start_period: 20s
    restart: unless-stopped

  replica2:
    image: mariadb:latest
    container_name: mariadb-replica2
    command:
      - --server-id=3
      - --log-bin=mariadb-bin
      - --binlog-format=ROW
      - --relay-log=relay-bin
      - --gtid-strict-mode=ON
      - --log-slave-updates=ON
      - --read-only=ON
      - --max-connections=500
      - --thread-cache-size=100
      - --innodb-buffer-pool-size=256M
      - --innodb-log-file-size=64M
      - --table-open-cache=2000
      - --slow-query-log=ON
      - --log-output=TABLE
      - --long-query-time=0.1
      - --log-slow-admin-statements=ON
    environment:
      MARIADB_ROOT_PASSWORD: rootpass
    volumes:
      - replica2-data:/var/lib/mysql
    ports:
      - "3313:3306"
    networks:
      - replication
    healthcheck:
      test: ["CMD", "mariadb-admin", "--user=root", "--password=rootpass", "ping"]
      interval: 5s
      timeout: 5s
      retries: 30
      start_period: 20s
    restart: unless-stopped

  # La cascada (David, 2026-10-13): `replica3` lee del primario y `replica4` lee
  # de `replica3`, no del primario. Es el caso de la réplica intermedia que se
  # usa para descargar al primario, y el que da al diagrama de la Topología una
  # rama de dos niveles: el colector decide quién lee de quién por `server_id`.
  # `replica3` necesita `log-slave-updates` —ya lo llevan todas— para escribir en
  # su binlog lo que recibe, que es de donde lee `replica4`. Las dos volvieron a
  # sus puertos de antes de la poda del 2026-09-12 (3314 y 3315), cuando el
  # testbed dejó el Mac por la PC.
  replica3:
    image: mariadb:latest
    container_name: mariadb-replica3
    command:
      - --server-id=4
      - --log-bin=mariadb-bin
      - --binlog-format=ROW
      - --relay-log=relay-bin
      - --gtid-strict-mode=ON
      - --log-slave-updates=ON
      - --read-only=ON
      - --max-connections=500
      - --thread-cache-size=100
      - --innodb-buffer-pool-size=256M
      - --innodb-log-file-size=64M
      - --table-open-cache=2000
      - --slow-query-log=ON
      - --log-output=TABLE
      - --long-query-time=0.1
      - --log-slow-admin-statements=ON
    environment:
      MARIADB_ROOT_PASSWORD: rootpass
    volumes:
      - replica3-data:/var/lib/mysql
    ports:
      - "3314:3306"
    networks:
      - replication
    healthcheck:
      test: ["CMD", "mariadb-admin", "--user=root", "--password=rootpass", "ping"]
      interval: 5s
      timeout: 5s
      retries: 30
      start_period: 20s
    restart: unless-stopped

  replica4:
    image: mariadb:latest
    container_name: mariadb-replica4
    command:
      - --server-id=5
      - --log-bin=mariadb-bin
      - --binlog-format=ROW
      - --relay-log=relay-bin
      - --gtid-strict-mode=ON
      - --log-slave-updates=ON
      - --read-only=ON
      - --max-connections=500
      - --thread-cache-size=100
      - --innodb-buffer-pool-size=256M
      - --innodb-log-file-size=64M
      - --table-open-cache=2000
      - --slow-query-log=ON
      - --log-output=TABLE
      - --long-query-time=0.1
      - --log-slow-admin-statements=ON
    environment:
      MARIADB_ROOT_PASSWORD: rootpass
    volumes:
      - replica4-data:/var/lib/mysql
    ports:
      - "3315:3306"
    networks:
      - replication
    healthcheck:
      test: ["CMD", "mariadb-admin", "--user=root", "--password=rootpass", "ping"]
      interval: 5s
      timeout: 5s
      retries: 30
      start_period: 20s
    restart: unless-stopped

  # ────────────────────── STANDALONE NODE ──────────────────────
  # Independent node, outside replication. Useful for isolated tests
  # or schema comparison. Applies the shared seed on first data-dir
  # initialization: Dockerfile.seeded copies it into /docker-entrypoint-initdb.d.
  #
  # Ni éste ni los dos MySQL de abajo esperan a `setup` desde el 2026-10-13. Lo
  # hacían sólo para arrancar después, y no necesitan nada de él: su semilla va
  # en su imagen. Con la espera, elegir «MySQL» en el asistente arrastraba el
  # grupo de MariaDB entero, porque `setup` depende del primario.
  #
  # Y es el nodo de las maneras de autenticarse de MariaDB (plan 61, tarea 1):
  # carga `auth_ed25519`, `auth_parsec` y `auth_pam` al arrancar, y no por
  # `INSTALL SONAME`, porque `pam_use_cleartext_plugin` sólo se fija con el plugin
  # cargado desde la línea de órdenes. Con esa variable en ON el servidor pide la
  # contraseña de una cuenta PAM por `mysql_clear_password`, que es el único
  # servidor del testbed que la pide: el cliente tiene que negarse a mandarla sin
  # TLS. Su imagen (`Dockerfile.mariadb-standalone`) añade al sistema el usuario
  # `pamuser` y `/etc/pam.d/mariadb`; las cuentas las crea
  # `ensure_protocol_fixtures` después de `up`. Su TLS lleva un certificado
  # que genera su entrypoint en `/tls` (plan 61, tarea 4), y la CA queda en
  # `~/CaliopeTestbed/mariadb-standalone-ca.pem`.
  standalone:
    build:
      context: .
      dockerfile: Dockerfile.mariadb-standalone
    container_name: mariadb-standalone
    command:
      - --plugin-load-add=auth_ed25519
      - --plugin-load-add=auth_parsec
      - --plugin-load-add=auth_pam
      - --pam-use-cleartext-plugin=ON
      - --event-scheduler=ON
      - --slow-query-log=ON
      - --log-output=TABLE
      - --long-query-time=0.1
      - --log-slow-admin-statements=ON
      - --ssl-cert=/tls/server.crt
      - --ssl-key=/tls/server.key
      - --ssl-ca=/tls/server.crt
    environment:
      MARIADB_ROOT_PASSWORD: rootpass
      MARIADB_DATABASE:      demo
      MARIADB_USER:          caliope
      MARIADB_PASSWORD:      caliopepass
      MARIADB_TLS_HOST:      ${MONGO_RS_HOST:-}
    volumes:
      - standalone-data:/var/lib/mysql
    ports:
      - "3316:3306"
    networks:
      - replication
    healthcheck:
      test: ["CMD", "mariadb-admin", "--user=root", "--password=rootpass", "ping"]
      interval: 5s
      timeout: 5s
      retries: 30
      start_period: 20s
    restart: unless-stopped

  # ────────────────────── MYSQL NODES ──────────────────────────
  # Two independent MySQL servers, outside the MariaDB replication
  # topology. Same credentials as the rest of the cluster so profiles
  # can be swapped without re-editing user/password fields. Both mount
  # the shared seed the same way as mariadb-standalone.

  mysql-latest:
    build:
      context: .
      dockerfile: Dockerfile.seeded
      args:
        BASE: mysql:latest
        SEED: scripts/seed
        DEST: /docker-entrypoint-initdb.d
    container_name: mysql-latest
    # `--local-infile=1`: sin él el servidor contesta 3948 a `LOAD DATA LOCAL`
    # antes de pedir el archivo, y el cliente no tiene nada que probar (plan 61).
    command:
      - --local-infile=1
      - --event-scheduler=ON
      - --slow-query-log=ON
      - --log-output=TABLE
      - --long-query-time=0.1
      - --log-slow-admin-statements=ON
    environment:
      MYSQL_ROOT_PASSWORD: rootpass
      MYSQL_DATABASE:      demo
      MYSQL_USER:          caliope
      MYSQL_PASSWORD:      caliopepass
    volumes:
      - mysql-latest-data:/var/lib/mysql
    ports:
      - "3320:3306"
    networks:
      - replication
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-prootpass"]
      interval: 5s
      timeout: 5s
      retries: 30
      start_period: 20s
    restart: unless-stopped

  # Este nodo lleva DOS cosas, y el nombre lo dice: el seed compartido (`demo`,
  # `rrhh`, `analytics`) y, desde el 2026-09-12, los datos espaciales que antes
  # vivían en un contenedor propio (`caliope-gis`, puerto 3322, MySQL 8.0 también).
  #
  # Que sean espaciales y que sean de MySQL 8 va junto, y es el motivo de que el
  # plegado no pierda nada: MariaDB guarda un SRID pero lo ignora para el orden
  # de ejes —X es siempre longitud—, mientras MySQL 8 honra el orden que declara
  # el SRS, y EPSG:4326 declara LATITUD PRIMERO. O sea que el mismo WKT señala
  # dos sitios distintos según el motor, y ése es el fallo que estas bases
  # existen para cazar. Ningún nodo MariaDB puede cazarlo.
  #
  # Tres bases espaciales, para tener las dos convenciones al lado:
  #   gis            → SRID 4326, latitud primero (semántica de MySQL)
  #   gis_cartesian  → SRID 0, X = longitud (la convención portable)
  #
  # El seed de este nodo es UN directorio propio, `scripts/mysql8-gis-seed`, que
  # el lanzador arma copiando el seed compartido y detrás el espacial con
  # prefijos 80-86. Dos cosas obligan a eso: el entrypoint de MySQL sólo aplica
  # lo que encuentra en /docker-entrypoint-initdb.d y no entra en
  # subdirectorios, y montar archivo a archivo dentro de ese directorio no se
  # puede cuando ya es un montaje `:ro` — el runtime falla con «make mountpoint
  # …: read-only file system» y el contenedor no arranca.
  #
  # mysql_native_password porque es contra lo que el cliente autentica sin TLS.
  mysql8-gis:
    build:
      context: .
      dockerfile: Dockerfile.seeded
      args:
        BASE: mysql:8.0
        SEED: scripts/mysql8-gis-seed
        DEST: /docker-entrypoint-initdb.d
    container_name: mysql8-gis
    command:
      - --local-infile=1
      - --event-scheduler=ON
      - --default-authentication-plugin=mysql_native_password
      - --innodb-buffer-pool-size=256M
      - --slow-query-log=ON
      - --log-output=TABLE
      - --long-query-time=0.1
      - --log-slow-admin-statements=ON
    environment:
      MYSQL_ROOT_PASSWORD: rootpass
      MYSQL_DATABASE:      demo
      MYSQL_USER:          caliope
      MYSQL_PASSWORD:      caliopepass
    volumes:
      - mysql8-data:/var/lib/mysql
    ports:
      - "3321:3306"
    networks:
      - replication
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-prootpass"]
      interval: 5s
      timeout: 5s
      retries: 30
      start_period: 20s
    restart: unless-stopped

  # ────────────────── MYSQL REPLICATION GROUP ──────────────────
  # Un grupo de MySQL de Oracle, aparte del de MariaDB (David, 2026-10-13): el
  # primario y dos réplicas por GTID, en la LTS 8.4 fijada por número. Existe
  # porque el colector de topología tiene ramas que sólo toma contra MySQL
  # —`SHOW REPLICAS`, `SHOW BINARY LOG STATUS` (8.4+) y `Executed_Gtid_Set` en
  # formato `uuid:intervalo`, no el `dominio-servidor-secuencia` de MariaDB— y
  # hasta hoy ningún nodo del testbed las ejercitaba: `mysql-latest` y
  # `mysql8-gis` no replican. Con éste quedan las tres generaciones: 8.0
  # (`mysql8-gis`), la LTS (este grupo) y la más nueva (`mysql-latest`).
  #
  # El primario se siembra con la semilla compartida por su `initdb.d`, y el
  # servidor temporal del entrypoint ya arranca con estas mismas banderas, así
  # que la siembra entra en el binlog con sus GTID. Las réplicas nacen vacías y
  # la reciben entera por autoposición (`SOURCE_AUTO_POSITION=1`): no hace falta
  # volcado. Por eso las réplicas llevan `MYSQL_INITDB_SKIP_TZINFO`: cargar las
  # zonas horarias en su arranque les daría GTID propios que el primario no
  # tiene, y la Topología los contaría como deriva.
  #
  # Las tres usan `caching_sha2_password`, la autenticación por omisión de 8.4:
  # `mysql_native_password` ya no viene cargado. La replicación la configura
  # `mysql-setup`, y la carga ligera, `mysql-loadgen`.
  mysql-primary:
    build:
      context: .
      dockerfile: Dockerfile.seeded
      args:
        BASE: mysql:8.4
        SEED: scripts/seed
        DEST: /docker-entrypoint-initdb.d
    container_name: mysql-primary
    command:
      - --server-id=101
      - --log-bin=mysql-bin
      - --gtid-mode=ON
      - --enforce-gtid-consistency=ON
      - --event-scheduler=ON
      - --innodb-buffer-pool-size=128M
      - --slow-query-log=ON
      - --log-output=TABLE
      - --long-query-time=0.1
      - --log-slow-admin-statements=ON
    environment:
      MYSQL_ROOT_PASSWORD: rootpass
      MYSQL_DATABASE:      demo
      MYSQL_USER:          caliope
      MYSQL_PASSWORD:      caliopepass
    volumes:
      - mysql-primary-data:/var/lib/mysql
    ports:
      - "3322:3306"
    networks:
      - replication
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-prootpass"]
      interval: 5s
      timeout: 5s
      retries: 30
      start_period: 30s
    restart: unless-stopped

  mysql-replica1:
    image: mysql:8.4
    container_name: mysql-replica1
    command:
      - --server-id=102
      - --log-bin=mysql-bin
      - --relay-log=relay-bin
      - --gtid-mode=ON
      - --enforce-gtid-consistency=ON
      - --read-only=ON
      - --innodb-buffer-pool-size=128M
      - --slow-query-log=ON
      - --log-output=TABLE
      - --long-query-time=0.1
      - --log-slow-admin-statements=ON
    environment:
      MYSQL_ROOT_PASSWORD:      rootpass
      MYSQL_INITDB_SKIP_TZINFO: "1"
    volumes:
      - mysql-replica1-data:/var/lib/mysql
    ports:
      - "3323:3306"
    networks:
      - replication
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-prootpass"]
      interval: 5s
      timeout: 5s
      retries: 30
      start_period: 30s
    restart: unless-stopped

  mysql-replica2:
    image: mysql:8.4
    container_name: mysql-replica2
    command:
      - --server-id=103
      - --log-bin=mysql-bin
      - --relay-log=relay-bin
      - --gtid-mode=ON
      - --enforce-gtid-consistency=ON
      - --read-only=ON
      - --innodb-buffer-pool-size=128M
      - --slow-query-log=ON
      - --log-output=TABLE
      - --long-query-time=0.1
      - --log-slow-admin-statements=ON
    environment:
      MYSQL_ROOT_PASSWORD:      rootpass
      MYSQL_INITDB_SKIP_TZINFO: "1"
    volumes:
      - mysql-replica2-data:/var/lib/mysql
    ports:
      - "3324:3306"
    networks:
      - replication
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-prootpass"]
      interval: 5s
      timeout: 5s
      retries: 30
      start_period: 30s
    restart: unless-stopped

  # Configura las dos réplicas y se apaga, como `setup` en MariaDB. Idempotente:
  # una réplica que ya tiene origen sólo recibe `START REPLICA`, nunca un
  # `CHANGE REPLICATION SOURCE` que le mueva la posición. `GET_SOURCE_PUBLIC_KEY`
  # porque la cuenta de replicación es `caching_sha2_password` y el canal va sin
  # TLS: sin la clave pública del servidor, la réplica no puede mandar la
  # contraseña y el hilo de E/S se queda en «Connecting» con el error 2061.
  # Una réplica que no sincroniza es un aviso, no un fallo del arranque: sale
  # con 0, por lo mismo que `setup-replication.sh`.
  mysql-setup:
    image: mysql:8.4
    container_name: mysql-setup
    depends_on:
      mysql-primary:
        condition: service_healthy
      mysql-replica1:
        condition: service_healthy
      mysql-replica2:
        condition: service_healthy
    environment:
      MYSQL_PWD: rootpass
    entrypoint: ["bash", "-c"]
    command:
      - |
        q() { mysql -h "$$1" -uroot --silent --skip-column-names -e "$$2"; }
        for h in mysql-primary mysql-replica1 mysql-replica2; do
          for i in $$(seq 1 60); do q $$h "SELECT 1" >/dev/null 2>&1 && break; sleep 2; done
        done
        q mysql-primary "CREATE USER IF NOT EXISTS 'replicator'@'%' IDENTIFIED BY 'replicapass'; GRANT REPLICATION SLAVE ON *.* TO 'replicator'@'%'"
        ok=0
        for r in mysql-replica1 mysql-replica2; do
          if [ -z "$$(q $$r 'SHOW REPLICA STATUS')" ]; then
            q $$r "CHANGE REPLICATION SOURCE TO SOURCE_HOST='mysql-primary', SOURCE_USER='replicator', SOURCE_PASSWORD='replicapass', SOURCE_AUTO_POSITION=1, GET_SOURCE_PUBLIC_KEY=1"
            echo "[caliope] $$r: origen mysql-primary configurado"
          fi
          q $$r "START REPLICA" 2>/dev/null
          for i in $$(seq 1 30); do
            st=$$(mysql -h $$r -uroot -e 'SHOW REPLICA STATUS\G' 2>/dev/null)
            io=$$(printf '%s\n' "$$st" | awk '/ Replica_IO_Running:/{print $$2}')
            sq=$$(printf '%s\n' "$$st" | awk '/ Replica_SQL_Running:/{print $$2}')
            [ "$$io" = "Yes" ] && [ "$$sq" = "Yes" ] && break
            sleep 2
          done
          if [ "$$io" = "Yes" ] && [ "$$sq" = "Yes" ]; then
            echo "[caliope] $$r: replicando (IO=$$io, SQL=$$sq)"
            ok=$$((ok + 1))
          else
            echo "[caliope] AVISO: $$r no replica (IO=$$io, SQL=$$sq)"
            printf '%s\n' "$$st" | grep -E 'Last_(IO_|SQL_)?Error:' | sed 's/^ */[caliope]   /'
          fi
        done
        echo "[caliope] MySQL replication configured. Replicas: $$ok of 2"
    networks:
      - replication
    restart: "no"

  # Carga ligera para el grupo de MySQL (David, 2026-10-13): una escritura por
  # segundo en el primario, para que el GTID avance y el retraso se mueva, y una
  # consulta lenta cada 4 s, para que el registro de lentas del primario tenga
  # algo que enseñar. Es una fracción de lo que `loadgen` mete en MariaDB a
  # propósito: la CPU es lo que limita la PC del testbed. Va en su propio
  # contenedor y no como un trabajador de `loadgen` porque ése espera al grupo
  # de MariaDB, y el asistente puede levantar MySQL sin él. La tabla se acota:
  # cada cuatro escrituras una borra lo que pasa de las 2000 filas de prueba.
  mysql-loadgen:
    image: mysql:8.4
    container_name: mysql-loadgen
    depends_on:
      mysql-setup:
        condition: service_completed_successfully
    environment:
      MYSQL_PWD: rootpass
    entrypoint: ["bash", "-c"]
    command:
      - |
        trap '' PIPE
        until mysql -h mysql-primary -uroot -e 'SELECT 1' demo >/dev/null 2>&1; do sleep 2; done
        echo "[caliope] mysql-loadgen: mysql-primary responde; una escritura por segundo y una lenta cada 4 s"
        n=0
        while true; do
          rm -f /tmp/w; mkfifo /tmp/w
          mysql -h mysql-primary -uroot demo < /tmp/w > /dev/null 2>&1 &
          pid=$$!
          exec 3> /tmp/w
          while kill -0 $$pid 2>/dev/null; do
            n=$$((n + 1))
            case $$((n % 4)) in
              0) sql="DELETE FROM productos WHERE nombre LIKE 'Carga-%' AND id < (SELECT m FROM (SELECT MAX(id) - 2000 AS m FROM productos) t);" ;;
              1) sql="INSERT INTO productos (nombre, categoria, precio, stock) VALUES (CONCAT('Carga-', LPAD(FLOOR(RAND() * 99999), 5, '0')), ELT(FLOOR(1 + RAND() * 5), 'electronica', 'cables', 'periféricos', 'accesorios', 'pantallas'), ROUND(1 + RAND() * 299, 2), FLOOR(RAND() * 200));" ;;
              2) sql="UPDATE productos SET stock = GREATEST(0, stock + FLOOR(-10 + RAND() * 20)) WHERE id = (SELECT id FROM (SELECT id FROM productos ORDER BY id DESC LIMIT 1) t);" ;;
              3) sql="INSERT INTO clientes (nombre, email, ciudad) VALUES (CONCAT('Carga-', FLOOR(RAND() * 99999)), CONCAT('c', FLOOR(RAND() * 99999999), '@carga.local'), 'San José') ON DUPLICATE KEY UPDATE ciudad = VALUES(ciudad);" ;;
            esac
            printf '%s\n' "$$sql" >&3 || break
            if [ $$((n % 4)) = 0 ]; then
              case $$(((n / 4) % 3)) in
                0) printf '%s\n' "SELECT SLEEP(0.3);" >&3 ;;
                1) printf '%s\n' "SELECT COUNT(*) AS filas, SLEEP(0.2) AS pausa FROM demo.productos;" >&3 ;;
                2) printf '%s\n' "SELECT id FROM demo.productos WHERE SLEEP(0.02) = 0 ORDER BY id LIMIT 8;" >&3 ;;
              esac
            fi
            sleep 1
          done
          exec 3>&-
          sleep 2
        done
    networks:
      - replication
    restart: unless-stopped

  # ────────────────────── POSTGRESQL ───────────────────────────
  # Tres nodos, y cada uno existe por una razón distinta:
  #
  #   postgres-gis      3330  PostGIS 17   → el nodo de trabajo: esquema, consultas,
  #                                          catálogos, respaldo, usuarios, búsqueda
  #                                          global Y datos espaciales
  #   postgres-primary  3331  Postgres 17  → origen de replicación por streaming
  #   postgres-replica  3332  Postgres 17  → réplica física (standby)
  #
  # El nodo de trabajo es PostGIS y no Postgres a secas porque la imagen de PostGIS
  # ES Postgres con la extensión encima: dos coberturas en un contenedor. Lo que no
  # se puede es al revés — un `postgres:17` no sabe de geometrías.
  #
  # La imagen NO es `postgis/postgis` y eso no es un descuido: la oficial se publica
  # SÓLO para linux/amd64, así que en un Mac con Apple Silicon o no arranca o arranca
  # emulada — una base de datos bajo qemu no es una cama de pruebas, es otro sistema.
  # `imresamu/postgis` es la construcción multi-arquitectura de esos mismos
  # Dockerfiles, hecha por el propio mantenedor de las imágenes de PostGIS, y trae
  # amd64 y arm64. Y la etiqueta lleva la versión de PostGIS (`17-3.5`) porque ese
  # repositorio no publica tags de sólo versión mayor.
  # Comprobado con `docker manifest inspect` el 2026-08-18.
  #
  # Credenciales iguales que el resto del testbed, con una diferencia que importa:
  # en Postgres NO hay un `root` aparte. `caliope` ES el superusuario, así que aquí
  # no hay pareja root/rootpass que buscar.
  # Los tres nodos llevan las mismas tres flags de consultas lentas, y aquí la
  # forma no se parece a la de MySQL:
  #
  #   · `shared_preload_libraries=pg_stat_statements` — el módulo *contrib* que
  #     acumula una fila por sentencia normalizada. Es lo ÚNICO que un cliente
  #     puede leer: el `log_min_duration_statement` de abajo escribe al log del
  #     servidor, que es un archivo del contenedor y no una tabla. Y va aquí y no
  #     por `ALTER SYSTEM` porque `shared_preload_libraries` exige reinicio: un
  #     `CREATE EXTENSION` sin la biblioteca precargada falla con 55000.
  #   · `pg_stat_statements.track=all` — cuenta también las sentencias de dentro
  #     de funciones y procedimientos, no sólo las de primer nivel.
  #   · `log_min_duration_statement=250` — el umbral, en milisegundos. No es el
  #     mismo número que el 0,1 s de MySQL a propósito: ahí la unidad son
  #     segundos y aquí milisegundos, y confundirlas da 250 s o 0,1 ms.
  #
  # Las dos primeras van en `command:` por el mismo motivo que en MySQL: lo que se
  # aplica arrancando no depende de que alguien lo ejecute después. **El umbral,
  # en este nodo, NO** — y ésa es la diferencia por la que este nodo existe frente
  # a los otros dos:
  #
  #   Un `-c` en la línea de órdenes del postmaster GANA sobre `ALTER SYSTEM`, que
  #   es con lo que Calíope escribe el umbral, y no lo dice: la sentencia devuelve
  #   éxito, `pg_reload_conf()` devuelve `t`, el valor entra en
  #   `postgresql.auto.conf` **y el valor efectivo no se mueve** (medido el
  #   2026-09-05 en un contenedor de usar y tirar con esta misma imagen). Con el
  #   `-c` puesto aquí, el paso 65 de QA no podía ejercitar el escritor de Postgres
  #   en ninguna tanda: leía `source = command line`, declaraba la configuración
  #   como externa —que es la verdad— y se saltaba la ida y vuelta.
  #
  # Así que aquí el umbral lo fija el seed con `ALTER SYSTEM` (que reporta
  # `source = configuration file`, y a eso `ALTER SYSTEM` sí le gana), y en
  # `postgres-primary` y `postgres-replica` **se queda en `command:` a propósito**:
  # el testbed necesita los dos estados, porque el caso «el ajuste lo fija algo que
  # la app no puede cambiar» es el que la herramienta tiene que declarar en vez de
  # ofrecer un control que no mueve nada.
  #
  # Y es el nodo de las maneras de autenticarse de Postgres (plan 61, tarea 1): TLS
  # con un certificado que genera su entrypoint (`/tls`, con la IP de la PC en el
  # SAN cuando es remoto, como `mongo-tls`) y un `pg_hba.conf` de la imagen con las
  # cuentas `md5user` (md5) y `clearuser` (contraseña en claro, sólo por TLS) por
  # delante de las genéricas. `ssl`, los certificados y `hba_file` van en `-c`
  # porque el volumen ya existe y `initdb.d` no vuelve a correr; las cuentas las
  # crea `ensure_protocol_fixtures` después de `up`, y la CA queda en
  # `~/CaliopeTestbed/postgres-gis-ca.pem`.
  postgres-gis:
    build:
      context: .
      dockerfile: Dockerfile.postgres-gis
    container_name: postgres-gis
    # Sin `-c log_min_duration_statement`: lo pone el seed con `ALTER SYSTEM`, para
    # que el escritor de la app pueda ganarle. El porqué, entero, arriba.
    command:
      - postgres
      - -c
      - shared_preload_libraries=pg_stat_statements
      - -c
      - pg_stat_statements.track=all
      - -c
      - ssl=on
      - -c
      - ssl_cert_file=/tls/server.crt
      - -c
      - ssl_key_file=/tls/server.key
      - -c
      - hba_file=/etc/caliope/pg_hba.conf
    environment:
      POSTGRES_USER:     caliope
      POSTGRES_PASSWORD: caliopepass
      POSTGRES_DB:       demo
      PG_TLS_HOST:       ${MONGO_RS_HOST:-}
    volumes:
      - postgres-gis-data:/var/lib/postgresql/data
    ports:
      - "3330:5432"
    networks:
      - replication
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U caliope -d demo"]
      interval: 5s
      timeout: 5s
      retries: 30
      start_period: 20s
    restart: unless-stopped

  # El primario monta `pg-setup` en initdb.d: crea el rol de replicación y abre la
  # línea de `pg_hba.conf` que hace falta. Y hace falta de verdad — en pg_hba la
  # palabra `all` de la columna de base de datos NO cubre las conexiones de
  # replicación, así que la entrada que pone la imagen oficial deja pasar al cliente
  # y rechaza al standby, con el resto en verde.
  #
  # **La imagen es la de PostGIS, igual que el 3330, y el motivo es el arnés de respaldos.**
  # El nodo existe por la replicación y eso no cambia; lo que cambia es que además es el
  # destino donde `verify-backup-roundtrip.py` restaura lo que produce el 3330. Con
  # `postgres:17` a secas, restaurar el seed moría en la primera tabla con una geometría
  # —«type "geometry" does not exist», y luego «extension postgis is not available», que es
  # el mismo problema un nivel más abajo: los binarios de la extensión no están en la imagen—.
  # Un destino que no puede aceptar lo que el origen produce no cierra ningún bucle. La
  # versión mayor es la misma, así que el directorio de datos ya creado sirve tal cual.
  postgres-primary:
    build:
      context: .
      dockerfile: Dockerfile.seeded
      args:
        BASE: imresamu/postgis:17-3.5
        SEED: scripts/pg-setup
        DEST: /docker-entrypoint-initdb.d
    container_name: postgres-primary
    command:
      - postgres
      - -c
      - shared_preload_libraries=pg_stat_statements
      - -c
      - pg_stat_statements.track=all
      - -c
      - log_min_duration_statement=250
    environment:
      POSTGRES_USER:     caliope
      POSTGRES_PASSWORD: caliopepass
      POSTGRES_DB:       demo
    volumes:
      - postgres-primary-data:/var/lib/postgresql/data
    ports:
      - "3331:5432"
    networks:
      - replication
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U caliope -d demo"]
      interval: 5s
      timeout: 5s
      retries: 30
      start_period: 20s
    restart: unless-stopped

  # La réplica hay que PROVISIONARLA, no sólo levantarla: un standby físico no se
  # crea con un `CHANGE MASTER`, se clona con `pg_basebackup` desde el primario.
  #
  # Dos reglas del testbed, que aquí se ven bien:
  #
  #   · A una réplica sana no se le toca la posición. El clonado sólo ocurre si
  #     PGDATA está vacío; en cualquier arranque posterior este bloque no hace nada
  #     y el standby sigue desde donde iba.
  #   · Un fallo de replicación no deja el testbed inservible. `depends_on` con
  #     `service_healthy` es lo que evita el caso feo: sin primario, este contenedor
  #     ni se intenta, en vez de girar en un bucle de clonados a medias.
  #
  # `pg_basebackup -R` escribe `primary_conninfo` SIN la contraseña —la omite a
  # propósito—, así que la línea de después no es redundante: sin ella el standby
  # arranca, no autentica, y se queda reintentando en silencio. Se anexa después
  # porque en `postgresql.auto.conf` gana la última.
  # Y la réplica, la misma imagen que su primario: `pg_basebackup` copia el directorio de
  # datos byte a byte, catálogo incluido, así que un `pg_extension` que nombre una extensión
  # cuyos binarios no estén aquí deja la réplica arrancando y fallando al resolver el tipo.
  postgres-replica:
    image: imresamu/postgis:17-3.5
    container_name: postgres-replica
    depends_on:
      postgres-primary:
        condition: service_healthy
    environment:
      PGDATA: /var/lib/postgresql/data/pgdata
    volumes:
      - postgres-replica-data:/var/lib/postgresql/data
    ports:
      - "3332:5432"
    networks:
      - replication
    entrypoint:
      - bash
      - -c
      - |
        set -e
        if [ ! -s "$$PGDATA/PG_VERSION" ]; then
          for i in $$(seq 1 60); do
            pg_isready -h postgres-primary -U caliope -q && break
            sleep 2
          done
          if ! pg_isready -h postgres-primary -U caliope -q; then
            echo "[caliope] postgres-primary no responde tras 120 s; no se clona la réplica" >&2
            exit 1
          fi
          mkdir -p "$$PGDATA"
          rm -rf "$$PGDATA"/*
          chown postgres:postgres "$$PGDATA"
          echo "[caliope] clonando desde postgres-primary con pg_basebackup..."
          PGPASSWORD=replicapass gosu postgres pg_basebackup \
            -h postgres-primary -U replicator -D "$$PGDATA" \
            -Fp -Xs -R -C -S caliope_replica_slot -P
          echo "primary_conninfo = 'host=postgres-primary port=5432 user=replicator password=replicapass application_name=caliope_replica'" \
            >> "$$PGDATA/postgresql.auto.conf"
          chmod 0700 "$$PGDATA"
          echo "[caliope] réplica clonada."
        else
          echo "[caliope] la réplica ya tiene datos; no se re-clona."
        fi
        exec docker-entrypoint.sh postgres \
          -c shared_preload_libraries=pg_stat_statements \
          -c pg_stat_statements.track=all \
          -c log_min_duration_statement=250
    healthcheck:
      # `pg_isready` devuelve 0 en un standby en cuanto acepta conexiones de sólo
      # lectura, que es exactamente lo que se quiere comprobar aquí.
      test: ["CMD-SHELL", "pg_isready -U caliope -d demo"]
      interval: 5s
      timeout: 5s
      retries: 30
      start_period: 30s
    restart: unless-stopped

  # ────────────────────── SQL SERVER ───────────────────────────
  # Un nodo, el de trabajo del cuarto motor (plan 12). La imagen es la 2025 y no
  # la 2022 porque es la misma que corre el servidor del revisor, y Ubuntu 24.04
  # no soporta la 2022.
  #
  # Es la ÚNICA imagen del testbed que corre emulada: Microsoft la publica sólo
  # para linux/amd64. En este Mac la emulación es Rosetta, no QEMU —medido el
  # 2026-09-29: el proceso amd64 mapea `/run/rosetta/rosetta`—, y con QEMU no
  # sería una cama de pruebas. Si algún día sale QEMU, se enciende «Use Rosetta
  # for x86/amd64 emulation» en Docker Desktop → Settings → General.
  #
  # La colación es la UTF-8 porque el seed lleva acentos: con la de por omisión
  # un `VARCHAR` con «Zúrich» se guarda en la página de códigos del servidor y
  # el dato se corrompe sin que nada falle.
  #
  # SQL Server se niega a arrancar con menos de 2000 MB de memoria, y es el nodo
  # que más cuesta: por eso va con `--only sqlserver` salvo que la tarea pida más.
  # La espera es `sqlcmd`, que la imagen trae en `/opt/mssql-tools18/bin/` —el
  # `-C` confía en el certificado autofirmado del contenedor—.
  #
  # El seed (`scripts/mssql-seed/`, base `demo` con los esquemas `dbo` y `rrhh`)
  # lo aplica `00-init.sh`, que es el `command:`: la imagen no tiene carpeta de
  # inicialización. El `ENTRYPOINT` de la imagen se queda y ejecuta el script.
  # El healthcheck exige además la marca `/tmp/caliope-seed-ok`, que sólo existe
  # con el seed entero: sin ella el nodo saldría sano con la base a medias.
  #
  # `00-init.sh` también le CONFIGURA un certificado (`mssql-conf`, en
  # `/var/opt/mssql/tls/`): sin él SQL Server rechaza el TLS estricto de TDS 8.0,
  # que es por donde entra Calíope primero (plan 12, tarea 9).
  sqlserver:
    build:
      context: .
      dockerfile: Dockerfile.seeded
      args:
        BASE: mcr.microsoft.com/mssql/server:2025-latest
        SEED: scripts/mssql-seed
        DEST: /seed
    platform: linux/amd64
    container_name: sqlserver
    environment:
      ACCEPT_EULA:        "Y"
      MSSQL_PID:          Developer
      MSSQL_SA_PASSWORD:  "Caliope!pass1"
      MSSQL_COLLATION:    Latin1_General_100_CI_AS_SC_UTF8
    command: ["/bin/bash", "/seed/00-init.sh"]
    volumes:
      - sqlserver-data:/var/opt/mssql
    ports:
      - "3340:1433"
    networks:
      - replication
    healthcheck:
      test: ["CMD-SHELL", "test -f /tmp/caliope-seed-ok && /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P \"$$MSSQL_SA_PASSWORD\" -C -Q 'SELECT 1' -b -o /dev/null"]
      interval: 5s
      timeout: 5s
      retries: 30
      start_period: 60s
    restart: unless-stopped

  # El destino de restauración de SQL Server (plan 12, tarea 3): el mismo
  # servidor que `sqlserver`, VACÍO. `verify-backup-roundtrip.py` restaura aquí
  # el respaldo que Calíope saca de `sqlserver` y compara base contra base; si
  # el nodo trajera el seed, la restauración chocaría con objetos que ya existen
  # y la comparación no probaría nada. Por eso no lleva ni el `command:` ni el
  # montaje de `/seed` de su hermano, y el healthcheck no espera la marca del
  # seed, que aquí no aparece nunca.
  #
  # Tampoco lleva certificado configurado, y es a propósito: con el que SQL Server
  # se genera solo rechaza TDS 8.0 estricto, como una instalación por omisión, y
  # Calíope entra aquí por el segundo intento, el TLS de TDS 7.x (plan 12, tarea 9b).
  #
  # Cabe, medido el 2026-09-29: 7 936 MiB de la VM de Docker, menos 3 604 de los
  # dieciséis nodos, menos 1 093 de `sqlserver`, dejan 3 239 MiB (2 969 con los
  # dos contenedores ajenos al testbed que corren en el Mac), y SQL Server pide
  # 2 000 para arrancar.
  sqlserver-restore:
    image: mcr.microsoft.com/mssql/server:2025-latest
    platform: linux/amd64
    container_name: sqlserver-restore
    environment:
      ACCEPT_EULA:        "Y"
      MSSQL_PID:          Developer
      MSSQL_SA_PASSWORD:  "Caliope!pass1"
      MSSQL_COLLATION:    Latin1_General_100_CI_AS_SC_UTF8
    volumes:
      - sqlserver-restore-data:/var/opt/mssql
    ports:
      - "3341:1433"
    networks:
      - replication
    healthcheck:
      test: ["CMD-SHELL", "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P \"$$MSSQL_SA_PASSWORD\" -C -Q 'SELECT 1' -b -o /dev/null"]
      interval: 5s
      timeout: 5s
      retries: 30
      start_period: 30s
    restart: unless-stopped

  # El nodo de ClickHouse, la quinta familia (plan 34, en `dev/1.6`). La imagen
  # es la LTS vigente por número, nunca `latest` ni `lts` (ésta no resuelve):
  # `26.8` resolvió a 26.8.15.10 el 2026-09-30 y a 26.8.18.2 el 2026-10-06, y la
  # PC baja la `amd64`, que es su arquitectura nativa — bajo emulación nada de lo
  # que se mida valdría.
  #
  # La app habla HTTP por el 8123 y nada más. El 9000 (protocolo nativo) se
  # publica sólo para que `clickhouse-client` funcione dentro del contenedor.
  #
  # `CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT=1` deja a la cuenta de arranque crear
  # usuarios por SQL (`CREATE USER`, `GRANT`, `DROP USER`): sin él no se pueden
  # sembrar los dos usuarios de prueba del plan. La cuenta misma vive en
  # `users_xml` y no puede alterarse a sí misma.
  #
  # El `nofile` alto lo pide la imagen: sin él arranca con avisos y se comporta
  # distinto bajo carga. El healthcheck es el `/ping` del HTTP, que contesta
  # `Ok.` en cuanto el servidor acepta consultas.
  #
  # Pesa poco: 729 MiB ociosa en el Mac (`arm64`, 2026-09-30) y 181 MiB en la PC
  # (`amd64`, 2026-10-06, sin datos todavía), con unos 4 s de arranque. Con los
  # dieciocho nodos de siempre y éste, la PC deja 21 GB disponibles de sus 30.
  #
  # El seed (`scripts/clickhouse-seed/`, base `demo`, plan 34, tarea 2) va dentro
  # de la imagen, nunca montado, y el entrypoint lo aplica sólo con el volumen
  # vacío: para volver a sembrar se borra el volumen `clickhouse-data`.
  clickhouse:
    build:
      context: .
      dockerfile: Dockerfile.seeded
      args:
        BASE: clickhouse/clickhouse-server:26.8
        SEED: scripts/clickhouse-seed
        DEST: /docker-entrypoint-initdb.d
    container_name: caliope-clickhouse
    environment:
      CLICKHOUSE_DB:                        demo
      CLICKHOUSE_USER:                      caliope
      CLICKHOUSE_PASSWORD:                  caliopepass
      CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: "1"
    ulimits:
      nofile:
        soft: 262144
        hard: 262144
    volumes:
      - clickhouse-data:/var/lib/clickhouse
    ports:
      - "8123:8123"
      - "9000:9000"
    networks:
      - replication
    healthcheck:
      test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1:8123/ping"]
      interval: 5s
      timeout: 5s
      retries: 30
      start_period: 20s
    restart: unless-stopped

  # El nodo de ClickHouse con TLS (plan 34, tarea 3): la misma imagen y la misma
  # semilla, y sólo HTTPS por el 8443. El 8123 no existe aquí —lo quita
  # `config.d/caliope-tls.xml`—, así que una conexión que no negocie TLS no entra.
  # Los certificados los genera el lanzador (`generate_clickhouse_tls`) con una
  # CA propia, y van DENTRO de la imagen (`Dockerfile.clickhouse`), no montados.
  # El cliente valida con `~/CaliopeTestbed/scripts/clickhouse-tls/ca.crt`.
  clickhouse-tls:
    build:
      context: .
      dockerfile: Dockerfile.clickhouse
      args:
        BASE: clickhouse/clickhouse-server:26.8
        SEED: scripts/clickhouse-seed
        CONF: scripts/clickhouse-tls
    container_name: caliope-clickhouse-tls
    environment:
      CLICKHOUSE_DB:                        demo
      CLICKHOUSE_USER:                      caliope
      CLICKHOUSE_PASSWORD:                  caliopepass
      CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: "1"
    ulimits:
      nofile:
        soft: 262144
        hard: 262144
    volumes:
      - clickhouse-tls-data:/var/lib/clickhouse
    ports:
      - "8443:8443"
    networks:
      - replication
    healthcheck:
      test: ["CMD", "wget", "--spider", "-q", "--no-check-certificate", "https://127.0.0.1:8443/ping"]
      interval: 5s
      timeout: 5s
      retries: 30
      start_period: 20s
    restart: unless-stopped

  # El nodo de ClickHouse tras el bastión (plan 34, tarea 3): sin `ports`, como
  # `db`. Sólo se alcanza por `localhost:2222` → `clickhouse-tunnel:8123`, con
  # `tunneluser` / `tunnel123`. La misma semilla, así que el recorrido del editor
  # es el mismo por los tres caminos.
  clickhouse-tunnel:
    build:
      context: .
      dockerfile: Dockerfile.clickhouse
      args:
        BASE: clickhouse/clickhouse-server:26.8
        SEED: scripts/clickhouse-seed
        CONF: scripts/clickhouse-tunnel
    container_name: caliope-clickhouse-tunnel
    environment:
      CLICKHOUSE_DB:                        demo
      CLICKHOUSE_USER:                      caliope
      CLICKHOUSE_PASSWORD:                  caliopepass
      CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: "1"
    ulimits:
      nofile:
        soft: 262144
        hard: 262144
    volumes:
      - clickhouse-tunnel-data:/var/lib/clickhouse
    networks:
      - replication
    healthcheck:
      test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1:8123/ping"]
      interval: 5s
      timeout: 5s
      retries: 30
      start_period: 20s
    restart: unless-stopped

  # El par replicado de ClickHouse (plan 34, tarea 4): un Keeper y dos réplicas.
  # Es contra lo que miden el estado de replicación (`system.replicas`) y la
  # topología (`system.clusters`, clúster `caliope_par`).
  #
  # El Keeper es la misma imagen arrancada como `clickhouse-keeper` (va dentro
  # del servidor): con un argumento que no empieza por `--` el entrypoint lo
  # ejecuta tal cual. Un solo nodo de Raft, porque aquí no se prueba el quórum
  # del Keeper sino la replicación de las tablas. Su 9181 es interno: la app no
  # habla con el Keeper, sólo los servidores. Se le pregunta `ruok` y contesta
  # `imok`.
  clickhouse-keeper:
    build:
      context: .
      dockerfile: Dockerfile.seeded
      args:
        BASE: clickhouse/clickhouse-server:26.8
        SEED: scripts/clickhouse-keeper
        DEST: /etc/clickhouse-keeper
    container_name: caliope-clickhouse-keeper
    command: ["clickhouse-keeper", "--config-file=/etc/clickhouse-keeper/keeper_config.xml"]
    volumes:
      - clickhouse-keeper-data:/var/lib/clickhouse-keeper
    networks:
      - replication
    healthcheck:
      test: ["CMD-SHELL", "echo ruok | nc -w 2 127.0.0.1 9181 | grep -q imok"]
      interval: 5s
      timeout: 5s
      retries: 30
      start_period: 10s
    restart: unless-stopped

  # Las dos réplicas: la misma imagen, con su fragmento de configuración
  # (Keeper, macros y el clúster `caliope_par`) y sólo las cuentas del seed, no
  # sus tablas —r1 es el destino de restauración de la capa 1—. La tabla
  # replicada la crea `clickhouse-replica-setup`. El 9000 no se publica: el
  # clúster se habla por la red interna.
  clickhouse-r1:
    build:
      context: .
      dockerfile: Dockerfile.clickhouse
      args:
        BASE: clickhouse/clickhouse-server:26.8
        SEED: scripts/clickhouse-replica-seed
        CONF: scripts/clickhouse-r1
    container_name: caliope-clickhouse-r1
    environment:
      CLICKHOUSE_DB:                        demo
      CLICKHOUSE_USER:                      caliope
      CLICKHOUSE_PASSWORD:                  caliopepass
      CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: "1"
    ulimits:
      nofile:
        soft: 262144
        hard: 262144
    volumes:
      - clickhouse-r1-data:/var/lib/clickhouse
    ports:
      - "8124:8123"
    networks:
      - replication
    healthcheck:
      test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1:8123/ping"]
      interval: 5s
      timeout: 5s
      retries: 30
      start_period: 20s
    restart: unless-stopped

  clickhouse-r2:
    build:
      context: .
      dockerfile: Dockerfile.clickhouse
      args:
        BASE: clickhouse/clickhouse-server:26.8
        SEED: scripts/clickhouse-replica-seed
        CONF: scripts/clickhouse-r2
    container_name: caliope-clickhouse-r2
    environment:
      CLICKHOUSE_DB:                        demo
      CLICKHOUSE_USER:                      caliope
      CLICKHOUSE_PASSWORD:                  caliopepass
      CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: "1"
    ulimits:
      nofile:
        soft: 262144
        hard: 262144
    volumes:
      - clickhouse-r2-data:/var/lib/clickhouse
    ports:
      - "8125:8123"
    networks:
      - replication
    healthcheck:
      test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1:8123/ping"]
      interval: 5s
      timeout: 5s
      retries: 30
      start_period: 20s
    restart: unless-stopped

  # El shard 02 del clúster `caliope_2x2` (David, 2026-10-13): r3 y r4, las dos
  # réplicas de la otra mitad. `caliope_par` no se toca —lo que la Ayuda, la QA
  # y el vídeo afirman de él sigue siendo verdad—, y `caliope_2x2` se declara
  # aparte en los cuatro nodos: shard 01 = r1 y r2, shard 02 = r3 y r4, con el
  # mismo Keeper. La macro `shard` de r3 y r4 vale `02`, así que sus tablas
  # replicadas viven en otra ruta de Keeper que las de r1 y r2. La tabla
  # repartida la crea `clickhouse-shard-setup`.

  clickhouse-r3:
    build:
      context: .
      dockerfile: Dockerfile.clickhouse
      args:
        BASE: clickhouse/clickhouse-server:26.8
        SEED: scripts/clickhouse-replica-seed
        CONF: scripts/clickhouse-r3
    container_name: caliope-clickhouse-r3
    environment:
      CLICKHOUSE_DB:                        demo
      CLICKHOUSE_USER:                      caliope
      CLICKHOUSE_PASSWORD:                  caliopepass
      CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: "1"
    ulimits:
      nofile:
        soft: 262144
        hard: 262144
    volumes:
      - clickhouse-r3-data:/var/lib/clickhouse
    ports:
      - "8126:8123"
    networks:
      - replication
    healthcheck:
      test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1:8123/ping"]
      interval: 5s
      timeout: 5s
      retries: 30
      start_period: 20s
    restart: unless-stopped

  clickhouse-r4:
    build:
      context: .
      dockerfile: Dockerfile.clickhouse
      args:
        BASE: clickhouse/clickhouse-server:26.8
        SEED: scripts/clickhouse-replica-seed
        CONF: scripts/clickhouse-r4
    container_name: caliope-clickhouse-r4
    environment:
      CLICKHOUSE_DB:                        demo
      CLICKHOUSE_USER:                      caliope
      CLICKHOUSE_PASSWORD:                  caliopepass
      CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: "1"
    ulimits:
      nofile:
        soft: 262144
        hard: 262144
    volumes:
      - clickhouse-r4-data:/var/lib/clickhouse
    ports:
      - "8127:8123"
    networks:
      - replication
    healthcheck:
      test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1:8123/ping"]
      interval: 5s
      timeout: 5s
      retries: 30
      start_period: 20s
    restart: unless-stopped

  # Crea `par.replicada` (ReplicatedMergeTree, misma ruta en Keeper) en las dos
  # réplicas, mete filas por r1 y comprueba que llegan a r2; sale con error si no
  # llegan. Idempotente: `IF NOT EXISTS`, y sólo siembra con la tabla vacía. Mismo
  # patrón que `mongo-rs-setup`: corre una vez y se apaga.
  # En su base `par` y no en `demo` a propósito: r1 es el destino de la capa 1, que
  # VACÍA `demo` antes de restaurar (`DROP DATABASE demo SYNC`), y con la tabla
  # dentro se llevaba por delante la réplica de r1 (plan 34, tarea 6).
  clickhouse-replica-setup:
    image: clickhouse/clickhouse-server:26.8
    container_name: caliope-clickhouse-replica-setup
    depends_on:
      clickhouse-keeper:
        condition: service_healthy
      clickhouse-r1:
        condition: service_healthy
      clickhouse-r2:
        condition: service_healthy
    entrypoint: ["bash", "-c"]
    command:
      - |
        set -e
        q() { clickhouse-client --host "$$1" --user caliope --password caliopepass -q "$$2"; }
        # Que un nodo conteste por HTTP no es que tenga sesión con Keeper: tras parar y
        # volver a levantar el Keeper, la sesión tarda unos segundos en rehacerse y
        # `SYSTEM SYNC REPLICA` muere con NO_ZOOKEEPER (medido el 2026-10-13).
        for h in clickhouse-r1 clickhouse-r2; do
          for i in $$(seq 1 60); do q $$h "SELECT 1 FROM system.zookeeper WHERE path = '/' LIMIT 1" >/dev/null 2>&1 && break; sleep 1; done
        done
        for h in clickhouse-r1 clickhouse-r2; do
          q $$h "CREATE DATABASE IF NOT EXISTS par"
          q $$h "CREATE TABLE IF NOT EXISTS par.replicada (id UInt32, valor String) ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/par/replicada', '{replica}') ORDER BY id"
        done
        if [ "$$(q clickhouse-r1 'SELECT count() FROM par.replicada')" = "0" ]; then
          q clickhouse-r1 "INSERT INTO par.replicada SELECT number + 100, concat('semilla-', toString(number)) FROM numbers(100)"
        fi
        # Reintentada: aun con la sesión hecha, la tabla replicada tarda en volver a
        # engancharse a Keeper tras un rearranque, y mientras tanto da NO_ZOOKEEPER.
        for i in $$(seq 1 30); do q clickhouse-r2 "SYSTEM SYNC REPLICA par.replicada" 2>/dev/null && break; sleep 2; done
        n=$$(q clickhouse-r2 'SELECT count() FROM par.replicada')
        echo "[caliope] par.replicada en r2: $$n filas"
        [ "$$n" -ge 100 ]
    networks:
      - replication
    restart: "no"

  # Crea la tabla repartida del clúster `caliope_2x2` y se apaga, como el de
  # arriba. En cada uno de los cuatro nodos: `reparto.ventas_local`
  # (ReplicatedMergeTree, cuya ruta lleva la macro `shard`, así que r1 y r2
  # comparten una y r3 y r4 otra) y `reparto.ventas`, la `Distributed` que reparte
  # por `cityHash64(id)` entre los dos shards. Siembra 1000 filas por la
  # distribuida sólo con la tabla vacía, con `insert_distributed_sync` para que
  # la comprobación no corra antes de que lleguen, y comprueba que las 1000 se
  # leen desde r1 y que el shard 02 tiene su parte. En la base `reparto` y no en
  # `demo` por lo mismo que `par`: r1 es el destino de la capa 1, que vacía `demo`.
  clickhouse-shard-setup:
    image: clickhouse/clickhouse-server:26.8
    container_name: caliope-clickhouse-shard-setup
    depends_on:
      clickhouse-keeper:
        condition: service_healthy
      clickhouse-r1:
        condition: service_healthy
      clickhouse-r2:
        condition: service_healthy
      clickhouse-r3:
        condition: service_healthy
      clickhouse-r4:
        condition: service_healthy
    entrypoint: ["bash", "-c"]
    command:
      - |
        set -e
        q() { clickhouse-client --host "$$1" --user caliope --password caliopepass -q "$$2"; }
        # La sesión con Keeper, como en `clickhouse-replica-setup`.
        for h in clickhouse-r1 clickhouse-r2 clickhouse-r3 clickhouse-r4; do
          for i in $$(seq 1 60); do q $$h "SELECT 1 FROM system.zookeeper WHERE path = '/' LIMIT 1" >/dev/null 2>&1 && break; sleep 1; done
        done
        for h in clickhouse-r1 clickhouse-r2 clickhouse-r3 clickhouse-r4; do
          q $$h "CREATE DATABASE IF NOT EXISTS reparto"
          q $$h "CREATE TABLE IF NOT EXISTS reparto.ventas_local (id UInt32, tienda String, importe Decimal(10, 2), fecha Date) ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/reparto/ventas_local', '{replica}') ORDER BY id"
          q $$h "CREATE TABLE IF NOT EXISTS reparto.ventas AS reparto.ventas_local ENGINE = Distributed(caliope_2x2, reparto, ventas_local, cityHash64(id))"
        done
        if [ "$$(q clickhouse-r1 'SELECT count() FROM reparto.ventas')" = "0" ]; then
          q clickhouse-r1 "INSERT INTO reparto.ventas SETTINGS insert_distributed_sync = 1 SELECT number + 1, ['Centro', 'Norte', 'Sur', 'Este', 'Oeste'][number % 5 + 1], round(5 + (number * 37) % 495, 2), toDate('2026-01-01') + number % 280 FROM numbers(1000)"
        fi
        for h in clickhouse-r2 clickhouse-r4; do
          for i in $$(seq 1 30); do q $$h "SYSTEM SYNC REPLICA reparto.ventas_local" 2>/dev/null && break; sleep 2; done
        done
        total=$$(q clickhouse-r1 'SELECT count() FROM reparto.ventas')
        s2=$$(q clickhouse-r4 'SELECT count() FROM reparto.ventas_local')
        echo "[caliope] reparto.ventas: $$total filas; el shard 02 guarda $$s2"
        [ "$$total" -ge 1000 ] && [ "$$s2" -gt 0 ]
    networks:
      - replication
    restart: "no"

  # Carga lenta para Postgres. Existe por lo mismo que el worker lento del
  # generador de MySQL: sin ella la herramienta «Consultas lentas» y la tarjeta
  # del Tablero enseñan una tabla vacía, que es indistinguible de estar roto.
  #
  # Contra `postgres-gis` y sólo contra él: `pg-seed` lo monta él nada más, así
  # que es el único de los tres que tiene la tabla `gis.sensores`. El primario
  # monta `pg-setup`, y la réplica es de sólo lectura.
  #
  # Las tres consultas son lentas POR CONSTRUCCIÓN, con `pg_sleep` dentro, y no
  # por volumen — la misma decisión que en MySQL y por el mismo motivo: 25 000
  # filas se recorren en milisegundos, así que una consulta lenta por volumen
  # tendría que ser un producto cartesiano, y eso no se pasa de lento: ahoga el
  # nodo. Medido el 2026-09-04 sobre `gis.sensores`: 337 ms, 306 ms y 327 ms.
  #
  # Y el umbral de aquí NO es el de MySQL: allí son 0,1 s y aquí 250 ms, así que
  # un `pg_sleep(0.2)` —que allí cruzaba de sobra— da 203 ms y **no** entra en el
  # log del servidor, aunque sí en `pg_stat_statements`. Las tres van con 0,3 s
  # para cruzar los dos umbrales con margen.
  #
  # Rota las tres en orden en vez de sortearlas: con un sorteo, mirar el nodo a
  # los dos minutos puede no encontrar una de las formas, y eso se lee como que
  # la agrupación por sentencia no funciona.
  pg-loadgen:
    image: imresamu/postgis:17-3.5
    container_name: postgres-loadgen
    depends_on:
      postgres-gis:
        condition: service_healthy
    environment:
      PGPASSWORD: caliopepass
    entrypoint: ["bash", "-c"]
    command:
      - |
        for i in $$(seq 1 60); do
          pg_isready -h postgres-gis -U caliope -d demo -q && break
          sleep 2
        done
        if ! pg_isready -h postgres-gis -U caliope -d demo -q; then
          echo "[caliope] pg-loadgen: postgres-gis no responde tras 120 s" >&2
          exit 1
        fi
        echo "[caliope] pg-loadgen: postgres-gis responde; produciendo una consulta lenta cada 4 s"
        n=0
        while true; do
          case $$(( n % 3 )) in
            0) sql="SELECT pg_sleep(0.3);" ;;
            1) sql="SELECT (SELECT count(*) FROM gis.sensores) AS filas, pg_sleep(0.3);" ;;
            2) sql="SELECT id, codigo, pg_sleep(0.04) FROM gis.sensores ORDER BY id LIMIT 8;" ;;
          esac
          # El error se IMPRIME, no se manda a /dev/null: una consulta que no
          # llega produce exactamente lo mismo que un nodo tranquilo —cero filas—
          # y eso se depura contra la herramienta en vez de contra el generador.
          if ! out=$$(psql -h postgres-gis -U caliope -d demo -qtAc "$$sql" 2>&1); then
            echo "[caliope] pg-loadgen: falló la forma $$(( n % 3 )): $$out" >&2
          fi
          n=$$(( n + 1 ))
          sleep 4
        done
    networks:
      - replication
    restart: unless-stopped

  # ────────────────────── MONGODB ──────────────────────────────
  # Dos nodos, y cada uno existe por una razón distinta:
  #
  #   mongo         27017  autenticado, standalone → usuarios, roles y permisos
  #   mongo-rs      27018  replica set de un nodo  → transacciones y change streams
  #   mongo-tls     27019  sólo TLS y autenticado  → el transporte cifrado (plan 60)
  #
  # Usuarios de `mongo` que crea el seed, además de `caliope`/`caliopepass` (root):
  # en `caliope_demo`, `lector`/`lectorpass` e `informes`/`informespass` (rol
  # propio); en `admin` —el `authSource` por omisión de Calíope—, `legado`/
  # `legadopass` (sólo SCRAM-SHA-1, para probar el mecanismo viejo) y `acentos`/
  # `contraseña` (con «ñ» precompuesta, U+00F1, para probar SASLprep) (plan 60).
  #
  # No hay un tercer nodo para el túnel SSH. Lo hubo —`mongo-tunnel`, sin puertos
  # publicados— y se retiró el 2026-09-12: lo que demuestra que el túnel se está
  # usando de verdad es que el nombre del destino sólo resuelve DENTRO de la red
  # de Docker, y `mongo` cumple eso igual. Que además esté publicado en 27017 no
  # le quita la propiedad: un perfil que apunte a `mongo:27017` por el bastión
  # falla en cuanto el túnel no se abre, que es justo lo que se quería probar.
  #
  # No hay un único nodo que sirva para todo: con autenticación, un replica set
  # exige además autenticación interna por keyFile, y eso convierte el testbed en
  # un ejercicio de gestión de secretos que no aporta nada a lo que se prueba.
  # Las tres imágenes de Mongo van fijadas a 8.2 y no a `mongo:8`: esa etiqueta
  # ya es la 8.3.8, que se niega a arrancar sobre kernels 6.19 o más nuevos
  # (SERVER-121912) y el VM de Docker Desktop de este Mac corre el 7.0.12-linuxkit,
  # así que los dos nodos entraban en bucle de reinicio y `mongo-rs-setup` moría con
  # «dependency failed to start». Medido el 2026-09-04: la 8.2.12 arranca. Cuando la
  # 8.3 lo arregle, se vuelve a `mongo:8`.
  # El *profiler* va como FLAG de arranque y no por `db.setProfilingLevel()`: el
  # nivel es estado en memoria de cada base y no sobrevive al reinicio del nodo,
  # así que puesto a mano desaparece el día que Docker recrea el contenedor y la
  # herramienta se queda mirando una colección vacía sin que nada falle. Nivel 1
  # es «sólo las lentas» (el 2 registra TODAS y llena la capada en minutos), y
  # `slowms` 100 es además el valor por omisión: se escribe para que el umbral
  # esté en el archivo y no en la memoria de nadie. Ojo con la unidad, que no es
  # la de los otros dos motores: aquí son 100 MILISEGUNDOS, contra los 0,1 s de
  # MySQL y los 250 ms de Postgres.
  #
  # `command:` empezando por `mongod` no cuesta la autenticación: el entrypoint
  # de la imagen añade `--auth` cuando el primer argumento es exactamente
  # `mongod` y hay usuario y contraseña de root, y a una lista que empieza por
  # `-` le pone `mongod` delante — por eso `mongo-rs` puede seguir con la suya.
  mongo:
    build:
      context: .
      dockerfile: Dockerfile.seeded
      args:
        BASE: mongo:8.2
        SEED: scripts/mongo-seed
        DEST: /docker-entrypoint-initdb.d
    container_name: caliope-mongo
    command: ["mongod", "--profile", "1", "--slowms", "100"]
    environment:
      MONGO_INITDB_ROOT_USERNAME: caliope
      MONGO_INITDB_ROOT_PASSWORD: caliopepass
      MONGO_INITDB_DATABASE:      caliope_demo
    volumes:
      - mongo-data:/data/db
    ports:
      - "27017:27017"
    networks:
      - replication
    healthcheck:
      test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
      interval: 5s
      timeout: 5s
      retries: 30
      start_period: 20s
    restart: unless-stopped

  # Replica set de un solo nodo. El miembro se declara como `127.0.0.1:27018` —la
  # dirección desde el Mac— y no como `mongo-rs:27017`: el cliente descubre la
  # topología por lo que anuncia el propio servidor, así que con el nombre interno
  # un driver en el host intentaría conectar a un hostname que no resuelve fuera
  # de Docker. Nadie dentro de la red necesita hablar con este nodo.
  #
  # En el destino remoto (`--remote`) el lanzador exporta `MONGO_RS_HOST` con la IP
  # de la máquina remota: ahí `127.0.0.1` sería el propio Mac, y el driver iría a
  # buscar el primario a una máquina que no lo tiene. En local la variable no se
  # exporta y el miembro sigue siendo `127.0.0.1`, y no la IP de la red del Mac,
  # a propósito: esa IP cambia con la red y el conjunto la guarda en su volumen.
  # La IP de la remota también puede cambiar, y entonces el conjunto se rehace con
  # `--remote --fresh`.
  mongo-rs:
    image: mongo:8.2
    container_name: caliope-mongo-rs
    command: ["--replSet", "rs0", "--bind_ip_all", "--port", "27018", "--profile", "1", "--slowms", "100"]
    volumes:
      - mongo-rs-data:/data/db
      # OJO: aquí NO se monta el seed en /docker-entrypoint-initdb.d. El entrypoint de
      # la imagen levanta un mongod temporal que ya lleva `--replSet` pero cuyo conjunto
      # todavía no está iniciado, así que no es primario y toda escritura muere con
      # `not primary` — el seed se ejecutaba y fallaba en silencio, dejando el nodo vacío.
      # Lo siembra `mongo-rs-setup`, que espera a que haya primario.
    ports:
      - "27018:27018"
      # Los de los otros dos miembros, que comparten la red de éste (abajo).
      - "27020:27020"
      - "27021:27021"
    networks:
      - replication
    healthcheck:
      # Inicia el conjunto en el primer sondeo y confirma en los siguientes: el
      # healthcheck es el único gancho que se repite hasta tener éxito sin añadir
      # otro contenedor de arranque.
      test: >
        mongosh --quiet --port 27018 --eval
        "try { rs.status().ok } catch (e) { rs.initiate({_id:'rs0',members:[{_id:0,host:'${MONGO_RS_HOST:-127.0.0.1}:27018'}]}).ok }"
      interval: 5s
      timeout: 10s
      retries: 30
      start_period: 15s
    restart: unless-stopped

  # Los otros dos miembros de `rs0` (David, 2026-10-13): con uno solo, el conjunto
  # no podía enseñar lo que hace un conjunto —si se cae un miembro, los demás
  # eligen primario—, y tres con datos es lo que MongoDB recomienda, no dos y un
  # árbitro. `mongo-rs-setup` los suma con `rs.add`.
  #
  # Comparten la red de `mongo-rs` (`network_mode: service:mongo-rs`), y no es
  # comodidad: cada miembro se anuncia como `${MONGO_RS_HOST:-127.0.0.1}:<puerto>`,
  # que es la dirección por la que llega el cliente de fuera, y los miembros se
  # hablan ENTRE SÍ por esa misma dirección. En el Docker del Mac eso es
  # `127.0.0.1`, que dentro de un contenedor es él mismo: en contenedores
  # separados, ningún miembro alcanzaría a los otros. Con la red compartida,
  # `127.0.0.1:27020` es el segundo miembro para los tres. En la PC la dirección es
  # la IP de la máquina, que se alcanza por los puertos publicados, como hoy el
  # miembro único. Los puertos se publican en `mongo-rs`, que es el dueño de la red.
  #
  # Un conjunto de tres con dos miembros abajo se queda sin primario, así que
  # `--only mongo-rs` levanta también estos dos (lo hace el lanzador).
  mongo-rs2:
    image: mongo:8.2
    container_name: caliope-mongo-rs2
    command: ["--replSet", "rs0", "--bind_ip_all", "--port", "27020", "--profile", "1", "--slowms", "100"]
    network_mode: "service:mongo-rs"
    volumes:
      - mongo-rs2-data:/data/db
    depends_on:
      mongo-rs:
        condition: service_started
    healthcheck:
      test: ["CMD", "mongosh", "--quiet", "--port", "27020", "--eval", "db.adminCommand('ping').ok"]
      interval: 5s
      timeout: 5s
      retries: 30
      start_period: 15s
    restart: unless-stopped

  mongo-rs3:
    image: mongo:8.2
    container_name: caliope-mongo-rs3
    command: ["--replSet", "rs0", "--bind_ip_all", "--port", "27021", "--profile", "1", "--slowms", "100"]
    network_mode: "service:mongo-rs"
    volumes:
      - mongo-rs3-data:/data/db
    depends_on:
      mongo-rs:
        condition: service_started
    healthcheck:
      test: ["CMD", "mongosh", "--quiet", "--port", "27021", "--eval", "db.adminCommand('ping').ok"]
      interval: 5s
      timeout: 5s
      retries: 30
      start_period: 15s
    restart: unless-stopped

  # Siembra el replica set cuando ya hay primario. Mismo patrón que el `setup` de la
  # replicación de MariaDB: un contenedor que corre una vez, comprueba y se apaga.
  mongo-rs-setup:
    build:
      context: .
      dockerfile: Dockerfile.seeded
      args:
        BASE: mongo:8.2
        SEED: scripts/mongo-seed
        DEST: /seed
    container_name: caliope-mongo-rs-setup
    depends_on:
      mongo-rs:
        condition: service_healthy
      mongo-rs2:
        condition: service_healthy
      mongo-rs3:
        condition: service_healthy
    environment:
      MONGO_RS_HOST: ${MONGO_RS_HOST:-127.0.0.1}
    entrypoint: ["bash", "-c"]
    command:
      - |
        for i in $$(seq 1 60); do
          state=$$(mongosh --quiet --host mongo-rs --port 27018 --eval 'rs.status().myState' 2>/dev/null || echo 0)
          [ "$$state" = "1" ] && break
          sleep 2
        done
        # Los otros dos miembros, por la dirección con que se anuncia el primero.
        # `rs.add` sólo si no están: un conjunto ya completo no se reconfigura.
        for port in 27020 27021; do
          member="$${MONGO_RS_HOST}:$${port}"
          has=$$(mongosh --quiet --host mongo-rs --port 27018 --eval "rs.conf().members.some(m => m.host === '$$member')" 2>/dev/null)
          if [ "$$has" != "true" ]; then
            mongosh --quiet --host mongo-rs --port 27018 --eval "rs.add({host: '$$member'}).ok" >/dev/null 2>&1 \
              && echo "[caliope] rs0: $$member añadido" \
              || echo "[caliope] AVISO: rs0 no aceptó $$member"
          fi
        done
        # El 27018 con prioridad 2 y los otros con 1: si uno cae, los demás eligen
        # igual, pero en reposo el primario vuelve a ser el de siempre. Sin esto la
        # primera elección podía caer en el 27020, y un perfil que entra directo al
        # 27018 —el de la app, el de la siembra de abajo— se encontraba un secundario.
        mongosh --quiet --host mongo-rs --port 27018 --eval 'const c = rs.conf(); if (c.members[0].priority !== 2) { c.members[0].priority = 2; rs.reconfig(c); print("[caliope] rs0: prioridad 2 para el 27018"); }' 2>/dev/null
        for i in $$(seq 1 60); do
          sec=$$(mongosh --quiet --host mongo-rs --port 27018 --eval 'rs.status().members.filter(m => m.stateStr === "SECONDARY").length' 2>/dev/null || echo 0)
          [ "$$sec" = "2" ] && break
          sleep 2
        done
        echo "[caliope] rs0: $$(mongosh --quiet --host mongo-rs --port 27018 --eval 'rs.status().members.map(m => m.name + " " + m.stateStr).join(", ")' 2>/dev/null)"
        count=$$(mongosh --quiet --host mongo-rs --port 27018 caliope_demo --eval 'db.getCollectionNames().length' 2>/dev/null || echo 0)
        if [ "$$count" = "0" ]; then
          mongosh --quiet --host mongo-rs --port 27018 caliope_demo /seed/10-seed.js
        else
          echo "[caliope] el replica set ya tiene $$count colecciones; no se siembra"
        fi
    networks:
      - replication
    restart: "no"

  # Produce consultas lentas contra `mongo`, que es el nodo autenticado y el que
  # la app usa para todo lo demás. Tres decisiones, y las tres
  # costaron una medición:
  #
  #   · Lentas por CONSTRUCCIÓN, con `sleep()` dentro de la consulta, no por
  #     volumen: la colección `eventos` tiene 10 000 documentos y un `$$group`
  #     entero sobre ella mide 6–27 ms, o sea que nunca cruza los 100 ms del
  #     umbral. Medido el 2026-09-04. Y `sleep()` sí existe en el JS del
  #     servidor en 8.2 —no es sólo del shell—, así que la forma más barata de
  #     una consulta lenta y estable es un `$$where` que duerme.
  #   · El `sleep` se ACOTA a un documento: `{tipo:"compra", secuencia:2}` casa
  #     exactamente uno, así que el JS corre una vez y la consulta mide 157–201
  #     ms en las tres formas (tres pases). Sin acotar, `$$where` se evalúa
  #     documento a documento y son 10 000 × 150 ms.
  #   · Las tres formas se turnan en orden, no al azar: con una elección
  #     aleatoria una de ellas puede faltar al mirar a los dos minutos, y eso se
  #     lee como que la herramienta agrupa mal.
  #
  # Son tres OPERACIONES distintas a propósito —`query`, `command`/aggregate y
  # `command`/count—, que es lo que `system.profile` distingue con su campo `op`
  # y lo que la herramienta tiene que saber enseñar.
  mongo-loadgen:
    image: mongo:8.2
    container_name: caliope-mongo-loadgen
    depends_on:
      mongo:
        condition: service_healthy
    entrypoint: ["bash", "-c"]
    command:
      - |
        MSH="mongosh --quiet --host mongo -u caliope -p caliopepass --authenticationDatabase admin caliope_demo"
        for i in $$(seq 1 60); do
          $$MSH --eval "db.adminCommand({ping:1}).ok" >/dev/null 2>&1 && break
          sleep 2
        done
        if ! $$MSH --eval "db.adminCommand({ping:1}).ok" >/dev/null 2>&1; then
          echo "[caliope] mongo-loadgen: mongo no responde tras 120 s" >&2
          exit 1
        fi
        echo "[caliope] mongo-loadgen: mongo responde; produciendo una consulta lenta cada 4 s"
        n=0
        while true; do
          case $$(( n % 3 )) in
            0) js='db.eventos.find({tipo:"compra", secuencia:NumberInt(2), $$where:"sleep(150) || true"}).toArray().length' ;;
            1) js='db.eventos.aggregate([{$$match:{tipo:"compra", secuencia:NumberInt(2)}}, {$$match:{$$expr:{$$function:{body:"function(){ sleep(150); return true; }", args:[], lang:"js"}}}}, {$$group:{_id:"$$carga.pagina", n:{$$sum:1}, ms:{$$avg:"$$carga.duracionMs"}}}]).toArray().length' ;;
            2) js='db.runCommand({count:"eventos", query:{tipo:"compra", secuencia:NumberInt(2), $$where:"sleep(150) || true"}}).n' ;;
          esac
          # El error se IMPRIME, no se manda a /dev/null: una consulta que no llega
          # produce exactamente lo mismo que un nodo tranquilo —`system.profile`
          # vacía— y eso se depura contra la herramienta en vez de contra el generador.
          if ! out=$$($$MSH --eval "$$js" 2>&1); then
            echo "[caliope] mongo-loadgen: falló la forma $$(( n % 3 )): $$out" >&2
          fi
          n=$$(( n + 1 ))
          sleep 4
        done
    networks:
      - replication
    restart: unless-stopped

  # El nodo Mongo con TLS (plan 60, tarea 1). Sólo TLS (`requireTLS`) y con
  # usuario: prueba el transporte cifrado y SCRAM a la vez, que es lo que hace
  # el cliente propio de Calíope en cuanto el perfil marca TLS. No lleva seed: el
  # nodo prueba transporte, no datos.
  #
  # El certificado lo genera EL PROPIO CONTENEDOR al arrancar, en el volumen
  # `mongo-tls-certs` (`/tls`), como el de `redis-tls` y por lo mismo: con
  # `--remote` un montaje desde el Mac llega vacío a la PC. Es su propia CA
  # (`ca.crt` es copia de `server.crt`, con `CA:TRUE`) y su SAN lleva
  # `localhost`, `127.0.0.1` y la IP de `MONGO_RS_HOST` cuando es una IP; se
  # regenera si el SAN que hay no es el de hoy. `mongod` pide clave y
  # certificado en un solo archivo (`/tls/server.pem`), y la imagen `mongo:8.2`
  # no trae `openssl`, así que va construida (`Dockerfile.mongo-tls`). El
  # lanzador copia la CA a `~/CaliopeTestbed/mongo-tls/ca.crt` después de
  # arrancar el nodo (`copy_mongo_tls_ca`).
  mongo-tls:
    build:
      context: .
      dockerfile: Dockerfile.mongo-tls
    container_name: caliope-mongo-tls
    command: ["mongod", "--port", "27019", "--tlsMode", "requireTLS", "--tlsCertificateKeyFile", "/tls/server.pem", "--tlsCAFile", "/tls/ca.crt", "--tlsAllowConnectionsWithoutCertificates", "--bind_ip_all"]
    environment:
      MONGO_RS_HOST:              ${MONGO_RS_HOST:-}
      MONGO_INITDB_ROOT_USERNAME: caliope
      MONGO_INITDB_ROOT_PASSWORD: caliopepass
      MONGO_INITDB_DATABASE:      caliope_demo
    volumes:
      - mongo-tls-certs:/tls
      - mongo-tls-data:/data/db
    ports:
      - "27019:27019"
    networks:
      - replication
    healthcheck:
      test: ["CMD", "mongosh", "--tls", "--tlsCAFile", "/tls/ca.crt", "--host", "localhost", "--port", "27019", "--quiet", "--eval", "db.adminCommand('ping').ok"]
      interval: 5s
      timeout: 5s
      retries: 30
      start_period: 20s
    restart: unless-stopped

  # ────────────────────── REDIS ────────────────────────────────
  # El nodo único de Redis (plan 33, tarea 1). Autentica SÓLO con contraseña
  # (`requirepass`, sin usuarios ACL): `HELLO 3 AUTH default <contraseña>` vale
  # contra él, y un perfil con usuario intentaría la forma ACL contra un servidor
  # que no tiene usuarios.
  #
  # Sin `--enable-debug-command`, a propósito: desde Redis 7 `DEBUG` viene
  # apagado, y el servidor de prueba se parece a uno real. La consulta lenta del
  # `redis-loadgen` se hace con `EVAL` (medido el 2026-09-19: con la
  # configuración por omisión entra en el `SLOWLOG`).
  #
  # La imagen va FIJADA en `redis:7.4`, nunca `latest`: una etiqueta flotante ya
  # rompió el testbed una vez con Mongo, y el que se cae no es el que lo dice.
  redis:
    image: redis:7.4
    container_name: caliope-redis
    command: ["redis-server", "--requirepass", "caliopepass", "--appendonly", "yes", "--slowlog-log-slower-than", "10000", "--maxmemory-policy", "allkeys-lru"]
    volumes:
      - redis-data:/data
    ports:
      - "6379:6379"
    networks:
      - replication
    healthcheck:
      test: ["CMD", "redis-cli", "-a", "caliopepass", "--no-auth-warning", "ping"]
      interval: 2s
      timeout: 3s
      retries: 30
      start_period: 5s
    restart: unless-stopped

  # Siembra la base 0 de `redis` una vez: corre, comprueba y se apaga, como
  # `mongo-rs-setup`. Tres decisiones, y las tres son medidas:
  #
  #   · Los comandos van ESCRITOS AQUÍ, no montados desde una carpeta: con
  #     `--remote` el montaje se resolvería en el disco de la otra máquina.
  #   · Se leen por STDIN: un escape en un argumento de `redis-cli` no se
  #     interpreta (`SET k 'a\r\nb'` guarda 6 bytes; por stdin, 4).
  #   · Sólo siembra sobre una base 0 vacía: el volumen persiste (`appendonly`),
  #     y una segunda pasada duplicaría la lista con `RPUSH`.
  #
  # Son 266 claves: 200 strings `app:sesion:*` (40 con TTL de una hora), 50
  # hashes `app:usuario:*` de seis campos, una lista de 500, un set de 300, un
  # sorted set de 100, un stream de 50 entradas, 9 strings `metricas:diarias:*`
  # (otro prefijo raíz), `sinprefijo`, `clave:con:{etiqueta}:hash` (etiqueta de
  # hash, contra `CLUSTER KEYSLOT`) y `binario:datos`, con `\r\n` dentro (4 bytes:
  # el control positivo de un decodificador RESP que corte por `\r\n`). La base 0
  # NO la escribe nadie más —los pasos que escriben van a las bases 1-3—, salvo
  # `redis-loadgen`, que renueva el TTL de las 40 sesiones sin cambiar el recuento.
  redis-seed:
    image: redis:7.4
    container_name: caliope-redis-seed
    depends_on:
      redis:
        condition: service_healthy
    entrypoint: ["sh", "-c"]
    command:
      - |
        R="redis-cli -h redis -a caliopepass --no-auth-warning -n 0"
        for i in $$(seq 1 60); do
          [ "$$($$R ping 2>/dev/null)" = "PONG" ] && break
          sleep 2
        done
        size=$$($$R DBSIZE 2>/dev/null || echo error)
        if [ "$$size" != "0" ]; then
          echo "[caliope] redis-seed: la base 0 ya tiene $$size claves; no se siembra"
          exit 0
        fi
        {
          for i in $$(seq 1001 1200); do
            if [ "$$i" -le 1040 ]; then
              echo "SET app:sesion:$$i token-$$i EX 3600"
            else
              echo "SET app:sesion:$$i token-$$i"
            fi
          done
          for i in $$(seq 1 50); do
            echo "HSET app:usuario:$$i nombre usuario$$i email usuario$$i@ejemplo.org pais AR plan pro edad $$(( 20 + i )) alta $$(printf '2026-01-%02d' $$(( i % 28 + 1 )))"
          done
          echo "RPUSH app:cola:pendientes $$(seq -f 'tarea-%g' 1 500 | tr '\n' ' ')"
          echo "SADD app:etiquetas:activas $$(seq -f 'etiqueta-%g' 1 300 | tr '\n' ' ')"
          echo "ZADD app:ranking:global $$(seq 1 100 | while read -r n; do printf '%s jugador-%s ' "$$(( n * 10 ))" "$$n"; done)"
          for i in $$(seq 1 50); do
            echo "XADD app:eventos:stream * tipo compra secuencia $$i importe $$(( i * 3 ))"
          done
          for d in $$(seq 1 9); do
            echo "SET metricas:diarias:2026-09-0$$d $$(( d * 1000 ))"
          done
          echo "SET sinprefijo valor-suelto"
          echo "SET clave:con:{etiqueta}:hash valor-con-etiqueta"
        } | $$R >/dev/null
        printf 'a\r\nb' | $$R -x SET binario:datos >/dev/null
        size=$$($$R DBSIZE)
        echo "[caliope] redis-seed: base 0 sembrada con $$size claves"
        [ "$$size" = "266" ]
    networks:
      - replication
    restart: "no"

  # Golpea `redis` en bucle, como `mongo-loadgen`, con tres cosas que hacen
  # medibles otras tantas herramientas (y renueva el TTL de la siembra: abajo):
  #
  #   · Lecturas en la base 0 que ACIERTAN y que FALLAN, para que crezcan
  #     `keyspace_hits` y `keyspace_misses` a la vez (la tasa de aciertos).
  #   · Escrituras en la base 1 (`carga:<n módulo 100>`), nunca en la 0: el
  #     `DBSIZE` de la 0 es el de la siembra y hay pasos que comparan con él.
  #   · Una lenta cada cuatro segundos, por TIEMPO y no por volumen: un `EVAL` que
  #     gira 50 ms de CPU sobre `os.clock()` y cruza los 10 000 µs del
  #     `slowlog-log-slower-than`. `DEBUG SLEEP` en Redis 7 viene denegado. Y NO
  #     sobre `redis.call('TIME')`: dentro de un script Redis 7 congela el reloj,
  #     el bucle no ve pasar el tiempo y gira hasta `busy-reply-threshold` (5 s),
  #     así que el servidor quedaba bloqueado cinco de cada cuatro segundos
  #     (medido el 2026-10-08: `SLOWLOG` con 5 010 782 µs por entrada).
  redis-loadgen:
    image: redis:7.4
    container_name: caliope-redis-loadgen
    depends_on:
      redis:
        condition: service_healthy
    entrypoint: ["sh", "-c"]
    command:
      - |
        R="redis-cli -h redis -a caliopepass --no-auth-warning"
        for i in $$(seq 1 60); do
          [ "$$($$R ping 2>/dev/null)" = "PONG" ] && break
          sleep 2
        done
        if [ "$$($$R ping 2>/dev/null)" != "PONG" ]; then
          echo "[caliope] redis-loadgen: redis no responde tras 120 s" >&2
          exit 1
        fi
        echo "[caliope] redis-loadgen: redis responde; lecturas, escrituras en la base 1 y una lenta cada 4 s"
        n=0
        while true; do
          # Las 40 sesiones con TTL de la siembra caducan a la hora, y con ellas el
          # `DBSIZE` de la base 0 bajaría de 266 a 226 y `app:sesion:1001` dejaría de
          # existir: las tareas que lo leen días después fallarían sin nada roto.
          # Así que cada ~10 min se vuelven a escribir IGUALES, con su hora de
          # nuevo; el recuento no cambia nunca. Sólo cuando la siembra ya terminó
          # (`binario:datos` es lo último que escribe): antes, crearlas haría que
          # `redis-seed` viera la base 0 no vacía y no sembrara.
          if [ $$(( n % 600 )) -eq 0 ] && [ "$$($$R -n 0 EXISTS binario:datos 2>/dev/null)" = "1" ]; then
            out=$$(seq 1001 1040 | while read -r i; do echo "SET app:sesion:$$i token-$$i EX 3600"; done | $$R -n 0 2>&1)
            case "$$out" in *ERR*|*NOAUTH*|*"Could not connect"*) echo "[caliope] redis-loadgen: renovar TTL: $$out" >&2 ;; esac
          fi
          # El error se IMPRIME, no se manda a /dev/null: una orden que no llega
          # produce lo mismo que un nodo tranquilo, y eso se depura contra la
          # herramienta en vez de contra el generador.
          out=$$(printf 'GET app:sesion:%s\nGET noexiste:%s\n' "$$(( 1041 + n % 160 ))" "$$n" | $$R -n 0 2>&1)
          case "$$out" in *ERR*|*NOAUTH*|*"Could not connect"*) echo "[caliope] redis-loadgen: lectura: $$out" >&2 ;; esac
          out=$$($$R -n 1 SET "carga:$$(( n % 100 ))" "v$$n" 2>&1)
          [ "$$out" = "OK" ] || echo "[caliope] redis-loadgen: escritura: $$out" >&2
          if [ $$(( n % 4 )) -eq 0 ]; then
            out=$$($$R EVAL "local s=os.clock(); while os.clock()-s<0.05 do end return 1" 0 2>&1)
            [ "$$out" = "1" ] || echo "[caliope] redis-loadgen: lenta: $$out" >&2
          fi
          n=$$(( n + 1 ))
          sleep 1
        done
    networks:
      - replication
    restart: unless-stopped

  # ── SSH-ONLY REPLICA (replica5) ──────────────────────────────
  # Port 3306 is NOT published on the host: this replica is only
  # reachable through the SSH bastion (localhost:2222 → db:3306).
  # MySQL credentials are the same as the other replicas — the
  # data arrives through GTID replication.
  db:
    image: mariadb:latest
    container_name: mariadb-db
    command:
      - --server-id=6
      - --log-bin=mariadb-bin
      - --binlog-format=ROW
      - --relay-log=relay-bin
      - --gtid-strict-mode=ON
      - --log-slave-updates=ON
      - --read-only=ON
      - --max-connections=500
      - --thread-cache-size=100
      - --innodb-buffer-pool-size=256M
      - --innodb-log-file-size=64M
      - --table-open-cache=2000
      - --slow-query-log=ON
      - --log-output=TABLE
      - --long-query-time=0.1
      - --log-slow-admin-statements=ON
    environment:
      MARIADB_ROOT_PASSWORD: rootpass
    volumes:
      - db-data:/var/lib/mysql
    networks:
      - replication
    healthcheck:
      test: ["CMD", "mariadb-admin", "--user=root", "--password=rootpass", "ping"]
      interval: 5s
      timeout: 5s
      retries: 30
      start_period: 20s
    restart: unless-stopped

  # El nodo Redis con TLS (plan 33, tarea 2). Sólo el 6380 y cifrado: `--port 0`
  # apaga el puerto en claro, y si se dejara abierto la conexión TLS de la app
  # podría salir en verde sin haber negociado TLS ni una vez.
  #
  # El certificado lo genera EL PROPIO CONTENEDOR al arrancar, en el volumen
  # `redis-tls-certs` (`/tls`), y no el lanzador en el Mac: con `--remote` un
  # montaje desde el Mac llega vacío a la PC. Es su propia CA (`ca.crt` es copia
  # de `server.crt`, con `CA:TRUE`) y lleva el nombre por el que se le llama:
  # `localhost`, `127.0.0.1` y la IP de `REDIS_ANNOUNCE_HOST` cuando es una IP,
  # así que el cliente VERIFICA el nombre contra la IP de la PC, que es el camino
  # del usuario. Se regenera si el SAN que hay no es el de hoy (la IP de la PC es
  # DHCP). La imagen `redis:7.4` no trae `openssl`, así que va construida
  # (`Dockerfile.redis-tls`, medido el 2026-10-08). El lanzador copia la CA a
  # `~/CaliopeTestbed/redis-tls/ca.crt` después de arrancar el nodo.
  redis-tls:
    build:
      context: .
      dockerfile: Dockerfile.redis-tls
    container_name: caliope-redis-tls
    environment:
      REDIS_ANNOUNCE_HOST: ${REDIS_ANNOUNCE_HOST:-localhost}
    command: ["redis-server", "--tls-port", "6380", "--port", "0", "--tls-cert-file", "/tls/server.crt", "--tls-key-file", "/tls/server.key", "--tls-ca-cert-file", "/tls/ca.crt", "--tls-auth-clients", "no", "--requirepass", "caliopepass"]
    volumes:
      - redis-tls-certs:/tls
    ports:
      - "6380:6380"
    networks:
      - replication
    healthcheck:
      test: ["CMD", "redis-cli", "--tls", "--cacert", "/tls/ca.crt", "-p", "6380", "-a", "caliopepass", "--no-auth-warning", "ping"]
      interval: 2s
      timeout: 3s
      retries: 30
      start_period: 5s
    restart: unless-stopped

  # El nodo Redis tras el bastión (plan 33, tarea 2): sin `ports`, como `db` y
  # `clickhouse-tunnel`. Sólo se alcanza por `localhost:2222` →
  # `redis-tunnel:6379`, con `tunneluser` / `tunnel123`. Vacío a propósito: lo
  # que se prueba por aquí es el transporte, no los datos.
  redis-tunnel:
    image: redis:7.4
    container_name: caliope-redis-tunnel
    command: ["redis-server", "--requirepass", "caliopepass"]
    networks:
      - replication
    healthcheck:
      test: ["CMD", "redis-cli", "-a", "caliopepass", "--no-auth-warning", "ping"]
      interval: 2s
      timeout: 3s
      retries: 30
      start_period: 5s
    restart: unless-stopped

  # ── REDIS CLUSTER ─────────────────────────────────────────────
  # Seis nodos en modo clúster (plan 33, tarea 3): tres primarios y una réplica de
  # cada uno, que forma `redis-cluster-setup`. El diseño es el medido en §B.14.6
  # del plan 33, y cada pieza corrige algo que no funcionaba:
  #
  #   · SIN `--cluster-announce-ip`: anunciar `127.0.0.1` hace que cada nodo le
  #     diga a los demás una dirección que para ellos es ellos mismos, y el
  #     clúster no se forma nunca (`cluster_known_nodes:1`). El bus entre nodos
  #     va por las IP de Docker, que se ven entre ellos.
  #   · A los CLIENTES se les dice `REDIS_ANNOUNCE_HOST` (la IP de la PC con
  #     `--remote`, `localhost` en local) por `--cluster-announce-hostname` y
  #     `--cluster-preferred-endpoint-type hostname`: así `CLUSTER SLOTS` y los
  #     `MOVED` llevan una dirección que el cliente alcanza, y no la `172.x`.
  #   · Puertos publicados UNO A UNO, mismo número dentro y fuera, porque el
  #     puerto anunciado es el de dentro. El bus (`1700N`) no se publica: sólo lo
  #     usan los nodos entre sí.
  redis-cluster-1:
    image: redis:7.4
    container_name: caliope-redis-cluster-1
    command: ["redis-server", "--port", "7001", "--cluster-enabled", "yes", "--cluster-config-file", "nodes.conf", "--cluster-node-timeout", "5000", "--appendonly", "yes", "--requirepass", "caliopepass", "--masterauth", "caliopepass", "--cluster-announce-hostname", "${REDIS_ANNOUNCE_HOST:-localhost}", "--cluster-preferred-endpoint-type", "hostname"]
    ports:
      - "7001:7001"
    networks:
      - replication
    healthcheck:
      test: ["CMD", "redis-cli", "-p", "7001", "-a", "caliopepass", "--no-auth-warning", "ping"]
      interval: 2s
      timeout: 3s
      retries: 30
      start_period: 5s
    restart: unless-stopped

  redis-cluster-2:
    image: redis:7.4
    container_name: caliope-redis-cluster-2
    command: ["redis-server", "--port", "7002", "--cluster-enabled", "yes", "--cluster-config-file", "nodes.conf", "--cluster-node-timeout", "5000", "--appendonly", "yes", "--requirepass", "caliopepass", "--masterauth", "caliopepass", "--cluster-announce-hostname", "${REDIS_ANNOUNCE_HOST:-localhost}", "--cluster-preferred-endpoint-type", "hostname"]
    ports:
      - "7002:7002"
    networks:
      - replication
    healthcheck:
      test: ["CMD", "redis-cli", "-p", "7002", "-a", "caliopepass", "--no-auth-warning", "ping"]
      interval: 2s
      timeout: 3s
      retries: 30
      start_period: 5s
    restart: unless-stopped

  redis-cluster-3:
    image: redis:7.4
    container_name: caliope-redis-cluster-3
    command: ["redis-server", "--port", "7003", "--cluster-enabled", "yes", "--cluster-config-file", "nodes.conf", "--cluster-node-timeout", "5000", "--appendonly", "yes", "--requirepass", "caliopepass", "--masterauth", "caliopepass", "--cluster-announce-hostname", "${REDIS_ANNOUNCE_HOST:-localhost}", "--cluster-preferred-endpoint-type", "hostname"]
    ports:
      - "7003:7003"
    networks:
      - replication
    healthcheck:
      test: ["CMD", "redis-cli", "-p", "7003", "-a", "caliopepass", "--no-auth-warning", "ping"]
      interval: 2s
      timeout: 3s
      retries: 30
      start_period: 5s
    restart: unless-stopped

  redis-cluster-4:
    image: redis:7.4
    container_name: caliope-redis-cluster-4
    command: ["redis-server", "--port", "7004", "--cluster-enabled", "yes", "--cluster-config-file", "nodes.conf", "--cluster-node-timeout", "5000", "--appendonly", "yes", "--requirepass", "caliopepass", "--masterauth", "caliopepass", "--cluster-announce-hostname", "${REDIS_ANNOUNCE_HOST:-localhost}", "--cluster-preferred-endpoint-type", "hostname"]
    ports:
      - "7004:7004"
    networks:
      - replication
    healthcheck:
      test: ["CMD", "redis-cli", "-p", "7004", "-a", "caliopepass", "--no-auth-warning", "ping"]
      interval: 2s
      timeout: 3s
      retries: 30
      start_period: 5s
    restart: unless-stopped

  redis-cluster-5:
    image: redis:7.4
    container_name: caliope-redis-cluster-5
    command: ["redis-server", "--port", "7005", "--cluster-enabled", "yes", "--cluster-config-file", "nodes.conf", "--cluster-node-timeout", "5000", "--appendonly", "yes", "--requirepass", "caliopepass", "--masterauth", "caliopepass", "--cluster-announce-hostname", "${REDIS_ANNOUNCE_HOST:-localhost}", "--cluster-preferred-endpoint-type", "hostname"]
    ports:
      - "7005:7005"
    networks:
      - replication
    healthcheck:
      test: ["CMD", "redis-cli", "-p", "7005", "-a", "caliopepass", "--no-auth-warning", "ping"]
      interval: 2s
      timeout: 3s
      retries: 30
      start_period: 5s
    restart: unless-stopped

  redis-cluster-6:
    image: redis:7.4
    container_name: caliope-redis-cluster-6
    command: ["redis-server", "--port", "7006", "--cluster-enabled", "yes", "--cluster-config-file", "nodes.conf", "--cluster-node-timeout", "5000", "--appendonly", "yes", "--requirepass", "caliopepass", "--masterauth", "caliopepass", "--cluster-announce-hostname", "${REDIS_ANNOUNCE_HOST:-localhost}", "--cluster-preferred-endpoint-type", "hostname"]
    ports:
      - "7006:7006"
    networks:
      - replication
    healthcheck:
      test: ["CMD", "redis-cli", "-p", "7006", "-a", "caliopepass", "--no-auth-warning", "ping"]
      interval: 2s
      timeout: 3s
      retries: 30
      start_period: 5s
    restart: unless-stopped

  # Forma el clúster y lo siembra, y se apaga: el patrón de `mongo-rs-setup`.
  # Idempotente, porque los contenedores se reinician y `--only` lo vuelve a
  # correr:
  #
  #   · Si el clúster ya está formado (`cluster_state:ok` y seis nodos), no lo
  #     toca. Si se ven seis nodos pero el estado aún no es `ok`, espera 30 s a
  #     que converja antes de darlo por roto.
  #   · Si no, deja los seis vacíos (`FLUSHALL` y `CLUSTER RESET HARD`, dos
  #     veces: una réplica no acepta `FLUSHALL` hasta que el reset la vuelve
  #     primaria) y lo crea con las IP de los servicios: `--cluster create`
  #     quiere IP, no nombres.
  #   · Siembra 300 claves (`cl:a:N`, `cl:b:N`, `cl:c:N`) sólo si los primarios no
  #     las tienen ya: cuenta `cl:*` y no `DBSIZE`, porque una prueba que escriba
  #     una clave más haría que la siguiente corrida sembrara y saliera en rojo. Por stdin y con `-c`; y como con el endpoint del cliente el
  #     `-c` seguiría el `MOVED` a una dirección que aquí dentro no es ese nodo,
  #     antes de sembrar los seis pasan a anunciar `ip` y después vuelven a
  #     `hostname`, también si la siembra falla (`trap`): un clúster que se queda
  #     anunciando IP de Docker hace que lo siguiente mida otra cosa sin avisar.
  redis-cluster-setup:
    image: redis:7.4
    container_name: caliope-redis-cluster-setup
    depends_on:
      redis-cluster-1:
        condition: service_healthy
      redis-cluster-2:
        condition: service_healthy
      redis-cluster-3:
        condition: service_healthy
      redis-cluster-4:
        condition: service_healthy
      redis-cluster-5:
        condition: service_healthy
      redis-cluster-6:
        condition: service_healthy
    entrypoint: ["bash", "-c"]
    command:
      - |
        A="-a caliopepass --no-auth-warning"
        cli() { local n=$$1; shift; redis-cli -h redis-cluster-$$n -p 700$$n $$A "$$@"; }
        field() { cli 1 cluster info 2>/dev/null | tr -d '\r' | sed -n "s/^$$1://p"; }
        for n in 1 2 3 4 5 6; do
          i=0
          until [ "$$(cli $$n ping 2>/dev/null)" = "PONG" ]; do
            i=$$((i+1))
            [ $$i -ge 60 ] && { echo "[caliope] redis-cluster-setup: redis-cluster-$$n no responde tras 120 s" >&2; exit 1; }
            sleep 2
          done
        done
        # Un contenedor recreado cambia de IP y su `nodes.conf` guarda la de antes: el estado
        # se lee `ok` un instante y cae a `fail` en cuanto vence el `cluster-node-timeout`, con
        # este setup ya salido en verde (auditoría 58, tarea 43, 2026-10-21). Un `CLUSTER MEET`
        # con la IP de hoy de cada nodo lo repara sin tocar slots ni datos; en uno nuevo, la
        # rama de abajo lo deja vacío y lo crea igual.
        for n in 1 2 3 4 5 6; do
          for m in 1 2 3 4 5 6; do
            [ $$n = $$m ] && continue
            cli $$n cluster meet $$(getent hosts redis-cluster-$$m | awk '{print $$1}') 700$$m >/dev/null 2>&1
          done
        done
        sleep 6
        formed=no
        if [ "$$(field cluster_known_nodes)" = "6" ]; then
          for i in $$(seq 1 15); do
            [ "$$(field cluster_state)" = "ok" ] && { formed=yes; break; }
            sleep 2
          done
        fi
        if [ "$$formed" = "yes" ]; then
          echo "[caliope] redis-cluster-setup: el clúster ya está formado; no se crea"
        else
          for n in 1 2 3 4 5 6; do
            cli $$n flushall >/dev/null 2>&1; cli $$n cluster reset hard >/dev/null 2>&1
            cli $$n flushall >/dev/null 2>&1; cli $$n cluster reset hard >/dev/null 2>&1
          done
          addrs=""
          for n in 1 2 3 4 5 6; do
            addrs="$$addrs $$(getent hosts redis-cluster-$$n | awk '{print $$1}'):700$$n"
          done
          echo "[caliope] redis-cluster-setup: creando el clúster con$$addrs"
          redis-cli $$A --cluster create $$addrs --cluster-replicas 1 --cluster-yes >/dev/null || exit 1
          for i in $$(seq 1 30); do
            [ "$$(field cluster_state)" = "ok" ] && [ "$$(field cluster_known_nodes)" = "6" ] && break
            sleep 2
          done
          [ "$$(field cluster_state)" = "ok" ] || { echo "[caliope] redis-cluster-setup: el clúster no llegó a cluster_state:ok" >&2; exit 1; }
        fi
        total() {
          t=0
          for n in 1 2 3 4 5 6; do
            cli $$n info replication 2>/dev/null | grep -q '^role:master' && t=$$((t + $$(cli $$n --scan --pattern 'cl:*' | wc -l)))
          done
          echo $$t
        }
        if [ "$$(total)" = "300" ]; then
          echo "[caliope] redis-cluster-setup: los primarios ya tienen las 300 claves cl:*; no se siembra"
          exit 0
        fi
        restore() { for n in 1 2 3 4 5 6; do cli $$n config set cluster-preferred-endpoint-type hostname >/dev/null; done; }
        trap restore EXIT
        for n in 1 2 3 4 5 6; do cli $$n config set cluster-preferred-endpoint-type ip >/dev/null; done
        for p in a b c; do
          for i in $$(seq 1 100); do echo "SET cl:$$p:$$i v$$i"; done
        done | redis-cli -c -h redis-cluster-1 -p 7001 $$A >/dev/null
        size=$$(total)
        echo "[caliope] redis-cluster-setup: los primarios tienen $$size claves cl:*"
        [ "$$size" = "300" ]
    networks:
      - replication
    restart: "no"

  # ────────────────────── ORACLE ───────────────────────────────
  # El nodo de trabajo de la séptima familia (plan 63): Oracle AI Database 26ai
  # Free con los esquemas `DEMO` y `RRHH` en la PDB `FREEPDB1`.
  #
  # La imagen va FIJADA POR DIGEST, no por `latest`: el 2026-10-15 ese digest
  # resolvió a la 23.26.3.0.0 (`V$INSTANCE.VERSION_FULL`; la etiqueta de la
  # imagen dice 23.26.0), y todo lo que el plan 63 midió es de esa versión. Un
  # `latest` cambiaría de versión sin que nadie lo midiera. Oracle la publica
  # para amd64 y arm64, sin cuenta.
  #
  # Es el nodo que más pesa: 13,8 GB de imagen en disco y 2,17 GiB de memoria
  # ociosa (`docker stats`, medido el 2026-10-15), y Free no arranca con menos de
  # unos 2 GB libres. Por eso va con `--only oracle` salvo que la tarea pida más.
  #
  # El seed (`scripts/oracle-seed/`) entra en la imagen sobre
  # `/opt/oracle/scripts/`: sus `setup/` y `startup/` se suman a los de la
  # imagen. OJO: la imagen sólo ejecuta `setup/` cuando CREA la base, y Free
  # trae la base ya creada (`/opt/oracle/oradata/.FREE.created` va dentro de la
  # imagen y el volumen vacío la recibe en el primer arranque), así que `setup/`
  # no corre nunca —medido el 2026-10-15, plan 63, tarea 1—. Lo que sí corre,
  # en cada arranque y tras «DATABASE IS READY TO USE!», es `startup/`: allí
  # `00-caliope-seed.sh` aplica `setup/` con el propio `runUserScripts.sh` de la
  # imagen (los `.sql` como SYSDBA, los `.sh` con `.`, en orden alfabético) si
  # falta la marca, y no hace nada si está.
  #
  # La marca la escribe el último archivo del seed, `setup/99-marca.sh`, en el
  # VOLUMEN (`/opt/oracle/oradata/caliope-seed-ok`): en `/tmp` desaparecería en
  # cada reinicio y el seed se repetiría sobre una base ya sembrada. El
  # healthcheck exige la marca además del `checkDBStatus.sh` de la imagen: sin
  # ella el nodo saldría sano con la base recién abierta y sin `DEMO`.
  #
  # Sólo `ORACLE_PWD` (la de SYS, SYSTEM y PDBADMIN): ni `ORACLE_SID` ni
  # `ORACLE_PDB`, porque Free usa `FREE` y `FREEPDB1` y no admite otros.
  oracle:
    build:
      context: .
      dockerfile: Dockerfile.seeded
      args:
        BASE: container-registry.oracle.com/database/free@sha256:f988b0c04c4c386cd306a2a914c0d7a9702d83acc31b064a28ad8eb6278a8fba
        SEED: scripts/oracle-seed
        DEST: /opt/oracle/scripts
    platform: linux/amd64
    container_name: oracle
    environment:
      ORACLE_PWD: "Caliope_pass1"
    shm_size: 1g
    volumes:
      - oracle-data:/opt/oracle/oradata
    ports:
      - "1521:1521"
    networks:
      - replication
    healthcheck:
      test: ["CMD-SHELL", "test -f /opt/oracle/oradata/caliope-seed-ok && /opt/oracle/checkDBStatus.sh"]
      interval: 10s
      timeout: 10s
      retries: 60
      start_period: 120s
    restart: unless-stopped

  # El nodo TCPS (plan 63, tarea 3): la misma imagen y la misma semilla que
  # `oracle`, y además TCPS en 2484 con un certificado firmado por la CA del
  # testbed (`generate_oracle_tls`, `31-config-oracle.sh`). Publica SÓLO el
  # 2484: el listener sigue escuchando TCP en 1521 dentro del contenedor (lo usa
  # `testbed-sql.py`), pero desde fuera una conexión que no negocie TLS no entra.
  # El healthcheck exige además que el listener escuche TCPS: el primer arranque
  # lo configura `startup/10-tcps.sh` tras la semilla, y hasta entonces el nodo
  # no está sano.
  oracle-tls:
    build:
      context: .
      dockerfile: Dockerfile.oracle
      args:
        BASE: container-registry.oracle.com/database/free@sha256:f988b0c04c4c386cd306a2a914c0d7a9702d83acc31b064a28ad8eb6278a8fba
        SEED: scripts/oracle-seed
        CONF: scripts/oracle-tls
        STARTUP: scripts/oracle-tls-startup
    platform: linux/amd64
    container_name: oracle-tls
    environment:
      ORACLE_PWD: "Caliope_pass1"
      TCPS_CERTS_LOCATION: /opt/oracle/tls
    shm_size: 1g
    volumes:
      - oracle-tls-data:/opt/oracle/oradata
    ports:
      - "2484:2484"
    networks:
      - replication
    healthcheck:
      test: ["CMD-SHELL", "test -f /opt/oracle/oradata/caliope-seed-ok && /opt/oracle/checkDBStatus.sh && lsnrctl status | grep -qi 'PROTOCOL=tcps'"]
      interval: 10s
      timeout: 10s
      retries: 60
      start_period: 120s
    restart: unless-stopped

  # ── SSH BASTION ──────────────────────────────────────────────
  # SSH gateway into the internal network. Caliope uses it to
  # tunnel connections down to db:3306.
  # SSH credentials: tunneluser / tunnel123  (or ED25519 key).
  bastion:
    build:
      context: .
      dockerfile: Dockerfile.bastion
    container_name: mariadb-bastion
    ports:
      - "2222:22"
    networks:
      - replication
    restart: unless-stopped

  # ────────────────── AUTOMATIC CONFIGURATION ──────────────────
  # The replication setup script is baked into the image at build
  # time, so no bind mount is required at runtime.
  setup:
    build:
      context: .
      dockerfile: Dockerfile.setup
    container_name: mariadb-setup
    environment:
      HOST_IP: ${HOST_IP:-127.0.0.1}
      # Las réplicas que esta corrida levantó, en orden: el asistente puede no
      # levantarlas todas, y un `setup` que esperara a una que no existe se
      # quedaba sus 120 s y salía con error. Sin la variable, las cinco. El
      # orden importa: `replica3` va antes que `replica4`, que lee de ella.
      SETUP_REPLICAS: ${SETUP_REPLICAS:-replica1 replica2 replica3 replica4 db}
    # Sólo el primario: las réplicas que tocan las espera el propio script, y
    # declararlas aquí arrastraría las cinco aunque el asistente eligiera dos.
    depends_on:
      primary:
        condition: service_healthy
    networks:
      - replication
    # `no`, y no `on-failure`. Cuatro servicios dependen de este contenedor con
    # `service_completed_successfully`, así que un `setup` que falla y se relanza
    # no es un reintento: es un cuelgue indefinido de `docker compose up`, con
    # standalone, mysql-latest, mysql8-gis y loadgen en «Waiting» para siempre y sin
    # que la salida diga por qué. Con `no`, o completa o el arranque falla
    # diciéndolo. Y los fallos que sólo afectan a la replicación salen del script
    # con código 0 a propósito (ver el resumen final de setup-replication.sh):
    # el resto del testbed no depende de que las réplicas sincronicen.
    restart: "no"

  # ────────────────────── LOAD GENERATOR ───────────────────────
  # Starts automatically after "setup" completes successfully.
  # Persistent FIFO connections: 24 SELECT on replicas + 12
  # write/DDL on primary. Target ~10 QPS per node. Write mix:
  # 60% INSERT · 20% UPDATE · 20% DELETE.
  #
  # Y seis conexiones más, una por nodo MySQL/MariaDB salvo `db`: el worker lento,
  # que cada 4 s lanza a propósito una consulta que cruza el umbral de
  # 0,1 s. Sin él la herramienta «Consultas lentas» y la tarjeta del
  # Tablero enseñan una tabla vacía, que es indistinguible de estar roto.
  loadgen:
    build:
      context: .
      dockerfile: Dockerfile.loadgen
    container_name: mariadb-loadgen
    depends_on:
      setup:
        condition: service_completed_successfully
    networks:
      - replication
    restart: unless-stopped

# ────────────────────── VOLUMES ──────────────────────────────
volumes:
  primary-data:
  replica1-data:
  replica2-data:
  replica3-data:
  replica4-data:
  standalone-data:
  db-data:
  mysql-latest-data:
  mysql8-data:
  mysql-primary-data:
  mysql-replica1-data:
  mysql-replica2-data:
  postgres-gis-data:
  postgres-primary-data:
  postgres-replica-data:
  sqlserver-data:
  sqlserver-restore-data:
  oracle-data:
  oracle-tls-data:
  clickhouse-data:
  clickhouse-tls-data:
  clickhouse-tunnel-data:
  clickhouse-keeper-data:
  clickhouse-r1-data:
  clickhouse-r2-data:
  clickhouse-r3-data:
  clickhouse-r4-data:
  mongo-data:
  mongo-rs-data:
  mongo-rs2-data:
  mongo-rs3-data:
  mongo-tls-certs:
  mongo-tls-data:
  redis-data:
  redis-tls-certs:

# ────────────────────── INTERNAL NETWORK ─────────────────────
networks:
  replication:
    driver: bridge
COMPOSE_EOF

  # ── Dockerfile.bastion ────────────────────────────────────────────────────
  cat > "${WORKDIR}/Dockerfile.bastion" <<'BASTION_EOF'
# SSH bastion image for tunnel testing.
# Compatible with ARM64 (Apple Silicon) and AMD64.
# Ubuntu 22.04 ships OpenSSH 8.9p1, compatible with NIO SSH 0.13.0.
# Ubuntu 24.04 enables strict KEX (Terrapin, OpenSSH 9.6+) which causes
# [preauth] failures with the current swift-nio-ssh release.
FROM ubuntu:22.04

RUN apt-get update && \
    apt-get install -y --no-install-recommends openssh-server && \
    rm -rf /var/lib/apt/lists/*

# sshd requires this directory at runtime
RUN mkdir -p /run/sshd

# Tunnel user (password: tunnel123)
RUN useradd -m -s /bin/bash tunneluser && \
    echo "tunneluser:tunnel123" | chpasswd

# Enable password + public-key authentication, allow TCP forwarding
# (required by the direct-tcpip channel used by NIOSSH).
RUN sed -i \
      -e 's/^#\?PermitRootLogin.*/PermitRootLogin no/'                \
      -e 's/^#\?PasswordAuthentication.*/PasswordAuthentication yes/' \
      -e 's/^#\?PubkeyAuthentication.*/PubkeyAuthentication yes/'     \
      /etc/ssh/sshd_config && \
    echo "AllowTcpForwarding yes" >> /etc/ssh/sshd_config && \
    echo "X11Forwarding no"       >> /etc/ssh/sshd_config

# Generate SSH host keys
RUN ssh-keygen -A

EXPOSE 22

# -D foreground, -e forwards logs to stderr (visible via docker compose logs)
CMD ["/usr/sbin/sshd", "-D", "-e"]
BASTION_EOF

  # ── Dockerfile.setup ──────────────────────────────────────────────────────
  cat > "${WORKDIR}/Dockerfile.setup" <<'SETUP_DF_EOF'
# Image for the automatic replication setup service.
# The script and seed files are copied into the image at build time,
# so no bind mount is required at runtime.
FROM mariadb:latest
COPY scripts/setup-replication.sh /setup.sh
COPY scripts/seed/                /seed/
RUN chmod +x /setup.sh
ENTRYPOINT ["bash", "/setup.sh"]
SETUP_DF_EOF

  # ── Dockerfile.loadgen ────────────────────────────────────────────────────
  cat > "${WORKDIR}/Dockerfile.loadgen" <<'LOADGEN_DF_EOF'
FROM mariadb:latest
COPY scripts/load-generator.sh /load-generator.sh
RUN chmod +x /load-generator.sh
ENTRYPOINT ["bash", "/load-generator.sh"]
LOADGEN_DF_EOF

  # ── Dockerfile.seeded ─────────────────────────────────────────────────────
  # Una imagen de motor con su semilla dentro. La usan los ocho servicios que
  # antes montaban una carpeta de `scripts/` (el porqué, en la cabecera del
  # compose): cada uno pasa su imagen base, la carpeta y el destino. La imagen
  # sólo añade archivos, así que el ENTRYPOINT, el CMD y el usuario son los de
  # la base, y la semilla se aplica igual que con el montaje: sólo la primera vez
  # que se crea el volumen de datos.
  cat > "${WORKDIR}/Dockerfile.seeded" <<'SEEDED_DF_EOF'
ARG BASE
FROM ${BASE}
ARG SEED
ARG DEST
COPY ${SEED}/ ${DEST}/
SEEDED_DF_EOF

  # ── Dockerfile.mariadb-standalone ─────────────────────────────────────────
  # `Dockerfile.seeded` más lo que pide la cuenta PAM de `mariadb-standalone`
  # (plan 61, tarea 1): un usuario del sistema con contraseña y el servicio PAM
  # `mariadb`, que el `auth_pam` del servidor comprueba con `pam_unix` a través
  # de su `auth_pam_tool` (setuid root en la imagen, por eso lee `/etc/shadow`).
  # La imagen trae `useradd`, `chpasswd` y `pam_unix.so`; no se instala nada.
  #
  # Y su TLS con una CA que se puede copiar (plan 61, tarea 4): sin certificado
  # configurado, MariaDB 11.4+ fabrica uno autofirmado en memoria al arrancar,
  # distinto en cada arranque y sin `ssl_ca` que copiar, así que «con la CA del
  # nodo» no se podía medir. El entrypoint genera en `/tls` uno autofirmado con
  # `CA:TRUE` (hace de su propia CA, como `postgres-gis`), con la IP de la PC en
  # el SAN en remoto, y el `command:` del servicio lo apunta con `--ssl-*`.
  # La imagen trae `openssl`.
  cat > "${WORKDIR}/Dockerfile.mariadb-standalone" <<'MARIADB_STANDALONE_DF_EOF'
FROM mariadb:latest
COPY scripts/seed/ /docker-entrypoint-initdb.d/
COPY scripts/mariadb-standalone-tls-entrypoint.sh /usr/local/bin/mariadb-standalone-tls-entrypoint.sh
RUN useradd -M -s /usr/sbin/nologin pamuser && \
    echo 'pamuser:pampass' | chpasswd && \
    printf 'auth    required pam_unix.so\naccount required pam_unix.so\n' > /etc/pam.d/mariadb && \
    chmod 755 /usr/local/bin/mariadb-standalone-tls-entrypoint.sh
ENTRYPOINT ["mariadb-standalone-tls-entrypoint.sh"]
CMD ["mariadbd"]
MARIADB_STANDALONE_DF_EOF

  mkdir -p "${WORKDIR}/scripts"
  cat > "${WORKDIR}/scripts/mariadb-standalone-tls-entrypoint.sh" <<'MARIADB_STANDALONE_TLS_EP_EOF'
#!/bin/sh
# Genera el certificado de `mariadb-standalone` si falta o si su SAN no es el de
# hoy (como `postgres-gis`), lo deja legible por `mysql` y le pasa el control al
# entrypoint de la imagen, que antepone `mariadbd` a las opciones del `command:`.
set -e
mkdir -p /tls
host="${MARIADB_TLS_HOST:-}"
san="DNS:localhost,IP:127.0.0.1"
case "$host" in
  ""|*[!0-9.]*) ;;
  *) san="${san},IP:${host}" ;;
esac
want="$(printf '%s' "$san" | sed 's/IP:/IPAddress:/g')"
have=""
if [ -s /tls/server.crt ] && [ -s /tls/server.key ]; then
  have="$(openssl x509 -in /tls/server.crt -noout -ext subjectAltName 2>/dev/null | tail -n +2 | tr -d ' ')"
fi
if [ "$have" != "$want" ]; then
  echo "[caliope] mariadb-standalone: certificado nuevo para ${san}"
  openssl req -x509 -newkey rsa:2048 -nodes -days 825 -sha256 \
    -keyout /tls/server.key -out /tls/server.crt \
    -subj "/CN=caliope-mariadb-standalone" \
    -addext "subjectAltName=${san}" \
    -addext "basicConstraints=critical,CA:TRUE" \
    -addext "extendedKeyUsage=serverAuth" 2>/dev/null
fi
chown mysql:mysql /tls/server.key /tls/server.crt
chmod 600 /tls/server.key
chmod 644 /tls/server.crt
exec docker-entrypoint.sh "$@"
MARIADB_STANDALONE_TLS_EP_EOF

  # ── Dockerfile.postgres-gis ───────────────────────────────────────────────
  # El nodo de trabajo de Postgres con TLS y con su propio `pg_hba.conf` (plan
  # 61, tarea 1). Las dos cosas van en la imagen y no en el directorio de datos,
  # porque el volumen ya existe en toda máquina que lanzó el testbed y `initdb.d`
  # no vuelve a correr: un `ALTER SYSTEM SET ssl` o una línea añadida al
  # `pg_hba.conf` de `PGDATA` sólo llegarían a un volumen nuevo. El `command:`
  # apunta `hba_file` y los certificados a lo que deja aquí la imagen.
  cat > "${WORKDIR}/Dockerfile.postgres-gis" <<'POSTGRES_GIS_DF_EOF'
FROM imresamu/postgis:17-3.5
COPY scripts/pg-seed/ /docker-entrypoint-initdb.d/
COPY scripts/postgres-gis-hba.conf /etc/caliope/pg_hba.conf
COPY scripts/postgres-gis-tls-entrypoint.sh /usr/local/bin/postgres-gis-tls-entrypoint.sh
RUN chmod 644 /etc/caliope/pg_hba.conf && \
    chmod 755 /usr/local/bin/postgres-gis-tls-entrypoint.sh
ENTRYPOINT ["postgres-gis-tls-entrypoint.sh"]
CMD ["postgres"]
POSTGRES_GIS_DF_EOF

  # Las líneas de cada cuenta van DELANTE de las genéricas: pg_hba gana la
  # primera que casa, y las de 127.0.0.1 son `trust`, así que una prueba desde
  # dentro del contenedor no comprobaría nada si cayera en ellas.
  #   · `clearuser` con la contraseña en claro (`password`) **también sin TLS**
  #     (plan 61, tarea 18): el servidor la pide igual por un canal sin cifrar,
  #     y es el CLIENTE quien tiene que negarse a mandarla (`cleartextWithoutTLS`,
  #     con cero bytes enviados tras la petición). Con un `hostnossl … reject`
  #     el servidor cortaba antes de pedirla y ese caso no se podía medir.
  #   · `md5user` por `md5`: su contraseña está guardada en md5, y la línea
  #     genérica de `scram-sha-256` no la aceptaría.
  # El resto es el archivo que escribe la imagen oficial al crear el volumen.
  cat > "${WORKDIR}/scripts/postgres-gis-hba.conf" <<'POSTGRES_GIS_HBA_EOF'
# TYPE    DATABASE     USER        ADDRESS          METHOD
host      all          clearuser   all              password
host      all          md5user     all              md5
local     all          all                          trust
host      all          all         127.0.0.1/32     trust
host      all          all         ::1/128          trust
local     replication  all                          trust
host      replication  all         127.0.0.1/32     trust
host      replication  all         ::1/128          trust
host      all          all         all              scram-sha-256
POSTGRES_GIS_HBA_EOF

  cat > "${WORKDIR}/scripts/postgres-gis-tls-entrypoint.sh" <<'POSTGRES_GIS_TLS_EP_EOF'
#!/bin/sh
# Genera el certificado de `postgres-gis` si falta o si su SAN no es el de hoy
# (como `mongo-tls`), lo deja legible sólo por `postgres` —el servidor se niega a
# arrancar con una llave abierta— y le pasa el control al entrypoint de la imagen.
set -e
mkdir -p /tls
host="${PG_TLS_HOST:-}"
san="DNS:localhost,IP:127.0.0.1"
case "$host" in
  ""|*[!0-9.]*) ;;
  *) san="${san},IP:${host}" ;;
esac
want="$(printf '%s' "$san" | sed 's/IP:/IPAddress:/g')"
have=""
if [ -s /tls/server.crt ] && [ -s /tls/server.key ]; then
  have="$(openssl x509 -in /tls/server.crt -noout -ext subjectAltName 2>/dev/null | tail -n +2 | tr -d ' ')"
fi
if [ "$have" != "$want" ]; then
  echo "[caliope] postgres-gis: certificado nuevo para ${san}"
  openssl req -x509 -newkey rsa:2048 -nodes -days 825 -sha256 \
    -keyout /tls/server.key -out /tls/server.crt \
    -subj "/CN=caliope-postgres-gis" \
    -addext "subjectAltName=${san}" \
    -addext "basicConstraints=critical,CA:TRUE" \
    -addext "extendedKeyUsage=serverAuth" 2>/dev/null
fi
chown postgres:postgres /tls/server.key /tls/server.crt
chmod 600 /tls/server.key
chmod 644 /tls/server.crt
exec docker-entrypoint.sh "$@"
POSTGRES_GIS_TLS_EP_EOF

  # ── Dockerfile.clickhouse ─────────────────────────────────────────────────
  # `Dockerfile.seeded` copia UNA carpeta; un nodo de ClickHouse que además
  # lleve configuración propia necesita dos: la semilla al `initdb.d` del
  # entrypoint y la configuración a `/etc/clickhouse-server/`, que se FUNDE con
  # la de la imagen (los fragmentos van en `config.d/` o `users.d/`). La
  # configuración se copia como `clickhouse`, el usuario con que corre el
  # servidor, para que pueda leer la llave del certificado.
  cat > "${WORKDIR}/Dockerfile.clickhouse" <<'CLICKHOUSE_DF_EOF'
ARG BASE
FROM ${BASE}
ARG SEED
ARG CONF
COPY ${SEED}/ /docker-entrypoint-initdb.d/
COPY --chown=clickhouse:clickhouse ${CONF}/ /etc/clickhouse-server/
CLICKHOUSE_DF_EOF

  # ── Dockerfile.oracle ─────────────────────────────────────────────────────
  # El nodo `oracle-tls` (plan 63, tarea 3): la semilla de `oracle` sobre
  # `/opt/oracle/scripts` (con su `startup/00-caliope-seed.sh`), los certificados
  # en `/opt/oracle/tls` —el `TCPS_CERTS_LOCATION` del servicio—, propiedad de
  # `oracle`, que es quien los lee, y `startup/10-tcps.sh`, que llama a
  # `configTcps.sh` de la imagen después de la semilla.
  cat > "${WORKDIR}/Dockerfile.oracle" <<'ORACLE_DF_EOF'
ARG BASE
FROM ${BASE}
ARG SEED
ARG CONF
ARG STARTUP
COPY ${SEED}/ /opt/oracle/scripts/
COPY --chown=oracle:oinstall ${CONF}/ /opt/oracle/tls/
COPY ${STARTUP}/ /opt/oracle/scripts/startup/
ORACLE_DF_EOF

  # ── Dockerfile.redis-tls ──────────────────────────────────────────────────
  # `redis:7.4` no trae `openssl`, y el nodo TLS genera su certificado al
  # arrancar (plan 33, tarea 2). El entrypoint vive en `scripts/` porque el
  # `.dockerignore` sólo deja subir esa carpeta al contexto.
  cat > "${WORKDIR}/Dockerfile.redis-tls" <<'REDIS_TLS_DF_EOF'
FROM redis:7.4
RUN apt-get update && \
    apt-get install -y --no-install-recommends openssl && \
    rm -rf /var/lib/apt/lists/*
COPY scripts/redis-tls-entrypoint.sh /usr/local/bin/redis-tls-entrypoint.sh
RUN chmod 755 /usr/local/bin/redis-tls-entrypoint.sh
ENTRYPOINT ["redis-tls-entrypoint.sh"]
REDIS_TLS_DF_EOF

  mkdir -p "${WORKDIR}/scripts"
  cat > "${WORKDIR}/scripts/redis-tls-entrypoint.sh" <<'REDIS_TLS_EP_EOF'
#!/bin/sh
# Genera el certificado del nodo `redis-tls` si falta o si su SAN no es el de
# hoy, y le pasa el control al entrypoint de la imagen (que baja a `redis`).
set -e
mkdir -p /tls
host="${REDIS_ANNOUNCE_HOST:-localhost}"
san="DNS:localhost,IP:127.0.0.1"
case "$host" in
  ""|*[!0-9.]*) ;;
  *) san="${san},IP:${host}" ;;
esac
want="$(printf '%s' "$san" | sed 's/IP:/IPAddress:/g')"
have=""
if [ -s /tls/server.crt ] && [ -s /tls/server.key ]; then
  have="$(openssl x509 -in /tls/server.crt -noout -ext subjectAltName 2>/dev/null | tail -n +2 | tr -d ' ')"
fi
if [ "$have" != "$want" ]; then
  echo "[caliope] redis-tls: certificado nuevo para ${san}"
  openssl req -x509 -newkey rsa:2048 -nodes -days 3650 \
    -keyout /tls/server.key -out /tls/server.crt \
    -subj "/CN=caliope-redis-tls" \
    -addext "subjectAltName=${san}" \
    -addext "basicConstraints=critical,CA:TRUE" 2>/dev/null
  cp /tls/server.crt /tls/ca.crt
else
  echo "[caliope] redis-tls: el certificado ya lleva ${san}"
fi
# La clave legible por `redis`, el usuario al que baja el entrypoint.
chmod 644 /tls/server.key /tls/server.crt /tls/ca.crt
exec docker-entrypoint.sh "$@"
REDIS_TLS_EP_EOF

  # ── Dockerfile.mongo-tls ──────────────────────────────────────────────────
  # `mongo:8.2` no trae `openssl`, y el nodo TLS genera su certificado al
  # arrancar (plan 60, tarea 1), como `redis-tls`.
  cat > "${WORKDIR}/Dockerfile.mongo-tls" <<'MONGO_TLS_DF_EOF'
FROM mongo:8.2
RUN apt-get update && \
    apt-get install -y --no-install-recommends openssl && \
    rm -rf /var/lib/apt/lists/*
COPY scripts/mongo-tls-entrypoint.sh /usr/local/bin/mongo-tls-entrypoint.sh
RUN chmod 755 /usr/local/bin/mongo-tls-entrypoint.sh
ENTRYPOINT ["mongo-tls-entrypoint.sh"]
MONGO_TLS_DF_EOF

  cat > "${WORKDIR}/scripts/mongo-tls-entrypoint.sh" <<'MONGO_TLS_EP_EOF'
#!/bin/sh
# Genera el certificado del nodo `mongo-tls` si falta o si su SAN no es el de
# hoy, deja clave + certificado en `/tls/server.pem` (lo que pide `mongod` en
# `--tlsCertificateKeyFile`) y le pasa el control al entrypoint de la imagen.
set -e
mkdir -p /tls
host="${MONGO_RS_HOST:-}"
san="DNS:localhost,IP:127.0.0.1"
case "$host" in
  ""|*[!0-9.]*) ;;
  *) san="${san},IP:${host}" ;;
esac
want="$(printf '%s' "$san" | sed 's/IP:/IPAddress:/g')"
have=""
if [ -s /tls/server.crt ] && [ -s /tls/server.key ]; then
  have="$(openssl x509 -in /tls/server.crt -noout -ext subjectAltName 2>/dev/null | tail -n +2 | tr -d ' ')"
fi
if [ "$have" != "$want" ]; then
  echo "[caliope] mongo-tls: certificado nuevo para ${san}"
  openssl req -x509 -newkey rsa:2048 -nodes -days 3650 \
    -keyout /tls/server.key -out /tls/server.crt \
    -subj "/CN=caliope-mongo-tls" \
    -addext "subjectAltName=${san}" \
    -addext "basicConstraints=critical,CA:TRUE" 2>/dev/null
  cp /tls/server.crt /tls/ca.crt
else
  echo "[caliope] mongo-tls: el certificado ya lleva ${san}"
fi
cat /tls/server.key /tls/server.crt > /tls/server.pem
# Legibles por `mongodb`, el usuario al que baja el entrypoint de la imagen.
chmod 644 /tls/server.key /tls/server.crt /tls/ca.crt /tls/server.pem
exec docker-entrypoint.sh "$@"
MONGO_TLS_EP_EOF

  # ── .dockerignore ─────────────────────────────────────────────────────────
  # El contexto de construcción es todo el directorio de trabajo, y con el
  # destino remoto viaja entero por SSH en cada construcción. Ahí también viven
  # los registros de las corridas, el CLI de DuckDB (`bin/`) y la base de la
  # demo, que no usa ninguna imagen: se sube sólo `scripts/`.
  cat > "${WORKDIR}/.dockerignore" <<'DOCKERIGNORE_EOF'
*
!scripts
DOCKERIGNORE_EOF

  # ── scripts/setup-replication.sh ──────────────────────────────────────────
  cat > "${SCRIPTS_DIR}/setup-replication.sh" <<'SETUP_SH_EOF'
#!/usr/bin/env bash
# Initializes the primary and configures GTID replication on every replica,
# including "db" (replica5, reachable only through the SSH bastion).
# Runs automatically from the "setup" service in docker-compose.yml.
# Schema initialization only runs on first execution and is idempotent.
set -euo pipefail

export MYSQL_PWD="rootpass"
CLIENT="mariadb --user=root --silent --connect-timeout=5"
# Las que el lanzador levantó (`SETUP_REPLICAS`), y en ese orden.
read -r -a REPLICAS <<< "${SETUP_REPLICAS:-replica1 replica2 replica3 replica4 db}"

# De quién lee cada una. Todas del primario salvo `replica4`, que lee de
# `replica3`: la cascada (2026-10-13). Si `replica3` no se levantó, `replica4`
# lee del primario, que es lo único que puede hacer.
source_of() {
  if [[ "$1" == "replica4" && " ${REPLICAS[*]} " == *" replica3 "* ]]; then
    echo replica3
  else
    echo primary
  fi
}

line() { printf '%s\n' "────────────────────────────────────────────────"; }

wait_for() {
  local host="$1"
  local i
  for i in $(seq 1 60); do
    if $CLIENT -h "$host" -e "SELECT 1" &>/dev/null; then
      return 0
    fi
    sleep 2
  done
  echo "ERROR: ${host} did not respond after 120 s" >&2
  exit 1
}

line
echo " MariaDB automatic replication setup"
echo " $(date '+%Y-%m-%d %H:%M:%S')"
line

echo ""
echo "Checking node availability..."
wait_for primary
for replica in "${REPLICAS[@]}"; do
  wait_for "$replica"
done
echo "All nodes are responding."

# ── First-run primary initialization ──────────────────────────────────────
# We detect prior initialization by looking up the 'replicator' user.
ALREADY_INIT=$($CLIENT -h primary \
  -e "SELECT COUNT(*) FROM mysql.user WHERE User='replicator'" | tail -1)

if [[ "$ALREADY_INIT" == "0" ]]; then
  echo ""
  echo "First run — creating replicator user..."

  $CLIENT -h primary <<-'SQL'
    CREATE USER IF NOT EXISTS 'replicator'@'%' IDENTIFIED BY 'replicapass';
    GRANT REPLICATION SLAVE ON *.* TO 'replicator'@'%';
    FLUSH PRIVILEGES;
SQL
else
  echo "Replicator user already exists — skipping."
fi

# ── Apply the shared seed (always, idempotent) ────────────────────────────
# Files are applied in alphabetical order (10-, 20-, 21-, 22-, 30-, 40-,
# 50-, 60-, 70-, 71-, 72-). Each .sql is idempotent (IF NOT EXISTS /
# OR REPLACE / INSERT IGNORE), so re-running them is safe and provides:
#   · Auto-recovery if a previous file half-applied
#   · Seed updates without recreating volumes (`docker compose restart setup`)
echo ""
echo "Applying shared seed on the primary..."
for f in /seed/*.sql; do
  printf "  applying %s...\n" "$(basename "$f")"
  if ! $CLIENT -h primary < "$f"; then
    echo "  ERROR while applying $(basename "$f")" >&2
    exit 1
  fi
done
echo "Seed applied."

# ── Ensure full privileges for caliope (always, idempotent) ───────────────
echo ""
echo "Granting full privileges to the caliope user..."
$CLIENT -h primary <<-'SQL'
  GRANT ALL PRIVILEGES ON *.* TO 'caliope'@'%' WITH GRANT OPTION;
  FLUSH PRIVILEGES;
SQL
echo "Privileges applied."

# ── Leftovers from the load generator ─────────────────────────────────────
# El worker de DDL crea y borra tablas `tmp_<id>_<pid>_<rnd>` sin parar. Si el
# contenedor muere entre el CREATE y el DROP —y muere en cada `--stop`— queda un
# .frm sin tablespace de InnoDB: la tabla aparece en information_schema pero
# cualquier lectura da 1932 «doesn't exist in engine». No es un adorno
# eliminarlas: `mariadb-dump` aborta con código 2 al llegar a una de ellas, así
# que una sola huérfana impide reconstruir cualquier réplica — y también impide
# respaldar la base desde la propia Calíope. `DROP TABLE IF EXISTS` se las lleva
# (deja un warning, no un error) y el borrado se replica a las réplicas sanas.
drop_leftovers() {
  local stmts
  stmts=$($CLIENT -h primary -e "SELECT CONCAT('DROP TABLE IF EXISTS \`', table_schema, '\`.\`', table_name, '\`;')
    FROM information_schema.tables WHERE table_name LIKE 'tmp\\_%'")
  if [[ -z "$stmts" ]]; then
    echo 0
    return 0
  fi
  printf '%s\n' "$stmts" | $CLIENT -h primary >/dev/null 2>&1 || true
  printf '%s\n' "$stmts" | grep -c .
}

echo ""
echo "Removing leftover load-generator tables..."
printf "  %s table(s) removed.\n" "$(drop_leftovers)"

# ── Primary version ───────────────────────────────────────────────────────
echo ""
VERSION=$($CLIENT -h primary -e "SELECT VERSION()" | tail -1)
echo "Primary version: ${VERSION}"

# ── Configure each replica ────────────────────────────────────────────────
#
# Tres reglas, y las tres salieron de cuelgues reales del testbed:
#
#   1. A una réplica sana NO se le toca la posición. La versión anterior hacía
#      `RESET SLAVE ALL; RESET MASTER;` incondicionalmente antes de cada
#      `CHANGE MASTER TO`, y eso vacía `gtid_slave_pos`: la réplica volvía a
#      reproducir el binlog del primary desde el principio sobre datos que ya
#      tenía, así que en la primera divergencia moría con 1032
#      (`Can't find record`) o 1062. Reusar volúmenes bastaba para romperla.
#   2. El reset sólo procede cuando la posición de la réplica es imposible para
#      este primary — un par dominio-servidor que el primary no tiene, o un
#      seq_no mayor que el suyo. Ése es el caso que gtid_strict_mode rechaza con
#      «out-of-order sequence number», y es el único que el reset arregla.
#   3. Y cuando hay que resetear, hay que reponer los datos: una réplica sin
#      posición y con datos viejos no se arregla apuntándola otra vez al
#      primary. Se recarga de un volcado consistente que ya trae su
#      `gtid_slave_pos`, y a partir de ahí replica normalmente.

DUMP_FILE=/tmp/primary-snapshot.sql
DUMP_ERR=/tmp/primary-snapshot.err
DUMP_READY=0
DUMP_FAILED=0

IO=""; SQL_T=""; ERR=""

# 0 si la posición de la réplica es imposible para este primary.
gtid_conflicts() {
  local rep_pos="$1" pri_pos="$2"
  local tuple key seq pri_seq
  [[ -z "$rep_pos" ]] && return 1
  while IFS= read -r tuple; do
    [[ -z "$tuple" ]] && continue
    key="${tuple%-*}"          # "0-1-2178404" → "0-1"
    seq="${tuple##*-}"
    pri_seq=$(printf '%s\n' "$pri_pos" | tr ',' '\n' \
              | awk -F- -v k="$key" '$1"-"$2 == k { print $3 }' | tail -1)
    [[ -z "$pri_seq" ]] && return 0
    (( seq > pri_seq )) && return 0
  done < <(printf '%s\n' "$rep_pos" | tr ',' '\n')
  return 1
}

replica_status() {
  local raw
  raw=$($CLIENT -h "$1" -e "SHOW SLAVE STATUS\G" 2>/dev/null || true)
  IO=$(printf '%s\n'    "$raw" | grep -E '^\s+Slave_IO_Running:'  | awk '{print $2}' || true)
  SQL_T=$(printf '%s\n' "$raw" | grep -E '^\s+Slave_SQL_Running:' | awk '{print $2}' || true)
  ERR=$(printf '%s\n'   "$raw" | grep -E '^\s+Last_Error:' | sed 's/.*Last_Error: //' || true)
}

configure_replica() {
  local src
  src="$(source_of "$1")"
  $CLIENT -h "$1" <<-SQL
    STOP SLAVE;
    CHANGE MASTER TO
      MASTER_HOST     = '${src}',
      MASTER_USER     = 'replicator',
      MASTER_PASSWORD = 'replicapass',
      MASTER_USE_GTID = slave_pos;
    START SLAVE;
SQL
}

# Observa unos segundos en vez de mirar una sola vez: el hilo SQL arranca en
# "Yes" y muere después, al llegar al primer evento que no puede aplicar. Un
# `sleep 3` seguido de una lectura daba por buena una réplica ya condenada.
wait_replica_ok() {
  local replica="$1" seconds="$2" i
  for (( i = 1; i <= seconds; i++ )); do
    sleep 1
    replica_status "$replica"
    [[ -n "$ERR" || "$SQL_T" == "No" ]] && return 1
  done
  [[ "$IO" == "Yes" && "$SQL_T" == "Yes" ]]
}

# Un solo volcado para todas las réplicas que lo necesiten: así comparten
# posición y el primary se lee una vez.
make_dump() {
  [[ "$DUMP_READY" == "1" ]] && return 0
  # Un volcado que ya falló vuelve a fallar igual: reintentarlo por cada réplica
  # sólo multiplica por cinco la espera y el ruido.
  [[ "$DUMP_FAILED" == "1" ]] && return 1
  local dbs
  dbs=$($CLIENT -h primary -e "SELECT schema_name FROM information_schema.schemata
        WHERE schema_name NOT IN ('mysql','information_schema','performance_schema','sys')" \
        | tr '\n' ' ')
  if [[ -z "${dbs// /}" ]]; then
    echo "             ERROR: the primary has no user databases to copy" >&2
    return 1
  fi
  echo "             taking a consistent snapshot of the primary (${dbs})..."
  # Dos intentos: el generador de carga puede estar en marcha (lo está siempre
  # que se repara un testbed ya levantado) y crea y borra tablas `tmp_` sin
  # parar, así que el volcado puede encontrarse una que nació después de la
  # limpieza y murió antes de llegar a ella. Es una carrera, no un estado: al
  # segundo intento, con la limpieza repetida, ya no está.
  local attempt
  for attempt in 1 2; do
    # --gtid --master-data=1 escribe en el propio volcado el
    # `SET GLOBAL gtid_slave_pos=...` de su posición: es lo que evita tener que
    # adivinarla, y lo que lo hace consistente aunque el planificador de eventos
    # escriba mientras tanto.
    # Sin --add-drop-database, y no por gusto: el `DROP DATABASE` del volcado
    # falla en la réplica con «can't rmdir './demo', errno 39» cuando su datadir
    # guarda .ibd huérfanos del generador de carga. Esos archivos no están
    # registrados ni en InnoDB, así que no hay sentencia que se los lleve y el
    # directorio nunca queda vacío. Quien vacía la réplica es purge_replica, con
    # DROP TABLE / DROP VIEW, que no toca el directorio.
    if mariadb-dump --user=root -h primary \
         --single-transaction --gtid --master-data=1 \
         --routines --events --triggers \
         --databases $dbs > "$DUMP_FILE" 2>"$DUMP_ERR"; then
      DUMP_READY=1
      return 0
    fi
    if [[ "$attempt" == "1" ]]; then
      echo "             snapshot interrupted, retrying once..."
      drop_leftovers >/dev/null
    fi
  done
  # El error se enseña. Enmudecerlo dejaba «could not dump the primary» como
  # única pista de un fallo que decía exactamente qué tabla lo rompía.
  echo "             ERROR: could not dump the primary" >&2
  sed 's/^/             /' "$DUMP_ERR" >&2
  rm -f "$DUMP_FILE"
  DUMP_FAILED=1
  return 1
}

# Vacía las bases de usuario de la réplica objeto a objeto. Es lo que sustituye
# al `DROP DATABASE` (ver make_dump), y de paso se lleva las tablas que la
# réplica tenga y el primary ya no: lo que sobrevive a la recarga sería
# divergencia disfrazada de dato.
purge_replica() {
  local replica="$1" stmts
  stmts=$($CLIENT -h "$replica" -e "
    SELECT CONCAT(IF(table_type='VIEW','DROP VIEW IF EXISTS ','DROP TABLE IF EXISTS '),
                  '\`', table_schema, '\`.\`', table_name, '\`;')
    FROM information_schema.tables
    WHERE table_schema NOT IN ('mysql','information_schema','performance_schema','sys')")
  [[ -z "$stmts" ]] && return 0
  { echo "SET SESSION sql_log_bin = 0;"
    echo "SET FOREIGN_KEY_CHECKS = 0;"
    printf '%s\n' "$stmts"; } | $CLIENT -h "$replica"
}

reprovision() {
  local replica="$1"
  make_dump || return 1
  echo "             loading the snapshot into ${replica}..."
  $CLIENT -h "$replica" -e "STOP SLAVE; RESET SLAVE ALL; RESET MASTER;" 2>/dev/null || true
  if ! purge_replica "$replica"; then
    echo "             ERROR: could not clear the previous contents of ${replica}" >&2
    return 1
  fi
  # sql_log_bin=0: la carga no puede escribir en el binlog local de la réplica.
  # Con log-slave-updates activo generaría GTIDs propios (0-<server_id>-N) que
  # este primary no tiene, y gtid_strict_mode los rechaza en el siguiente
  # arranque — que es justo el «out-of-order sequence number» que el reset
  # incondicional venía a tapar.
  if ! { echo "SET SESSION sql_log_bin = 0;"; cat "$DUMP_FILE"; } | $CLIENT -h "$replica"; then
    echo "             ERROR: the snapshot could not be loaded" >&2
    return 1
  fi
  # El GRANT del primary quedó por detrás de la posición del volcado, así que ya
  # no llegará por replicación: la cuenta de la app se repone aquí, y también
  # sin binlog local.
  $CLIENT -h "$replica" <<-SQL
    SET SESSION sql_log_bin = 0;
    CREATE USER IF NOT EXISTS 'caliope'@'%' IDENTIFIED BY 'caliopepass';
    GRANT ALL PRIVILEGES ON *.* TO 'caliope'@'%' WITH GRANT OPTION;
    FLUSH PRIVILEGES;
SQL
}

echo ""
echo "Configuring replicas..."
echo ""

PRIMARY_POS=$($CLIENT -h primary -e "SELECT @@gtid_binlog_pos" | tail -1)
echo "  Primary GTID position: ${PRIMARY_POS:-<empty>}"
echo ""

ERRORS=0

for replica in "${REPLICAS[@]}"; do
  REP_POS=$($CLIENT -h "$replica" -e "SELECT @@gtid_current_pos" | tail -1)

  # Una réplica que ya replica sana DESDE SU ORIGEN no se toca: ni `STOP SLAVE` ni
  # `CHANGE MASTER`. Pararla en pleno tráfico dejó a `replica3` con una transacción
  # aplicada y ausente de su binlog, y `replica4`, que lee de ese binlog, murió con
  # el 1236 de «falta el GTID 0-1-114657» (medido el 2026-10-13, la cascada).
  replica_status "$replica"
  CUR_SRC=$($CLIENT -h "$replica" -e "SHOW SLAVE STATUS\G" 2>/dev/null \
            | grep -E '^\s+Master_Host:' | awk '{print $2}' || true)
  if [[ "$IO" == "Yes" && "$SQL_T" == "Yes" && -z "$ERR" && "$CUR_SRC" == "$(source_of "$replica")" ]]; then
    echo "  [${replica}] OK, already replicating from ${CUR_SRC} — left untouched"
    continue
  fi

  if gtid_conflicts "$REP_POS" "$PRIMARY_POS"; then
    echo "  [${replica}] position ${REP_POS} is impossible for this primary — rebuilding"
    if ! reprovision "$replica"; then
      echo "  [${replica}] ERROR: could not be re-provisioned"
      ERRORS=$(( ERRORS + 1 ))
      continue
    fi
  else
    echo "  [${replica}] Applying CHANGE MASTER TO $(source_of "$replica") (keeping position ${REP_POS:-<empty>})..."
  fi

  configure_replica "$replica"

  if wait_replica_ok "$replica" 6; then
    echo "  [${replica}] OK  (IO=${IO}, SQL=${SQL_T})"
    continue
  fi

  # Segunda oportunidad: la posición era válida pero los datos han divergido
  # (típico tras el generador de carga). Reponerlos es lo único que lo arregla.
  echo "  [${replica}] replication stopped (IO=${IO}, SQL=${SQL_T})"
  [[ -n "$ERR" ]] && echo "             ${ERR}"
  echo "  [${replica}] data has diverged from the primary — rebuilding"

  if reprovision "$replica" && configure_replica "$replica" && wait_replica_ok "$replica" 8; then
    echo "  [${replica}] OK after rebuilding  (IO=${IO}, SQL=${SQL_T})"
    continue
  fi

  echo "  [${replica}] ERROR  (IO=${IO}, SQL=${SQL_T})"
  [[ -n "$ERR" ]] && echo "             Last error: ${ERR}"
  ERRORS=$(( ERRORS + 1 ))
done

rm -f "$DUMP_FILE" "$DUMP_ERR"

# ── Final summary ─────────────────────────────────────────────────────────
echo ""
line

if [[ "$ERRORS" -gt 0 ]]; then
  echo " WARNING: ${ERRORS} replica(s) could not be synchronized."
  echo " Run: docker compose logs <container-name>"
  echo ""
  echo " The rest of the testbed stays up: standalone, mysql-latest, mysql8-gis,"
  echo " the PostgreSQL nodes and the MongoDB nodes do not depend on replication."
  line
  # Sale con 0 A PROPÓSITO. Este contenedor es dependencia
  # `service_completed_successfully` de standalone, mysql-latest, mysql8-gis y
  # loadgen: saliendo con error, esos cuatro no arrancan nunca y
  # `docker compose up` se queda esperando sin decir por qué. Una réplica que no
  # sincroniza es un aviso, no un motivo para dejar el testbed inservible.
  exit 0
fi

# Una sola línea, y ésta la lee el lanzador del registro para saber si la
# replicación quedó en pie: el informe de perfiles, puertos y direcciones lo
# imprime él, una vez y al final.
echo " Replication configured successfully. Replicas: ${#REPLICAS[@]} (${REPLICAS[*]})"
line
SETUP_SH_EOF

  # ── scripts/load-generator.sh ─────────────────────────────────────────────
  cat > "${SCRIPTS_DIR}/load-generator.sh" <<'LOADGEN_SH_EOF'
#!/usr/bin/env bash
# MariaDB load generator — persistent connections via FIFO.
#
# Architecture:
#   Every worker keeps ONE connection alive for its full lifetime (a
#   single mariadb client process). Queries are pushed through a named
#   pipe (FIFO) without reconnecting between them, mimicking a real
#   connection pool. This eliminates the per-query TCP handshake overhead
#   that previously capped throughput at 1-3 QPS inside Docker on macOS.
#
# ── Control variables (tune to change the load) ─────────────────────────────
#   WORKERS_PER_REPLICA  persistent SELECT connections per replica
#   WORKERS_WRITE        persistent write connections on the primary
#   WORKERS_DDL          DDL workers on the primary (reconnect per cycle)
#   SELECT_SLEEP         seconds between SELECT queries  →  QPS ≈ WORKERS/SLEEP
#   WRITE_SLEEP          seconds between write queries
#   DDL_SLEEP            seconds between full DDL cycles
#
# Current values target ~10 QPS per node.
# To double the load either halve the SLEEPs or double the WORKERS.
# ────────────────────────────────────────────────────────────────────────────
set -uo pipefail
trap '' PIPE   # ignore SIGPIPE if a FIFO closes unexpectedly

export MYSQL_PWD="rootpass"
export PRIMARY="primary"
REPLICAS=(replica1 replica2)

WORKERS_PER_REPLICA=12   # × 2 replicas = 24 persistent SELECT connections
WORKERS_WRITE=10          # persistent write connections on the primary
WORKERS_DDL=2             # DDL workers on the primary (reconnect per cycle)

SELECT_SLEEP="1.1"        # → 12 / 1.1 ≈ 11 QPS per replica
WRITE_SLEEP="1.0"         # → 10 / 1.0 ≈ 10 QPS write on primary
DDL_SLEEP="2.0"           # → 2  / 2.0 ≈  1 QPS DDL on primary
SLOW_SLEEP="4.0"          # → 1 consulta lenta cada 4 s y por nodo

# ── Nodos donde se produce carga lenta a propósito ────────────────────────
# `host|base|tabla`. Ocho de los nueve nodos MySQL/MariaDB fuera del grupo de
# MySQL 8.4, que lleva su propio generador (`mysql-loadgen`): `db` (replica5)
# queda fuera porque sólo se alcanza por el bastión y no recibe otra cosa que
# replicación. Uno por nodo y no más: el contador de cada worker es
# /tmp/lg_ops_slow_<host>, así que dos entradas del mismo host comparten archivo
# y una de las dos se pierde al truncarlo. En `mysql8-gis` la carga lenta va
# contra la base espacial —el registro de lentas es del servidor entero, no de
# una base— y por eso la tabla es un parámetro y no una constante.
SLOW_NODES=(
  "primary|demo|productos"
  "replica1|demo|productos"
  "replica2|demo|productos"
  "replica3|demo|productos"
  "replica4|demo|productos"
  "standalone|demo|productos"
  "mysql-latest|demo|productos"
  "mysql8-gis|gis|ciudades"
)

export SELECT_SLEEP WRITE_SLEEP DDL_SLEEP SLOW_SLEEP PRIMARY

line() { printf '%s\n' "────────────────────────────────────────────────"; }

# ── Per-node op counters ──────────────────────────────────────────────────
for _r in "${REPLICAS[@]}"; do : > "/tmp/lg_ops_${_r}"; done
: > "/tmp/lg_ops_primary_w"
: > "/tmp/lg_ops_primary_d"
for _s in "${SLOW_NODES[@]}"; do : > "/tmp/lg_ops_slow_${_s%%|*}"; done

# ── Wait until a host responds ────────────────────────────────────────────
# `--ssl-verify-server-cert=0` no es laxitud: es lo único que deja al cliente
# de esta imagen (MariaDB) hablar con los dos nodos MySQL. MySQL 26.7 autentica
# a root por `caching_sha2_password`, que exige canal cifrado, y el certificado
# que el servidor autofirma al inicializarse no lo firma nadie: sin la bandera
# el cliente corta con «TLS/SSL error: self-signed certificate in certificate
# chain» (2026-09-04, medido contra los dos nodos; `mysql8-gis` da
# «Certificate verification failure» por el mismo motivo). Contra los nodos
# MariaDB, que no ofrecen TLS, la bandera no cambia nada.
CLIENT_TLS="--ssl-verify-server-cert=0"
export CLIENT_TLS   # lo usan los workers, que corren en subshells por `bash -c`

wait_for() {
  local host="$1"
  for i in $(seq 1 60); do
    if mariadb --user=root --silent --connect-timeout=5 $CLIENT_TLS \
         -h "$host" -e "SELECT 1" &>/dev/null; then
      return 0
    fi
    sleep 2
  done
  echo "ERROR: ${host} did not respond after 120 s" >&2
  exit 1
}

# Igual, pero un nodo que no contesta no mata al generador: devuelve 1 y quien
# llama se salta ese nodo. Los cinco de la topología de replicación son
# obligatorios —sin ellos no hay carga que generar—; los cuatro que sólo
# reciben carga lenta, no: que `gis` esté caído no es motivo para dejar sin
# carga a las réplicas.
wait_for_soft() {
  local host="$1" secs="${2:-60}"
  for i in $(seq 1 "$secs"); do
    if mariadb --user=root --silent --connect-timeout=5 $CLIENT_TLS \
         -h "$host" -e "SELECT 1" &>/dev/null; then
      return 0
    fi
    sleep 2
  done
  return 1
}

# ── Open a persistent connection via a FIFO ───────────────────────────────
# Creates a named pipe, launches mariadb reading from it, and opens the
# write end on an auto-allocated fd (bash 4.1+).
# Exports: _CONN_FD (write fd) and _CONN_PID (mariadb client PID).
open_persistent() {
  local host="$1" db="${2:-demo}"
  local fifo
  fifo=$(mktemp -u /tmp/lgfifo_XXXXXX)
  mkfifo "$fifo"
  mariadb --user=root --silent --connect-timeout=30 $CLIENT_TLS \
    -h "$host" "$db" < "$fifo" >/dev/null 2>&1 &
  _CONN_PID=$!
  exec {_CONN_FD}>"$fifo"
  rm -f "$fifo"
}
export -f open_persistent

# ── SELECT worker — persistent connection to a replica ────────────────────
select_worker() {
  trap '' PIPE
  local host="$1"
  local ops_file="/tmp/lg_ops_${host}"

  open_persistent "$host" "demo"
  local fd=$_CONN_FD pid=$_CONN_PID

  while true; do
    # Reconnect if the mariadb client process died
    if ! kill -0 "$pid" 2>/dev/null; then
      eval "exec ${fd}>&-" 2>/dev/null || true
      open_persistent "$host" "demo"
      fd=$_CONN_FD; pid=$_CONN_PID
    fi

    # Random selection by PK range (ORDER BY id LIMIT n) instead of
    # ORDER BY RAND(), which forces a full-table sort O(n log n).
    local sql
    case $(( RANDOM % 8 )) in
      0) sql="SELECT * FROM productos WHERE id >= FLOOR(1+RAND()*(SELECT MAX(id) FROM productos)) ORDER BY id LIMIT 10;" ;;
      1) sql="SELECT p.nombre, COUNT(pe.id) AS pedidos FROM productos p LEFT JOIN pedidos pe ON pe.producto_id = p.id GROUP BY p.id ORDER BY pedidos DESC LIMIT 5;" ;;
      2) sql="SELECT c.nombre, COALESCE(SUM(pe.total),0) AS total_gastado FROM clientes c LEFT JOIN pedidos pe ON pe.cliente_id = c.id GROUP BY c.id ORDER BY total_gastado DESC LIMIT 20;" ;;
      3) sql="SELECT * FROM pedidos WHERE estado='pendiente' ORDER BY creado_en DESC LIMIT 20;" ;;
      4) sql="SELECT COUNT(*) AS sobre_media FROM productos WHERE precio > (SELECT AVG(precio) FROM productos);" ;;
      5) sql="SELECT pe.id, pe.total, pe.estado, c.nombre AS cliente, p.nombre AS producto FROM pedidos pe JOIN clientes c ON c.id=pe.cliente_id JOIN productos p ON p.id=pe.producto_id WHERE pe.id >= FLOOR(1+RAND()*(SELECT MAX(id) FROM pedidos)) ORDER BY pe.id LIMIT 15;" ;;
      6) sql="SELECT categoria, COUNT(*) AS total, ROUND(AVG(precio),2) AS precio_medio, SUM(stock) AS stock_total FROM productos GROUP BY categoria;" ;;
      7) sql="SELECT * FROM clientes WHERE ciudad IS NOT NULL AND id >= FLOOR(1+RAND()*(SELECT MAX(id) FROM clientes)) ORDER BY id LIMIT 5;" ;;
    esac

    printf '%s\n' "$sql" >&"$fd" 2>/dev/null && printf '.' >> "$ops_file"
    sleep "$SELECT_SLEEP"
  done
}
export -f select_worker

# ── WRITE worker — persistent connection to the primary ──────────────────
write_worker() {
  trap '' PIPE
  local ops_file="/tmp/lg_ops_primary_w"

  open_persistent "$PRIMARY" "demo"
  local fd=$_CONN_FD pid=$_CONN_PID

  while true; do
    if ! kill -0 "$pid" 2>/dev/null; then
      eval "exec ${fd}>&-" 2>/dev/null || true
      open_persistent "$PRIMARY" "demo"
      fd=$_CONN_FD; pid=$_CONN_PID
    fi

    # Distribution: 60% INSERT · 20% UPDATE · 20% DELETE
    # INSERTs dominate so the DB grows visibly over time.
    local sql
    case $(( RANDOM % 10 )) in
      0) sql="INSERT INTO productos (nombre,categoria,precio,stock) VALUES (CONCAT('Prod-',LPAD(FLOOR(RAND()*99999),5,'0')),ELT(FLOOR(1+RAND()*5),'electronica','cables','periféricos','accesorios','pantallas'),ROUND(1+RAND()*299,2),FLOOR(RAND()*200));" ;;
      1) sql="INSERT INTO productos (nombre,categoria,precio,stock) VALUES (CONCAT('Item-',UUID_SHORT()),ELT(FLOOR(1+RAND()*5),'electronica','cables','periféricos','accesorios','pantallas'),ROUND(5+RAND()*995,2),FLOOR(RAND()*500));" ;;
      2) sql="INSERT INTO clientes (nombre,email,ciudad) VALUES (CONCAT('Cliente-',FLOOR(RAND()*99999)),CONCAT('u',FLOOR(RAND()*9999999),'@loadtest.internal'),ELT(FLOOR(1+RAND()*6),'Madrid','San José','Lima','Bogotá','Santiago','Ciudad de México')) ON DUPLICATE KEY UPDATE ciudad=VALUES(ciudad);" ;;
      3) sql="INSERT INTO clientes (nombre,email,ciudad) VALUES (CONCAT('User-',UUID_SHORT()),CONCAT('x',FLOOR(RAND()*99999999),'@test.local'),ELT(FLOOR(1+RAND()*6),'Madrid','San José','Lima','Bogotá','Santiago','Ciudad de México')) ON DUPLICATE KEY UPDATE ciudad=VALUES(ciudad);" ;;
      4) sql="INSERT INTO pedidos (cliente_id,producto_id,cantidad,total,estado) VALUES ((SELECT id FROM clientes WHERE id>=FLOOR(1+RAND()*(SELECT MAX(id) FROM clientes)) ORDER BY id LIMIT 1),(SELECT id FROM productos WHERE id>=FLOOR(1+RAND()*(SELECT MAX(id) FROM productos)) ORDER BY id LIMIT 1),FLOOR(1+RAND()*10),ROUND(10+RAND()*990,2),ELT(FLOOR(1+RAND()*4),'pendiente','enviado','entregado','cancelado'));" ;;
      5) sql="INSERT INTO pedidos (cliente_id,producto_id,cantidad,total,estado) VALUES ((SELECT id FROM clientes WHERE id>=FLOOR(1+RAND()*(SELECT MAX(id) FROM clientes)) ORDER BY id LIMIT 1),(SELECT id FROM productos WHERE id>=FLOOR(1+RAND()*(SELECT MAX(id) FROM productos)) ORDER BY id LIMIT 1),FLOOR(1+RAND()*10),ROUND(10+RAND()*990,2),'pendiente');" ;;
      6) sql="UPDATE productos SET stock=GREATEST(0,stock+FLOOR(-10+RAND()*20)),activo=IF(RAND()>0.05,1,0) WHERE id=(SELECT id FROM (SELECT id FROM productos WHERE id>=FLOOR(1+RAND()*(SELECT MAX(id) FROM productos)) ORDER BY id LIMIT 1) AS t);" ;;
      7) sql="UPDATE pedidos SET estado=ELT(FLOOR(1+RAND()*4),'pendiente','enviado','entregado','cancelado') WHERE id=(SELECT id FROM (SELECT id FROM pedidos WHERE id>=FLOOR(1+RAND()*(SELECT MAX(id) FROM pedidos)) ORDER BY id LIMIT 1) AS t);" ;;
      8) sql="DELETE FROM pedidos WHERE estado='cancelado' ORDER BY id LIMIT 5;" ;;
      9) sql="DELETE FROM pedidos WHERE estado='cancelado' ORDER BY id LIMIT 10;" ;;
    esac

    printf '%s\n' "$sql" >&"$fd" 2>/dev/null && printf '.' >> "$ops_file"
    sleep "$WRITE_SLEEP"
  done
}
export -f write_worker

# ── DDL worker — reconnects per cycle (CREATE / INSERT / SELECT / DROP) ──
ddl_worker() {
  trap '' PIPE
  local id="$1"
  local ops_file="/tmp/lg_ops_primary_d"
  while true; do
    local tbl="tmp_${id}_${BASHPID}_${RANDOM}"
    local rows=$(( RANDOM % 41 + 10 ))
    mariadb --user=root --silent --connect-timeout=5 \
      -h "$PRIMARY" demo >/dev/null 2>&1 <<SQL || true
CREATE TABLE IF NOT EXISTS \`${tbl}\` (
  id  INT AUTO_INCREMENT PRIMARY KEY,
  val VARCHAR(80)    NOT NULL,
  num DECIMAL(10,2),
  ts  TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  INDEX idx_num (num)
) ENGINE=InnoDB;
INSERT INTO \`${tbl}\` (val,num)
SELECT CONCAT('row-',a.n+b.n*10+1), ROUND(RAND()*1000,2)
FROM (SELECT 0 AS n UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4
      UNION SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9) a
CROSS JOIN (SELECT 0 AS n UNION SELECT 1 UNION SELECT 2 UNION SELECT 3) b
LIMIT ${rows};
SELECT COUNT(*), ROUND(AVG(num),2), MAX(num), MIN(num) FROM \`${tbl}\`;
UPDATE \`${tbl}\` SET num=ROUND(num*1.1,2) WHERE num>500;
DROP TABLE IF EXISTS \`${tbl}\`;
SQL
    printf '.' >> "$ops_file"
    sleep "$DDL_SLEEP"
  done
}
export -f ddl_worker

# ── SLOW worker — una consulta lenta a propósito, cada SLOW_SLEEP ─────────
# Existe para que la herramienta «Consultas lentas» y la tarjeta del Tablero
# tengan algo que enseñar: con `mysql.slow_log` vacío no se distingue un
# servidor tranquilo de una herramienta rota.
#
# Las tres consultas cruzan el umbral de 0,1 s **por construcción**, con un
# SLEEP dentro, y no por el volumen de la tabla. Eso no es pereza: los nodos
# del testbed no se parecen entre sí. El 2026-09-04 `demo.productos` tenía
# 937 938 filas en `primary` —el generador lleva meses insertando— y **8** en
# `standalone`, `mysql-latest` y `mysql8-gis`, que sólo reciben el seed. Una
# consulta cuya lentitud dependa del volumen es de segundos en el primero e
# instantánea en los otros tres, así que no serviría en ninguno: en unos
# ahoga el testbed y en otros no registra nada.
#
# Medido en los dos extremos el 2026-09-04, con `long_query_time=0.1`:
#
#   SELECT SLEEP(0.3)                        301 ms  ·  308 ms
#   SELECT COUNT(*), SLEEP(0.2) FROM t       265 ms  ·  201 ms
#   SELECT id FROM t WHERE SLEEP(0.02)=0     185 ms  ·  185 ms
#
# La segunda es la única cuyo coste crece con la tabla (265 ms con 937 938
# filas: es un recorrido de índice), y la tercera se corta en la octava fila
# por el `ORDER BY id LIMIT 8`, así que da lo mismo el tamaño.
#
# Son tres y no una porque el log tiene que enseñar formas distintas: la
# primera no examina filas, la segunda examina la tabla entera y la tercera
# ocho. Con una sola, la vista «Por sentencia» tendría un único digest.
slow_worker() {
  trap '' PIPE
  local host="$1" db="$2" tbl="$3"
  local ops_file="/tmp/lg_ops_slow_${host}"

  open_persistent "$host" "$db"
  local fd=$_CONN_FD pid=$_CONN_PID

  while true; do
    if ! kill -0 "$pid" 2>/dev/null; then
      eval "exec ${fd}>&-" 2>/dev/null || true
      open_persistent "$host" "$db"
      fd=$_CONN_FD; pid=$_CONN_PID
    fi

    local sql
    case $(( RANDOM % 3 )) in
      0) sql="SELECT SLEEP(0.3);" ;;
      1) sql="SELECT COUNT(*) AS filas, SLEEP(0.2) AS pausa FROM ${db}.${tbl};" ;;
      2) sql="SELECT id FROM ${db}.${tbl} WHERE SLEEP(0.02) = 0 ORDER BY id LIMIT 8;" ;;
    esac

    printf '%s\n' "$sql" >&"$fd" 2>/dev/null && printf '.' >> "$ops_file"
    sleep "$SLOW_SLEEP"
  done
}
export -f slow_worker

# ── Worker registry ───────────────────────────────────────────────────────
declare -a PIDS=()
declare -a CMDS=()

spawn() {
  bash -c "$1" &
  PIDS+=($!)
  CMDS+=("$1")
}

# ── Startup ──────────────────────────────────────────────────────────────
line
printf " MariaDB load generator — persistent connections\n"
printf " Target: ~10 QPS/node | %d replicas + 1 primary\n" "${#REPLICAS[@]}"
printf " %s\n" "$(date '+%Y-%m-%d %H:%M:%S')"
line
echo ""
echo "Checking node availability..."

# Un nodo que esta corrida no levantó no resuelve en el DNS de Docker, y se
# sabe al instante: preguntarle 60 veces era un minuto de espera por cada
# ausente (el asistente levanta a veces sólo una parte, 2026-10-13).
up() { getent hosts "$1" >/dev/null 2>&1; }

wait_for "$PRIMARY"
_present=()
for r in "${REPLICAS[@]}"; do
  if up "$r"; then wait_for "$r"; _present+=("$r"); else echo "  ${r}: not started — no SELECT load there"; fi
done
REPLICAS=("${_present[@]}")
echo "All started nodes are responding. Launching workers..."
echo ""

for r in "${REPLICAS[@]}"; do
  for i in $(seq 1 "$WORKERS_PER_REPLICA"); do
    spawn "select_worker $r"
  done
  printf "  %-14s — %2d persistent SELECT connections  (sleep=%.1fs → ~%d QPS est.)\n" \
    "$r" "$WORKERS_PER_REPLICA" "$SELECT_SLEEP" \
    "$(awk -v w="$WORKERS_PER_REPLICA" -v s="$SELECT_SLEEP" 'BEGIN{printf "%d", w/s}')"
done

for i in $(seq 1 "$WORKERS_WRITE"); do
  spawn "write_worker"
done
printf "  %-14s — %2d persistent write connections (sleep=%.1fs → ~%d QPS est.)\n" \
  "primary (W)" "$WORKERS_WRITE" "$WRITE_SLEEP" \
  "$(awk -v w="$WORKERS_WRITE" -v s="$WRITE_SLEEP" 'BEGIN{printf "%d", w/s}')"

for i in $(seq 1 "$WORKERS_DDL"); do
  spawn "ddl_worker $i"
done
printf "  %-14s — %2d DDL workers                       (sleep=%.1fs)\n" \
  "primary (DDL)" "$WORKERS_DDL" "$DDL_SLEEP"

# ── Workers lentos: uno por nodo SQL, salvo `db` ──────────────────────────
# `wait_for_soft` y no `wait_for`: un nodo que sólo recibe carga lenta no puede
# tumbar al generador entero. Los que se salten se nombran, porque un nodo
# ausente y un nodo sin carga se ven igual en la tabla de estadísticas.
SLOW_LAUNCHED=()
SLOW_SKIPPED=()
for entry in "${SLOW_NODES[@]}"; do
  slow_host="${entry%%|*}"
  slow_rest="${entry#*|}"
  slow_db="${slow_rest%%|*}"
  slow_tbl="${slow_rest##*|}"
  if up "$slow_host" && wait_for_soft "$slow_host" 15; then
    spawn "slow_worker $slow_host $slow_db $slow_tbl"
    SLOW_LAUNCHED+=("$slow_host")
  else
    SLOW_SKIPPED+=("$slow_host")
  fi
done
printf "  %-14s — %2d slow-query connections           (sleep=%.1fs → 1 lenta/%.0fs por nodo)\n" \
  "slow (log)" "${#SLOW_LAUNCHED[@]}" "$SLOW_SLEEP" "$SLOW_SLEEP"
printf "                     nodos: %s\n" "${SLOW_LAUNCHED[*]:-ninguno}"
if [ "${#SLOW_SKIPPED[@]}" -gt 0 ]; then
  printf "                     SIN carga lenta (no respondieron): %s\n" "${SLOW_SKIPPED[*]}"
fi

echo ""
line
printf " Workers launched   : %d\n" "${#PIDS[@]}"
printf " Persistent conns   : %d on replicas + %d on primary + %d slow\n" \
  "$(( WORKERS_PER_REPLICA * ${#REPLICAS[@]} ))" \
  "$(( WORKERS_WRITE + WORKERS_DDL ))" \
  "${#SLOW_LAUNCHED[@]}"
line
echo ""

# ── Supervisor + per-node stats every 30 s ───────────────────────────────
declare -A TOTAL_OPS
for r in "${REPLICAS[@]}"; do TOTAL_OPS[$r]=0; done
TOTAL_OPS[primary_w]=0
TOTAL_OPS[primary_d]=0
TOTAL_OPS[slow]=0
LAST_STAT=$(date +%s)

while true; do
  sleep 10

  # Restart dead workers
  RESTARTED=0
  for idx in "${!PIDS[@]}"; do
    if ! kill -0 "${PIDS[$idx]}" 2>/dev/null; then
      bash -c "${CMDS[$idx]}" &
      PIDS[$idx]=$!
      RESTARTED=$(( RESTARTED + 1 ))
    fi
  done
  [ "$RESTARTED" -gt 0 ] && printf "[supervisor] %s — %d worker(s) restarted\n" \
    "$(date '+%H:%M:%S')" "$RESTARTED"

  NOW=$(date +%s)
  if [ $(( NOW - LAST_STAT )) -ge 30 ]; then
    ELAPSED=$(( NOW - LAST_STAT ))

    echo ""
    line
    printf " Stats — %s  (interval: %ds)\n" "$(date '+%H:%M:%S')" "$ELAPSED"
    line
    printf "  %-16s  %8s  %12s\n" "Node" "QPS" "Ops total"

    for r in "${REPLICAS[@]}"; do
      delta=$(wc -c < "/tmp/lg_ops_${r}" 2>/dev/null || echo 0)
      TOTAL_OPS[$r]=$(( ${TOTAL_OPS[$r]} + delta ))
      rate=$(awk -v d="$delta" -v e="$ELAPSED" 'BEGIN{printf "%.1f", d/e}')
      printf "  %-16s  %8s  %12d\n" "$r" "$rate" "${TOTAL_OPS[$r]}"
      : > "/tmp/lg_ops_${r}"
    done

    dw=$(wc -c < "/tmp/lg_ops_primary_w" 2>/dev/null || echo 0)
    dd_ops=$(wc -c < "/tmp/lg_ops_primary_d" 2>/dev/null || echo 0)
    TOTAL_OPS[primary_w]=$(( ${TOTAL_OPS[primary_w]} + dw ))
    TOTAL_OPS[primary_d]=$(( ${TOTAL_OPS[primary_d]} + dd_ops ))
    rate_w=$(awk -v d="$dw"     -v e="$ELAPSED" 'BEGIN{printf "%.1f", d/e}')
    rate_d=$(awk -v d="$dd_ops" -v e="$ELAPSED" 'BEGIN{printf "%.1f", d/e}')
    rate_p=$(awk -v w="$dw" -v d="$dd_ops" -v e="$ELAPSED" 'BEGIN{printf "%.1f",(w+d)/e}')
    printf "  %-16s  %8s  %12d  (write:%s  DDL:%s)\n" \
      "primary" "$rate_p" \
      "$(( ${TOTAL_OPS[primary_w]} + ${TOTAL_OPS[primary_d]} ))" \
      "$rate_w" "$rate_d"
    : > "/tmp/lg_ops_primary_w"
    : > "/tmp/lg_ops_primary_d"

    # Carga lenta, agregada: en la tabla van tres filas y aquí serían seis
    # más. Quién produce cada una se lee en su nodo, que es donde se consulta
    # `mysql.slow_log`; lo que esta fila contesta es si el worker sigue vivo.
    ds=0
    for slow_host in "${SLOW_LAUNCHED[@]}"; do
      d=$(wc -c < "/tmp/lg_ops_slow_${slow_host}" 2>/dev/null || echo 0)
      ds=$(( ds + d ))
      : > "/tmp/lg_ops_slow_${slow_host}"
    done
    TOTAL_OPS[slow]=$(( ${TOTAL_OPS[slow]} + ds ))
    rate_s=$(awk -v d="$ds" -v e="$ELAPSED" 'BEGIN{printf "%.1f", d/e}')
    printf "  %-16s  %8s  %12d  (%d nodos)\n" \
      "slow (log)" "$rate_s" "${TOTAL_OPS[slow]}" "${#SLOW_LAUNCHED[@]}"

    line
    echo ""
    LAST_STAT=$NOW
  fi
done
LOADGEN_SH_EOF

  # ── Postgres: preparación del primario para replicación ───────────────────
  # Corre una sola vez, desde initdb.d del contenedor `postgres-primary`, y sólo
  # en el primer arranque (que es cuando el directorio de datos se inicializa).
  cat > "${PG_SETUP_DIR}/10-replication.sh" <<'PG_SETUP_EOF'
#!/bin/bash
# Prepara postgres-primary para servir un standby físico.
#
# Dos cosas, y ninguna sobra:
#
#   1. El rol `replicator`. Es un rol con el atributo REPLICATION, no un GRANT:
#      en Postgres la replicación no es un privilegio sobre objetos.
#   2. La línea de `pg_hba.conf`. Y ésta es la que se olvida: en pg_hba la palabra
#      `all` de la columna de base de datos NO cubre las conexiones de replicación
#      —son un pseudo-destino aparte—, así que la entrada que escribe la imagen
#      oficial (`host all all all scram-sha-256`) deja entrar al cliente y rechaza
#      al standby. El síntoma es el peor posible: el primario en verde, el cliente
#      conectando, y la réplica reintentando sin que nada lo diga.
set -e

psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname postgres <<-SQL
  CREATE ROLE replicator WITH REPLICATION LOGIN PASSWORD 'replicapass';
SQL

# `pg_stat_statements`, en las DOS bases. Este nodo monta `pg-setup` y no
# `pg-seed`, así que la línea del seed del nodo de trabajo no le llega, y la
# extensión es por base: `demo` porque es donde conecta Calíope, y `postgres`
# porque es donde cae un `psql` sin `-d` y ahí la ausencia se lee como que el
# nodo no lo trae. La réplica no necesita nada: `pg_basebackup` copia el
# directorio de datos byte a byte, catálogo incluido.
for _db in "$POSTGRES_DB" postgres; do
  psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$_db" \
       -c "CREATE EXTENSION IF NOT EXISTS pg_stat_statements;"
done

echo "host replication replicator all scram-sha-256" >> "$PGDATA/pg_hba.conf"
echo "[caliope] rol 'replicator' creado y pg_hba.conf abierto para replicación."
PG_SETUP_EOF
  chmod +x "${PG_SETUP_DIR}/10-replication.sh"

  # ── Postgres: el seed del nodo de trabajo ─────────────────────────────────
  # Paralelo al de MySQL, con las mismas bases, tablas y nombres, para que los
  # pasos de QA puedan recorrer los dos motores. Lo que NO tiene equivalente
  # está inventariado en 60-sin-equivalente.sql, que no crea nada y se lee.
  #
  # OJO: este directorio se monta sobre /docker-entrypoint-initdb.d y tapa los
  # scripts propios de la imagen PostGIS, así que `CREATE EXTENSION postgis` va
  # explícito en 10-schemas.sql.

  cat > "${PG_SEED_DIR}/10-schemas.sql" <<'PG_SEED_10_SCHEMAS_EOF'
-- 10-schemas.sql — Postgres
--
-- Equivalente de `seed/10-schemas.sql`, y la primera decisión que hay que
-- entender antes de leer el resto.
--
-- ## Por qué esto no es una traducción línea a línea
--
-- En MySQL «base de datos» y «esquema» son la misma cosa, y `USE` cambia de una
-- a otra dentro de la misma conexión. En Postgres son dos niveles distintos: una
-- conexión está atada a UNA base y no puede ver las tablas de otra; lo que se
-- recorre sin reconectar son los **esquemas**.
--
-- El seed de MySQL tiene referencias cruzadas —los eventos escriben en
-- `analytics` leyendo de `demo`— que dejarían de compilar si cada base de MySQL
-- se volviera una base de Postgres. Así que el reparto es:
--
--   base `demo`  → esquemas `demo`, `analytics`, `caliope_gis_test`, `gis`
--   base `rrhh`  → esquema  `rrhh`
--
-- `rrhh` se queda como base aparte **a propósito**, no por descuido: en el seed
-- de MySQL nadie la referencia desde fuera, así que es el único bloque que puede
-- vivir separado, y separado ejercita el camino que en Postgres no existe en
-- MySQL — cambiar de base es **reconectar**, no un `USE`. Sin un caso así en la
-- cama de pruebas, ese camino no se prueba nunca.
--
-- ## Sobre la extensión PostGIS
--
-- La imagen trae sus propios scripts en `/docker-entrypoint-initdb.d`, y este
-- directorio se monta ENCIMA: los tapa. Por eso `CREATE EXTENSION` va aquí
-- explícito en vez de darse por hecho.

CREATE EXTENSION IF NOT EXISTS postgis;

-- `pg_stat_statements` es lo que la herramienta «Consultas lentas» lee en
-- Postgres, y la extensión hay que crearla POR BASE: la biblioteca precargada
-- (`shared_preload_libraries`, en el `command:` del contenedor) hace que el
-- módulo exista en el servidor, no que la vista exista en `demo`. Sin esta línea
-- la consulta falla con «relation "pg_stat_statements" does not exist», que se
-- lee como un motor que no lo trae y no como una base a la que le falta el
-- `CREATE EXTENSION`.
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;

-- El umbral de consultas lentas, en milisegundos, y **aquí y no en el `command:`
-- del contenedor**: un `-c` de la línea de órdenes gana sobre `ALTER SYSTEM`, que
-- es con lo que Calíope lo escribe, y gana en silencio —la sentencia devuelve
-- éxito y el valor efectivo no se mueve—. Escrito así, `pg_settings.source` dice
-- `configuration file` y la app sí puede cambiarlo, que es el camino que el paso
-- 65 de QA ejercita en cada tanda.
--
-- `ALTER SYSTEM` no puede ir dentro de una transacción, y `psql -f` ejecuta en
-- autocommit, así que aquí vale. El valor rige en el arranque siguiente, que es
-- el que hace el propio entrypoint al terminar los scripts de inicialización.
ALTER SYSTEM SET log_min_duration_statement = 250;

CREATE SCHEMA IF NOT EXISTS demo;
CREATE SCHEMA IF NOT EXISTS analytics;
CREATE SCHEMA IF NOT EXISTS caliope_gis_test;
CREATE SCHEMA IF NOT EXISTS gis;

-- `CREATE DATABASE` no puede ir dentro de una transacción, y `psql -f` ejecuta
-- en autocommit, así que aquí sí vale. El `\gexec` lo hace idempotente: si la
-- base ya existe, la consulta no devuelve nada y no se ejecuta nada.
SELECT 'CREATE DATABASE rrhh'
 WHERE NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = 'rrhh')\gexec

\connect rrhh
CREATE SCHEMA IF NOT EXISTS rrhh;
\connect demo

-- El usuario `caliope` ya es el superusuario del clúster (POSTGRES_USER), así
-- que aquí no hay `CREATE USER` ni `GRANT ALL`: en Postgres el dueño de la base
-- lo puede todo sobre ella. Ésta es la línea del seed de MySQL que no tiene
-- equivalente porque no hace falta, no porque falte.
PG_SEED_10_SCHEMAS_EOF

  cat > "${PG_SEED_DIR}/20-tables-demo.sql" <<'PG_SEED_20_TABLES_DEMO_EOF'
-- 20-tables-demo.sql — Postgres
--
-- Mismas tablas y mismos nombres que `seed/20-tables-demo.sql`, para que los
-- pasos de `qa-plan.json` puedan recorrer los dos motores. Lo que cambia y por
-- qué:
--
--   AUTO_INCREMENT      → GENERATED BY DEFAULT AS IDENTITY. «BY DEFAULT» y no
--                         «ALWAYS» porque la semilla inserta ids explícitos; con
--                         ALWAYS haría falta OVERRIDING SYSTEM VALUE en cada
--                         INSERT. La secuencia se recoloca al final del seed.
--   TINYINT(1)          → boolean. MySQL no tiene booleano y Postgres sí, así
--                         que aquí la cama de pruebas gana un tipo que el otro
--                         motor no puede dar.
--   ENUM(...)           → un tipo enumerado de verdad (CREATE TYPE ... AS ENUM).
--   JSON                → jsonb.
--   POINT               → geometry(Point, 0). SRID 0 igual que el original: el
--                         caso de latitud primero no existe en PostGIS y está
--                         explicado en 24-tables-gis-node.sql.
--   VARBINARY(32)       → bytea.
--   TIMESTAMP           → timestamptz. Es el análogo: MySQL normaliza TIMESTAMP
--                         a UTC, y `timestamp` a secas de Postgres no lo hace.
--   ON UPDATE CURRENT_TIMESTAMP → no existe como cláusula; va como disparador
--                         en 50-triggers.sql.
--   INDEX / SPATIAL     → CREATE INDEX, con USING GIST para la geometría.

SET search_path = demo, public;

CREATE TYPE demo.estado_pedido AS ENUM ('pendiente','enviado','entregado','cancelado');
CREATE TYPE demo.accion_auditoria AS ENUM ('INSERT','UPDATE','DELETE');

CREATE TABLE IF NOT EXISTS productos (
  id         integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
  nombre     varchar(100) NOT NULL,
  categoria  varchar(50)  NOT NULL DEFAULT 'general',
  precio     numeric(10,2) NOT NULL,
  stock      integer NOT NULL DEFAULT 0,
  activo     boolean NOT NULL DEFAULT true,
  creado_en  timestamptz DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_categoria ON productos (categoria);

CREATE TABLE IF NOT EXISTS clientes (
  id         integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
  nombre     varchar(100) NOT NULL,
  email      varchar(150) NOT NULL UNIQUE,
  ciudad     varchar(80),
  creado_en  timestamptz DEFAULT now()
);

CREATE TABLE IF NOT EXISTS pedidos (
  id           integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
  cliente_id   integer NOT NULL REFERENCES clientes(id),
  producto_id  integer NOT NULL REFERENCES productos(id),
  cantidad     integer NOT NULL,
  total        numeric(10,2) NOT NULL,
  estado       demo.estado_pedido DEFAULT 'pendiente',
  creado_en    timestamptz DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_estado  ON pedidos (estado);
CREATE INDEX IF NOT EXISTS idx_cliente ON pedidos (cliente_id);

-- Catálogo autoreferenciado. Igual que en MySQL, sin FK desde productos.
CREATE TABLE IF NOT EXISTS categorias (
  id           integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
  slug         varchar(50)  NOT NULL UNIQUE,
  nombre       varchar(100) NOT NULL,
  descripcion  text,
  padre_id     integer NULL REFERENCES categorias(id) ON DELETE SET NULL,
  activa       boolean NOT NULL DEFAULT true,
  creada_en    timestamptz DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_activa ON categorias (activa);

-- En MySQL `ubicacion` es NOT NULL porque el índice espacial lo exige (error
-- 1252). En PostGIS un índice GiST admite NULL sin problema, pero la columna se
-- deja NOT NULL igualmente: si el esquema no coincide, la comparación entre los
-- dos motores deja de significar nada.
CREATE TABLE IF NOT EXISTS direcciones (
  id            integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
  cliente_id    integer NOT NULL REFERENCES clientes(id) ON DELETE CASCADE,
  etiqueta      varchar(30) NOT NULL DEFAULT 'principal',
  linea1        varchar(200) NOT NULL,
  linea2        varchar(200),
  ciudad        varchar(80) NOT NULL,
  pais          char(2) NOT NULL,
  metadatos     jsonb,
  ubicacion     geometry(Point, 0) NOT NULL,
  actualizada   timestamptz DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_ubicacion ON direcciones USING GIST (ubicacion);
CREATE INDEX IF NOT EXISTS idx_pais      ON direcciones (pais);

CREATE TABLE IF NOT EXISTS pedido_lineas (
  id            integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
  pedido_id     integer NOT NULL REFERENCES pedidos(id)   ON DELETE CASCADE,
  producto_id   integer NOT NULL REFERENCES productos(id) ON DELETE RESTRICT,
  cantidad      integer NOT NULL DEFAULT 1,
  precio_unit   numeric(10,2) NOT NULL,
  descuento_pct numeric(5,2) NOT NULL DEFAULT 0.00
);
CREATE INDEX IF NOT EXISTS idx_pedido ON pedido_lineas (pedido_id);

CREATE TABLE IF NOT EXISTS log_auditoria (
  id            bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
  ocurrido_en   timestamptz(3) DEFAULT now(),
  actor         varchar(80),
  entidad       varchar(80) NOT NULL,
  entidad_id    bigint,
  accion        demo.accion_auditoria NOT NULL,
  payload_json  jsonb,
  payload_hash  bytea
);
CREATE INDEX IF NOT EXISTS idx_entidad  ON log_auditoria (entidad, entidad_id);
CREATE INDEX IF NOT EXISTS idx_ocurrido ON log_auditoria (ocurrido_en);
PG_SEED_20_TABLES_DEMO_EOF

  cat > "${PG_SEED_DIR}/21-tables-rrhh.sql" <<'PG_SEED_21_TABLES_RRHH_EOF'
-- 21-tables-rrhh.sql — Postgres
--
-- Va en la base `rrhh`, no en un esquema de `demo`: ver el porqué en
-- 10-schemas.sql. `\connect` es del cliente psql, y el entrypoint de la imagen
-- ejecuta estos archivos con `psql -f`, así que se procesa.
--
-- Lo que cambia:
--   SET('lectura','escritura',…) → text[]. Postgres no tiene SET; un array de
--       texto es el equivalente honesto —varios valores en una columna— y
--       además ejercita un tipo que MySQL no tiene.
--   ENUM(…)                       → tipos enumerados de verdad.
--   GENERATED … STORED            → igual en los dos, sin traducción.
--   COMMENT en la columna         → COMMENT ON COLUMN, sentencia aparte.

\connect rrhh
SET search_path = rrhh, public;

CREATE TYPE rrhh.estado_empleado AS ENUM ('activo','baja_medica','vacaciones','despedido');
CREATE TYPE rrhh.estado_nomina   AS ENUM ('borrador','emitida','pagada','anulada');

CREATE TABLE IF NOT EXISTS departamentos (
  id           integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
  codigo       char(4) NOT NULL UNIQUE,
  nombre       varchar(120) NOT NULL,
  presupuesto  numeric(14,2) NOT NULL DEFAULT 0.00,
  creado_en    timestamptz DEFAULT now()
);

CREATE TABLE IF NOT EXISTS empleados (
  id                integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
  nombre            varchar(120) NOT NULL,
  email             varchar(150) NOT NULL UNIQUE,
  departamento_id   integer NOT NULL REFERENCES departamentos(id) ON DELETE RESTRICT,
  jefe_id           integer NULL     REFERENCES empleados(id)     ON DELETE SET NULL,
  fecha_ingreso     date NOT NULL,
  salario_base      numeric(12,2) NOT NULL,
  estado            rrhh.estado_empleado DEFAULT 'activo',
  permisos          text[] NOT NULL DEFAULT ARRAY['lectura'],
  notas             text
);
CREATE INDEX IF NOT EXISTS idx_departamento ON empleados (departamento_id);
CREATE INDEX IF NOT EXISTS idx_estado       ON empleados (estado);

CREATE TABLE IF NOT EXISTS nominas (
  id             bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
  empleado_id    integer NOT NULL REFERENCES empleados(id) ON DELETE CASCADE,
  periodo        char(7) NOT NULL,
  bruto          numeric(12,2) NOT NULL,
  impuestos      numeric(12,2) NOT NULL,
  neto           numeric(12,2) GENERATED ALWAYS AS (bruto - impuestos) STORED,
  estado         rrhh.estado_nomina DEFAULT 'borrador',
  documento_pdf  bytea,
  emitida_en     timestamptz NULL,
  CONSTRAINT uk_empleado_periodo UNIQUE (empleado_id, periodo)
);
CREATE INDEX IF NOT EXISTS idx_periodo ON nominas (periodo);

COMMENT ON COLUMN nominas.periodo  IS 'YYYY-MM';
COMMENT ON COLUMN empleados.permisos IS 'Equivalente del SET de MySQL: varios permisos por empleado';

\connect demo
PG_SEED_21_TABLES_RRHH_EOF

  cat > "${PG_SEED_DIR}/22-tables-analytics.sql" <<'PG_SEED_22_TABLES_ANALYTICS_EOF'
-- 22-tables-analytics.sql — Postgres
--
-- En MySQL estas tres tablas existen para tener TRES MOTORES DE
-- ALMACENAMIENTO distintos (MEMORY, MyISAM, InnoDB particionada). Postgres no
-- tiene motores intercambiables, así que esa cobertura **no se traduce**: se
-- sustituye por los ejes equivalentes que sí existen, y lo que se pierde queda
-- escrito en 60-events.sql junto con el resto de huecos.
--
--   ENGINE=MEMORY   → UNLOGGED. No es lo mismo —los datos sobreviven a un
--                     reinicio limpio, y se pierden sólo tras una caída— pero
--                     es el único eje análogo: tabla sin WAL, más rápida y no
--                     replicada a los standby.
--   INDEX USING HASH → USING HASH existe igual en Postgres.
--   ENGINE=MyISAM   → sin equivalente. Lo que la tabla ejercitaba de verdad era
--                     FULLTEXT, y eso sí existe: índice GIN sobre to_tsvector.
--                     CHECK/REPAIR de tabla no tienen contraparte.
--   PARTITION BY RANGE (TO_DAYS(fecha)) → PARTITION BY RANGE (fecha), que es
--                     particionado declarativo de verdad y no necesita envolver
--                     la fecha en una función.
--   INT UNSIGNED    → integer con CHECK (>= 0). Postgres no tiene enteros sin
--                     signo, y una restricción explícita dice lo mismo.
--   VARBINARY(16) + INET6_ATON → inet, que es el tipo que le corresponde.

SET search_path = analytics, public;

CREATE TYPE analytics.nivel_log AS ENUM ('debug','info','warn','error');

CREATE UNLOGGED TABLE IF NOT EXISTS sesiones_activas (
  session_id   char(36) NOT NULL PRIMARY KEY,
  usuario      varchar(80) NOT NULL,
  ip           inet NOT NULL,
  iniciada_en  timestamptz DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_usuario_hash ON sesiones_activas USING HASH (usuario);

CREATE TABLE IF NOT EXISTS logs_ligeros (
  id         bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
  nivel      analytics.nivel_log NOT NULL DEFAULT 'info',
  origen     varchar(80) NOT NULL,
  mensaje    text NOT NULL,
  registrado timestamptz DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_mensaje    ON logs_ligeros USING GIN (to_tsvector('spanish', mensaje));
CREATE INDEX IF NOT EXISTS idx_nivel      ON logs_ligeros (nivel);
CREATE INDEX IF NOT EXISTS idx_registrado ON logs_ligeros (registrado);

-- Particionado declarativo. La clave de partición tiene que formar parte de la
-- clave primaria, y aquí ya lo estaba: PRIMARY KEY (fecha, metrica).
CREATE TABLE IF NOT EXISTS metricas_diarias (
  fecha       date NOT NULL,
  metrica     varchar(60) NOT NULL,
  valor       numeric(18,4) NOT NULL,
  muestras    integer NOT NULL DEFAULT 1 CHECK (muestras >= 0),
  PRIMARY KEY (fecha, metrica)
) PARTITION BY RANGE (fecha);

CREATE TABLE IF NOT EXISTS metricas_diarias_p_hist
  PARTITION OF metricas_diarias FOR VALUES FROM (MINVALUE)     TO ('2025-01-01');
CREATE TABLE IF NOT EXISTS metricas_diarias_p_2025q1
  PARTITION OF metricas_diarias FOR VALUES FROM ('2025-01-01') TO ('2025-04-01');
CREATE TABLE IF NOT EXISTS metricas_diarias_p_2025q2
  PARTITION OF metricas_diarias FOR VALUES FROM ('2025-04-01') TO ('2025-07-01');
CREATE TABLE IF NOT EXISTS metricas_diarias_p_2025q3
  PARTITION OF metricas_diarias FOR VALUES FROM ('2025-07-01') TO ('2025-10-01');
CREATE TABLE IF NOT EXISTS metricas_diarias_p_2025q4
  PARTITION OF metricas_diarias FOR VALUES FROM ('2025-10-01') TO ('2026-01-01');
CREATE TABLE IF NOT EXISTS metricas_diarias_p_2026q1
  PARTITION OF metricas_diarias FOR VALUES FROM ('2026-01-01') TO ('2026-04-01');
CREATE TABLE IF NOT EXISTS metricas_diarias_p_2026q2
  PARTITION OF metricas_diarias FOR VALUES FROM ('2026-04-01') TO ('2026-07-01');
CREATE TABLE IF NOT EXISTS metricas_diarias_p_2026q3
  PARTITION OF metricas_diarias FOR VALUES FROM ('2026-07-01') TO ('2026-10-01');
CREATE TABLE IF NOT EXISTS metricas_diarias_p_futuro
  PARTITION OF metricas_diarias FOR VALUES FROM ('2026-10-01') TO (MAXVALUE);

COMMENT ON TABLE sesiones_activas IS 'UNLOGGED: el análogo más cercano a ENGINE=MEMORY de MySQL';
COMMENT ON TABLE logs_ligeros     IS 'En MySQL es MyISAM, por el FULLTEXT; aquí el índice de texto es GIN';
PG_SEED_22_TABLES_ANALYTICS_EOF

  cat > "${PG_SEED_DIR}/23-tables-gis.sql" <<'PG_SEED_23_TABLES_GIS_EOF'
-- 23-tables-gis.sql — Postgres
--
-- Equivalente de `seed/23-tables-gis.sql`: el esquema espacial que en MySQL
-- viaja con el seed compartido. SRID 0 y X=longitud, igual que el original —y
-- en PostGIS ésa es la única convención que hay, ver 24-tables-gis-node.sql.
--
-- Aquí `caliope_gis_test` es un esquema y no una base, porque en Postgres la
-- conexión está atada a una base sola (ver 10-schemas.sql).

SET search_path = caliope_gis_test, public;

CREATE TABLE IF NOT EXISTS spatial_points (
  id        integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
  name      varchar(100) NOT NULL,
  category  varchar(50),
  location  geometry(Point, 0) NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_location ON spatial_points USING GIST (location);
CREATE INDEX IF NOT EXISTS idx_category ON spatial_points (category);

CREATE TABLE IF NOT EXISTS spatial_regions (
  id           integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
  region_name  varchar(100) NOT NULL,
  boundary     geometry(Polygon, 0) NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_boundary ON spatial_regions USING GIST (boundary);

CREATE TABLE IF NOT EXISTS spatial_routes (
  id          integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
  route_name  varchar(100) NOT NULL,
  path        geometry(LineString, 0) NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_path ON spatial_routes USING GIST (path);

-- Geometría heterogénea y anulable: `geometry` sin tipo declarado admite
-- cualquier subtipo, que es lo que esta tabla existe para ejercitar.
CREATE TABLE IF NOT EXISTS spatial_mixed (
  id        integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
  label     varchar(100) NOT NULL,
  note      varchar(200),
  shape     geometry NULL
);
PG_SEED_23_TABLES_GIS_EOF

  cat > "${PG_SEED_DIR}/24-tables-gis-node.sql" <<'PG_SEED_24_TABLES_GIS_NODE_EOF'
-- 24-tables-gis-node.sql — Postgres
--
-- ═══════════════════════════════════════════════════════════════════════════
--  EL ORDEN DE LOS EJES, QUE ES LA RAZÓN DE SER DE ESTE ARCHIVO
-- ═══════════════════════════════════════════════════════════════════════════
--
-- El nodo `mysql8-gis` existe por una sola cosa: MySQL 8 **respeta**
-- el orden de ejes que declara el sistema de referencia, y EPSG:4326 declara
-- LATITUD PRIMERO. Por eso allí hay dos bases gemelas —`gis` con latitud
-- primero y `gis_cartesian` con longitud primero— y leerlas una al lado de la
-- otra es lo que hace visible un error de ejes.
--
-- **PostGIS no tiene ese modo.** Siempre es X = longitud, igual que MariaDB,
-- sin importar lo que declare la autoridad del SRID. Así que:
--
--   · La gemela `gis_cartesian` NO se reproduce aquí. Reproducirla sería fingir
--     una distinción que en este motor no existe, y un nodo de la cama de
--     pruebas que no distingue nada es peor que no tenerlo: da confianza falsa.
--   · Los datos vienen del seed de MySQL con **los ejes intercambiados**. No es
--     una corrección cosmética: copiados tal cual, Madrid quedaría en
--     longitud 40,4 y latitud -3,7, o sea en Somalia. El descuido no da error
--     —el servidor acepta el punto— y mueve el dato de continente.
--
-- Esto es dato para quien implemente el provider: en Postgres `axisOrder` no
-- es una decisión, es una constante. Lo que en MySQL 8 hay que negociar, aquí
-- hay que no tocar.
-- ═══════════════════════════════════════════════════════════════════════════

SET search_path = gis, public;

CREATE TYPE gis.continente AS ENUM ('Europa','América del Norte','América Central',
                                    'América del Sur','África','Asia','Oceanía','Antártida');
CREATE TYPE gis.tipo_ruta  AS ENUM ('aérea','marítima','terrestre','ferroviaria');
CREATE TYPE gis.clase_region AS ENUM ('país','provincia','parque','zona urbana','reserva');

CREATE TABLE IF NOT EXISTS ciudades (
  id           integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
  nombre       varchar(80)  NOT NULL,
  pais         char(2)      NOT NULL,
  continente   gis.continente NOT NULL,
  poblacion    integer      NULL CHECK (poblacion >= 0),
  es_capital   boolean      NOT NULL DEFAULT false,
  ubicacion    geometry(Point, 4326) NOT NULL,
  actualizada  timestamptz  DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_ciudades_ubicacion  ON ciudades USING GIST (ubicacion);
CREATE INDEX IF NOT EXISTS idx_ciudades_pais       ON ciudades (pais);
CREATE INDEX IF NOT EXISTS idx_ciudades_continente ON ciudades (continente);
COMMENT ON TABLE  ciudades           IS 'Puntos SRID 4326 — en PostGIS, X es longitud siempre';
COMMENT ON COLUMN ciudades.poblacion IS 'Área metropolitana aproximada, redondeada';

CREATE TABLE IF NOT EXISTS aeropuertos (
  id           integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
  iata         char(3)      NOT NULL,
  nombre       varchar(120) NOT NULL,
  ciudad_id    integer      NULL REFERENCES ciudades(id) ON DELETE SET NULL,
  altitud_m    integer      NULL,
  ubicacion    geometry(Point, 4326) NOT NULL,
  CONSTRAINT uq_iata UNIQUE (iata)
);
CREATE INDEX IF NOT EXISTS idx_aeropuertos_ubicacion ON aeropuertos USING GIST (ubicacion);

CREATE TABLE IF NOT EXISTS rutas (
  id           integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
  nombre       varchar(120) NOT NULL,
  tipo         gis.tipo_ruta NOT NULL,
  distancia_km numeric(10,2) NULL,
  trazado      geometry(LineString, 4326) NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_rutas_trazado ON rutas USING GIST (trazado);
CREATE INDEX IF NOT EXISTS idx_rutas_tipo    ON rutas (tipo);

CREATE TABLE IF NOT EXISTS regiones (
  id           integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
  nombre       varchar(120) NOT NULL,
  clase        gis.clase_region NOT NULL,
  area_km2     numeric(14,2) NULL,
  contorno     geometry(Polygon, 4326) NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_regiones_contorno ON regiones USING GIST (contorno);
CREATE INDEX IF NOT EXISTS idx_regiones_clase    ON regiones (clase);
COMMENT ON TABLE regiones IS 'Contornos aproximados: cajas envolventes, no fronteras reales';

-- Los casos raros. Geometría anulable y sin subtipo declarado: es la tabla que
-- rompe los lectores ingenuos, y está aquí para eso.
CREATE TABLE IF NOT EXISTS zonas_complejas (
  id           integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
  nombre       varchar(120) NOT NULL,
  motivo       varchar(200) NOT NULL,
  forma        geometry     NULL
);
CREATE INDEX IF NOT EXISTS idx_zonas_nombre ON zonas_complejas (nombre);
COMMENT ON COLUMN zonas_complejas.motivo IS 'Qué caso límite ejercita esta fila';

-- Volumen. `sensores` se llena por encima del techo de 10 000 geometrías del
-- mapa a propósito: el aviso de recorte tiene que ser alcanzable con un
-- `SELECT * FROM sensores` y sin escribir una consulta a mano.
CREATE TABLE IF NOT EXISTS sensores (
  id           integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
  codigo       varchar(24)  NOT NULL,
  lote         smallint     NOT NULL,
  temperatura  numeric(5,2) NULL,
  activo       boolean      NOT NULL DEFAULT true,
  ubicacion    geometry(Point, 4326) NOT NULL,
  medido_en    timestamp    NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sensores_ubicacion ON sensores USING GIST (ubicacion);
CREATE INDEX IF NOT EXISTS idx_sensores_lote      ON sensores (lote);

CREATE TABLE IF NOT EXISTS parcelas (
  id           integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
  referencia   varchar(24)  NOT NULL,
  cultivo      varchar(40)  NULL,
  contorno     geometry(Polygon, 4326) NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_parcelas_contorno ON parcelas USING GIST (contorno);

-- Una vista sobre geometrías, para comprobar que el tipo de la columna
-- sobrevive al pasar por ella.
CREATE OR REPLACE VIEW v_capitales AS
  SELECT id, nombre, pais, poblacion, ubicacion
    FROM ciudades
   WHERE es_capital;
PG_SEED_24_TABLES_GIS_NODE_EOF

  cat > "${PG_SEED_DIR}/30-views.sql" <<'PG_SEED_30_VIEWS_EOF'
-- 30-views.sql — Postgres
--
-- Las mismas cuatro vistas, cubriendo las mismas combinaciones. Lo que cambia:
--
--   ALGORITHM = MERGE / UNDEFINED  → sin equivalente. Postgres decide solo si
--       puede empotrar la vista en la consulta; no es una opción del DDL.
--   DEFINER = 'caliope'@'%'        → en Postgres el «definer» es el dueño de la
--       vista, y lo es quien la crea. No se escribe.
--   SQL SECURITY INVOKER           → WITH (security_invoker = true), que existe
--       desde Postgres 15. El valor por omisión sigue siendo «definer», igual
--       que en MySQL.
--   WITH CHECK OPTION              → WITH CASCADED CHECK OPTION.
--   NOW() - INTERVAL 30 DAY        → now() - interval '30 days'.

SET search_path = demo, public;

-- 1) Ranking de productos por pedidos. GROUP BY → no actualizable.
CREATE OR REPLACE VIEW v_top_productos AS
SELECT
  p.id,
  p.nombre,
  p.categoria,
  COUNT(pe.id)                AS total_pedidos,
  COALESCE(SUM(pe.total), 0)  AS ingresos
FROM productos p
LEFT JOIN pedidos pe ON pe.producto_id = p.id
GROUP BY p.id, p.nombre, p.categoria;

-- 2) Pedidos recientes (últimos 30 días) — actualizable, con CHECK OPTION.
CREATE OR REPLACE VIEW v_pedidos_recientes AS
SELECT
  id, cliente_id, producto_id, cantidad, total, estado, creado_en
FROM pedidos
WHERE creado_en >= (now() - interval '30 days')
WITH CASCADED CHECK OPTION;

-- 3) Clientes activos con seguridad del invocador.
CREATE OR REPLACE VIEW v_clientes_activos
WITH (security_invoker = true) AS
SELECT
  c.id,
  c.nombre,
  c.email,
  c.ciudad,
  COUNT(pe.id) AS pedidos_totales
FROM clientes c
LEFT JOIN pedidos pe ON pe.cliente_id = c.id
GROUP BY c.id, c.nombre, c.email, c.ciudad;

-- 4) Resumen de ventas vía UNION — no actualizable por definición.
CREATE OR REPLACE VIEW v_resumen_ventas AS
SELECT 'pendiente'::text AS estado, COUNT(*) AS pedidos, COALESCE(SUM(total), 0) AS monto
  FROM pedidos WHERE estado = 'pendiente'
UNION ALL
SELECT 'enviado',    COUNT(*), COALESCE(SUM(total), 0)
  FROM pedidos WHERE estado = 'enviado'
UNION ALL
SELECT 'entregado',  COUNT(*), COALESCE(SUM(total), 0)
  FROM pedidos WHERE estado = 'entregado'
UNION ALL
SELECT 'cancelado',  COUNT(*), COALESCE(SUM(total), 0)
  FROM pedidos WHERE estado = 'cancelado';
PG_SEED_30_VIEWS_EOF

  cat > "${PG_SEED_DIR}/40-routines.sql" <<'PG_SEED_40_ROUTINES_EOF'
-- 40-routines.sql — Postgres
--
-- Los mismos seis objetos con los mismos nombres, pero esto es una reescritura,
-- no una traducción. Lo que hay que saber:
--
--   DELIMITER //         → no existe ni hace falta. El cuerpo va entre $$ …$$,
--       que es una comilla de dólar de Postgres, y dentro los `;` no cortan
--       nada. `DELIMITER` era un apaño del CLIENTE de MySQL, no SQL.
--   SQL/PSM              → plpgsql. Es otro lenguaje: `DECLARE` va antes del
--       `BEGIN`, no dentro; `SET x = …` es `x := …`; `SELECT … INTO var` existe
--       pero con otra semántica de «ninguna fila».
--   DETERMINISTIC        → IMMUTABLE.
--   NOT DETERMINISTIC + READS SQL DATA → STABLE.
--   NO SQL               → IMMUTABLE.
--   MODIFIES SQL DATA    → VOLATILE (el valor por omisión).
--   SQL SECURITY DEFINER → SECURITY DEFINER, igual.
--   LAST_INSERT_ID()     → INSERT … RETURNING … INTO, que además es exacto:
--       `LAST_INSERT_ID` es por conexión y aquí no hace falta creérselo.
--
-- **Una diferencia que no es cosmética**: `sp_reporte_ventas` en MySQL es un
-- PROCEDURE que devuelve un conjunto de filas. Un procedimiento de Postgres no
-- puede hacer eso, así que aquí es una FUNCTION … RETURNS TABLE. El nombre se
-- conserva para que los pasos de QA lo encuentren, pero se invoca con SELECT y
-- no con CALL, y eso lo tiene que saber quien escriba el paso.
--
-- **Cada rutina lleva su propio `SET search_path`, y no es decoración.** El
-- cuerpo de una función de Postgres resuelve los nombres sin cualificar contra
-- el `search_path` de QUIEN LA LLAMA, no contra el del esquema donde se creó.
-- Sin esta línea, `fn_stock_producto` funciona desde psql con el search_path
-- puesto y falla con «relation "productos" does not exist» desde cualquier
-- cliente que no lo fije —o sea, desde Calíope—. Se descubrió aquí porque un
-- INSERT a través de una vista disparó un trigger que no encontró su tabla.
-- Además es lo que recomienda la documentación para todo `SECURITY DEFINER`:
-- un search_path abierto en una función con privilegios ajenos es una vía de
-- escalada.

SET search_path = demo, public;

DROP PROCEDURE IF EXISTS sp_registrar_pedido(integer, integer, integer, integer);
DROP PROCEDURE IF EXISTS sp_ajustar_stock(integer, integer);
DROP FUNCTION  IF EXISTS sp_reporte_ventas(date, date);
DROP FUNCTION  IF EXISTS fn_precio_con_iva(numeric, numeric);
DROP FUNCTION  IF EXISTS fn_stock_producto(integer);
DROP FUNCTION  IF EXISTS fn_slugify(varchar);

CREATE PROCEDURE sp_registrar_pedido(
    IN  in_cliente_id  integer,
    IN  in_producto_id integer,
    IN  in_cantidad    integer,
    OUT out_pedido_id  integer
)
LANGUAGE plpgsql
SET search_path = demo, public
SECURITY DEFINER
AS $$
DECLARE
    v_precio numeric(10,2);
BEGIN
    SELECT precio INTO v_precio FROM productos WHERE id = in_producto_id;

    IF v_precio IS NULL THEN
        out_pedido_id := NULL;
    ELSE
        INSERT INTO pedidos (cliente_id, producto_id, cantidad, total, estado)
          VALUES (in_cliente_id, in_producto_id, in_cantidad,
                  v_precio * in_cantidad, 'pendiente')
        RETURNING id INTO out_pedido_id;
    END IF;
END;
$$;
COMMENT ON PROCEDURE sp_registrar_pedido(integer, integer, integer, integer)
  IS 'Registra un pedido calculando el total desde productos.precio';

-- Función, no procedimiento: ver la nota de la cabecera.
CREATE FUNCTION sp_reporte_ventas(in_desde date, in_hasta date)
RETURNS TABLE (estado demo.estado_pedido, pedidos bigint, monto numeric)
LANGUAGE plpgsql
SET search_path = demo, public
STABLE
SECURITY DEFINER
AS $$
BEGIN
    RETURN QUERY
    SELECT p.estado,
           COUNT(*)                  AS pedidos,
           COALESCE(SUM(p.total), 0) AS monto
      FROM pedidos p
     WHERE p.creado_en::date BETWEEN in_desde AND in_hasta
     GROUP BY p.estado;
END;
$$;
COMMENT ON FUNCTION sp_reporte_ventas(date, date)
  IS 'Resumen de ventas por estado en un rango. En MySQL es un PROCEDURE; aquí una función que devuelve tabla';

CREATE PROCEDURE sp_ajustar_stock(
    IN    in_producto_id integer,
    INOUT io_delta       integer
)
LANGUAGE plpgsql
SET search_path = demo, public
SECURITY DEFINER
AS $$
BEGIN
    UPDATE productos SET stock = GREATEST(0, stock + io_delta)
     WHERE id = in_producto_id;
    SELECT stock INTO io_delta FROM productos WHERE id = in_producto_id;
END;
$$;
COMMENT ON PROCEDURE sp_ajustar_stock(integer, integer)
  IS 'Aplica un delta al stock; devuelve en io_delta el stock resultante';

CREATE FUNCTION fn_precio_con_iva(p_precio numeric(10,2), p_iva_pct numeric(5,2))
RETURNS numeric(10,2)
LANGUAGE plpgsql
SET search_path = demo, public
IMMUTABLE
SECURITY DEFINER
AS $$
BEGIN
    RETURN ROUND(p_precio * (1 + p_iva_pct / 100), 2);
END;
$$;
COMMENT ON FUNCTION fn_precio_con_iva(numeric, numeric)
  IS 'Aplica un porcentaje de IVA sobre un precio base';

CREATE FUNCTION fn_stock_producto(p_producto_id integer)
RETURNS integer
LANGUAGE plpgsql
SET search_path = demo, public
STABLE
SECURITY DEFINER
AS $$
DECLARE
    v_stock integer;
BEGIN
    SELECT stock INTO v_stock FROM productos WHERE id = p_producto_id;
    RETURN COALESCE(v_stock, 0);
END;
$$;
COMMENT ON FUNCTION fn_stock_producto(integer)
  IS 'Devuelve el stock actual de un producto por id';

CREATE FUNCTION fn_slugify(p_texto varchar(200))
RETURNS varchar(200)
LANGUAGE plpgsql
SET search_path = demo, public
IMMUTABLE
SECURITY DEFINER
AS $$
BEGIN
    RETURN LOWER(REPLACE(REPLACE(BTRIM(p_texto), ' ', '-'), '_', '-'));
END;
$$;
COMMENT ON FUNCTION fn_slugify(varchar)
  IS 'Normaliza un texto a slug ASCII minúsculas';
PG_SEED_40_ROUTINES_EOF

  cat > "${PG_SEED_DIR}/50-triggers.sql" <<'PG_SEED_50_TRIGGERS_EOF'
-- 50-triggers.sql — Postgres
--
-- Los mismos seis disparadores, más dos que en MySQL no son disparadores. Esto
-- también es reescritura:
--
--   · **Un disparador de Postgres son DOS objetos**: una función que devuelve
--     `trigger` y el `CREATE TRIGGER` que la llama. En MySQL el cuerpo va
--     dentro. Por eso aquí hay el doble de nombres para el mismo número de
--     comportamientos.
--   · `FOR EACH ROW` es igual, pero un BEFORE de Postgres **tiene que devolver
--     NEW** o la fila no se escribe. Devolver NULL es cancelar la operación en
--     silencio, que es justo el fallo que parece «no hace nada».
--   · `SET NEW.x = …` → `NEW.x := …`.
--   · `JSON_OBJECT(…)`  → `jsonb_build_object(…)`.
--   · `UNHEX(SHA2(x, 256))` → `sha256(convert_to(x, 'UTF8'))`, que ya devuelve
--     bytea. No hace falta pgcrypto: `sha256` es de serie desde Postgres 11.
--   · `CONCAT_WS('|', …)` → `concat_ws('|', …)`, igual.
--   · `CURRENT_USER()` → `current_user`, sin paréntesis.
--
-- Los dos disparadores de más son el equivalente de
-- `ON UPDATE CURRENT_TIMESTAMP`, que en MySQL es una cláusula de la columna y
-- en Postgres no existe: hay que escribirlo.
--
-- **Cada función lleva su propio `SET search_path`.** Un disparador se ejecuta
-- con el `search_path` del cliente que provocó el INSERT, no con el del esquema
-- donde vive: sin esa línea, un `INSERT INTO demo.pedidos` desde una conexión
-- normal muere con «relation "log_auditoria" does not exist» **desde dentro del
-- disparador**, y el mensaje señala a una tabla que sí existe. El porqué largo
-- está en la cabecera de 40-routines.sql.

SET search_path = demo, public;

-- ── 1 y 2) Normalización del slug, sobre tabla aislada del loadgen ──────────

CREATE OR REPLACE FUNCTION trg_categorias_slug_insert() RETURNS trigger
LANGUAGE plpgsql
SET search_path = demo, public AS $$
BEGIN
    IF NEW.slug IS NULL OR NEW.slug = '' THEN
        NEW.slug := fn_slugify(NEW.nombre);
    END IF;
    RETURN NEW;
END;
$$;

DROP TRIGGER IF EXISTS bi_categorias_slug ON categorias;
CREATE TRIGGER bi_categorias_slug
BEFORE INSERT ON categorias
FOR EACH ROW EXECUTE FUNCTION trg_categorias_slug_insert();

CREATE OR REPLACE FUNCTION trg_categorias_slug_update() RETURNS trigger
LANGUAGE plpgsql
SET search_path = demo, public AS $$
BEGIN
    IF NEW.nombre <> OLD.nombre AND NEW.slug = OLD.slug THEN
        NEW.slug := fn_slugify(NEW.nombre);
    END IF;
    RETURN NEW;
END;
$$;

DROP TRIGGER IF EXISTS bu_categorias_slug ON categorias;
CREATE TRIGGER bu_categorias_slug
BEFORE UPDATE ON categorias
FOR EACH ROW EXECUTE FUNCTION trg_categorias_slug_update();

-- ── 3, 4 y 5) Auditoría de pedidos: sólo escribe en log_auditoria ───────────

CREATE OR REPLACE FUNCTION trg_pedidos_audit_insert() RETURNS trigger
LANGUAGE plpgsql
SET search_path = demo, public AS $$
BEGIN
    INSERT INTO log_auditoria (actor, entidad, entidad_id, accion, payload_json, payload_hash)
    VALUES (
        current_user,
        'pedidos',
        NEW.id,
        'INSERT',
        jsonb_build_object(
            'cliente_id',  NEW.cliente_id,
            'producto_id', NEW.producto_id,
            'cantidad',    NEW.cantidad,
            'total',       NEW.total,
            'estado',      NEW.estado
        ),
        sha256(convert_to(concat_ws('|', NEW.cliente_id, NEW.producto_id, NEW.total), 'UTF8'))
    );
    RETURN NULL;  -- AFTER: el valor devuelto se ignora
END;
$$;

DROP TRIGGER IF EXISTS ai_pedidos_audit ON pedidos;
CREATE TRIGGER ai_pedidos_audit
AFTER INSERT ON pedidos
FOR EACH ROW EXECUTE FUNCTION trg_pedidos_audit_insert();

-- El WHEN va en el CREATE TRIGGER y no dentro del cuerpo: así el disparador ni
-- se llama cuando el estado no cambia. En MySQL eso sólo se puede escribir con
-- un IF dentro.
CREATE OR REPLACE FUNCTION trg_pedidos_audit_update() RETURNS trigger
LANGUAGE plpgsql
SET search_path = demo, public AS $$
BEGIN
    INSERT INTO log_auditoria (actor, entidad, entidad_id, accion, payload_json, payload_hash)
    VALUES (
        current_user,
        'pedidos',
        NEW.id,
        'UPDATE',
        jsonb_build_object('estado_anterior', OLD.estado, 'estado_nuevo', NEW.estado),
        sha256(convert_to(concat_ws('|', NEW.id, OLD.estado, NEW.estado), 'UTF8'))
    );
    RETURN NULL;
END;
$$;

DROP TRIGGER IF EXISTS au_pedidos_audit ON pedidos;
CREATE TRIGGER au_pedidos_audit
AFTER UPDATE ON pedidos
FOR EACH ROW WHEN (NEW.estado IS DISTINCT FROM OLD.estado)
EXECUTE FUNCTION trg_pedidos_audit_update();

CREATE OR REPLACE FUNCTION trg_pedidos_audit_delete() RETURNS trigger
LANGUAGE plpgsql
SET search_path = demo, public AS $$
BEGIN
    INSERT INTO log_auditoria (actor, entidad, entidad_id, accion, payload_json)
    VALUES (
        current_user,
        'pedidos',
        OLD.id,
        'DELETE',
        jsonb_build_object(
            'cliente_id',  OLD.cliente_id,
            'producto_id', OLD.producto_id,
            'total',       OLD.total,
            'estado',      OLD.estado
        )
    );
    RETURN NULL;
END;
$$;

DROP TRIGGER IF EXISTS ad_pedidos_audit ON pedidos;
CREATE TRIGGER ad_pedidos_audit
AFTER DELETE ON pedidos
FOR EACH ROW EXECUTE FUNCTION trg_pedidos_audit_delete();

-- ── 6) Auditoría de altas de cliente ────────────────────────────────────────

CREATE OR REPLACE FUNCTION trg_clientes_audit_insert() RETURNS trigger
LANGUAGE plpgsql
SET search_path = demo, public AS $$
BEGIN
    INSERT INTO log_auditoria (actor, entidad, entidad_id, accion, payload_json)
    VALUES (
        current_user,
        'clientes',
        NEW.id,
        'INSERT',
        jsonb_build_object('email', NEW.email, 'ciudad', NEW.ciudad)
    );
    RETURN NULL;
END;
$$;

DROP TRIGGER IF EXISTS ai_clientes_audit ON clientes;
CREATE TRIGGER ai_clientes_audit
AFTER INSERT ON clientes
FOR EACH ROW EXECUTE FUNCTION trg_clientes_audit_insert();

-- ── 7 y 8) El equivalente de ON UPDATE CURRENT_TIMESTAMP ────────────────────
-- En MySQL es una cláusula de la columna; aquí es un disparador y punto. Sin
-- esto, `direcciones.actualizada` y `gis.ciudades.actualizada` se quedarían
-- congeladas en el momento del alta y las dos camas dejarían de comparar igual.

CREATE OR REPLACE FUNCTION trg_touch_actualizada() RETURNS trigger
LANGUAGE plpgsql
SET search_path = demo, public AS $$
BEGIN
    NEW.actualizada := now();
    RETURN NEW;
END;
$$;

DROP TRIGGER IF EXISTS bu_direcciones_touch ON direcciones;
CREATE TRIGGER bu_direcciones_touch
BEFORE UPDATE ON direcciones
FOR EACH ROW EXECUTE FUNCTION trg_touch_actualizada();

DROP TRIGGER IF EXISTS bu_ciudades_touch ON gis.ciudades;
CREATE TRIGGER bu_ciudades_touch
BEFORE UPDATE ON gis.ciudades
FOR EACH ROW EXECUTE FUNCTION trg_touch_actualizada();
PG_SEED_50_TRIGGERS_EOF

  cat > "${PG_SEED_DIR}/60-sin-equivalente.sql" <<'PG_SEED_60_SIN_EQUIVALENTE_EOF'
-- 60-sin-equivalente.sql — Postgres
--
-- Este archivo no crea nada. Es el inventario de lo que el seed de MySQL tiene
-- y aquí **no existe**, y está numerado en el hueco que dejó `60-events.sql`
-- para que se note la ausencia al listar el directorio.
--
-- Existe porque un hueco que no se escribe se lee como un descuido, y porque
-- quien implemente el provider de Postgres necesita esta lista antes de
-- escribir la primera línea: cada punto de aquí es una herramienta de Calíope
-- que tiene que saber decir «este motor no tiene esto» en vez de devolver una
-- lista vacía.
--
-- ═══════════════════════════════════════════════════════════════════════════
--  1. EVENTOS PROGRAMADOS  (seed/60-events.sql — 3 eventos, 66 líneas)
-- ═══════════════════════════════════════════════════════════════════════════
--  MySQL y MariaDB traen un planificador dentro del servidor
--  (`event_scheduler`), con eventos recurrentes, de una sola vez, habilitados o
--  no. Postgres **no tiene ninguno en el núcleo**: `pg_cron` es una extensión
--  de terceros que hay que instalar en el servidor y declarar en
--  `shared_preload_libraries`, o sea que no es una diferencia de sintaxis sino
--  de qué trae puesto el motor.
--
--  No se instala aquí a propósito: meter una extensión ajena en la cama de
--  pruebas haría que la herramienta de eventos pareciera funcionar en Postgres,
--  y en el Postgres de un usuario cualquiera no funcionaría.
--
--  Lo que se pierde de cobertura: los tres casos que ejercitaba
--  (`RECURRING` habilitado, `RECURRING` deshabilitado, `ONE TIME`) y con ellos
--  la herramienta de eventos entera.
--
-- ═══════════════════════════════════════════════════════════════════════════
--  2. MOTORES DE ALMACENAMIENTO  (seed/22-tables-analytics.sql)
-- ═══════════════════════════════════════════════════════════════════════════
--  Las tres tablas de `analytics` existen en MySQL para tener tres motores
--  distintos en la misma base. Postgres tiene UNO. Lo que se sustituyó y lo que
--  no está en la cabecera de 22-tables-analytics.sql; en resumen:
--
--    ENGINE=MEMORY  → UNLOGGED       (parecido, no igual)
--    ENGINE=MyISAM  → sin equivalente. Se conserva lo que la tabla ejercitaba
--                     de verdad —el índice de texto completo— como GIN.
--    CHECK TABLE / REPAIR TABLE → **sin equivalente**, y con ellos el sentido
--                     de la herramienta de mantenimiento de tablas.
--    Particionado   → sí existe, y declarativo. Ése no se pierde.
--
-- ═══════════════════════════════════════════════════════════════════════════
--  3. ORDEN DE EJES LATITUD-PRIMERO  (gis-seed/, base `gis_cartesian`)
-- ═══════════════════════════════════════════════════════════════════════════
--  PostGIS es siempre X = longitud. El nodo gemelo de MySQL 8 no se reproduce;
--  el porqué, largo, está en la cabecera de 24-tables-gis-node.sql. Para el
--  provider: en Postgres `axisOrder` no es una decisión, es una constante.
--
-- ═══════════════════════════════════════════════════════════════════════════
--  4. COSAS PEQUEÑAS QUE CAMBIARON DE FORMA
-- ═══════════════════════════════════════════════════════════════════════════
--    · `SET('a','b')`            → text[]
--    · `TINYINT(1)`              → boolean (Postgres gana un tipo aquí)
--    · `INT UNSIGNED`            → integer + CHECK (>= 0)
--    · `ON UPDATE CURRENT_TIMESTAMP` → disparador escrito a mano
--    · `LAST_INSERT_ID()`        → INSERT … RETURNING
--    · un PROCEDURE que devuelve filas → una FUNCTION … RETURNS TABLE
--      (`sp_reporte_ventas` se invoca con SELECT, no con CALL)
--    · `DELIMITER`               → no existe; es del cliente, no del motor
--    · `ALGORITHM` de una vista  → no existe
--    · `CREATE USER` + `GRANT ALL` → innecesario: `caliope` es el superusuario
--
-- ═══════════════════════════════════════════════════════════════════════════
--  5. LO QUE VA AL REVÉS: lo que hay aquí y en MySQL no
-- ═══════════════════════════════════════════════════════════════════════════
--    · Tipos enumerados como objetos de primera clase (`CREATE TYPE … AS ENUM`),
--      que se listan, se alteran y se comparten entre tablas.
--    · `boolean` de verdad.
--    · `inet` para direcciones IP.
--    · Arrays.
--    · Esquemas **dentro** de una base, que es un nivel de jerarquía que MySQL
--      no tiene y que el árbol de esquema de Calíope va a tener que enseñar.
--
--  Este último punto es el que más trabajo trae, y no está en ninguna lista de
--  «lo que falta»: no es una carencia de Postgres, es una carencia de la
--  interfaz de Calíope, que hoy da por hecho que base y esquema son lo mismo.
--
-- ═══════════════════════════════════════════════════════════════════════════
--  6. LOG DE CONSULTAS LENTAS  (2026-09-03)
-- ═══════════════════════════════════════════════════════════════════════════
--  Éste es el punto que va al revés del 1, y la diferencia entre los dos es la
--  que decide qué se habilita en una cama de pruebas.
--
--  MySQL y MariaDB escriben las consultas lentas en una TABLA —`mysql.slow_log`,
--  con `log_output=TABLE`— y un cliente la lee con un SELECT. Postgres no tiene
--  nada parecido: `log_min_duration_statement` escribe al log del servidor, que
--  es un archivo del sistema de archivos del servidor, y ningún cliente lo lee.
--  No es una diferencia de sintaxis: es que el dato no está donde Calíope puede
--  ir a buscarlo.
--
--  Lo consultable es `pg_stat_statements`, y no es lo mismo. La tabla de MySQL
--  guarda EJECUCIONES —una fila por consulta lenta, con su hora, su usuario y su
--  texto literal—; la vista de Postgres guarda AGREGADOS por sentencia
--  normalizada: una fila por `queryid`, con `calls`, `total_exec_time`,
--  `mean_exec_time` y `max_exec_time`, y sin hora de ninguna ejecución concreta.
--  Así que la herramienta no puede enseñar lo mismo en los dos motores, y eso es
--  una capacidad declarada, no un `if`.
--
--  Y sí se habilita aquí, al contrario que `pg_cron` del punto 1. La regla que
--  separa los dos casos no es «es una extensión» sino **qué trae puesto el
--  Postgres de un usuario cualquiera**: `pg_stat_statements` es un módulo
--  *contrib*, viaja con el propio Postgres —incluido RDS, Aurora, Cloud SQL y
--  Azure— y sólo hay que precargarlo y crear la extensión; `pg_cron` es de
--  terceros y hay que instalarlo en el servidor. Habilitar el primero hace que
--  la cama de pruebas se parezca a lo que el usuario tiene; habilitar el segundo
--  haría que la herramienta de eventos pareciera funcionar donde no funciona.
--
--  Lo que hay puesto en el testbed, en el `command:` de los tres contenedores:
--  `shared_preload_libraries=pg_stat_statements`, `pg_stat_statements.track=all`
--  y `log_min_duration_statement=250`. La extensión se crea por base, y aquí
--  está en `demo`.

DO $$
BEGIN
    RAISE NOTICE '[caliope] 60-sin-equivalente.sql: inventario, no crea nada. Léelo antes de escribir el provider.';
END;
$$;
PG_SEED_60_SIN_EQUIVALENTE_EOF

  cat > "${PG_SEED_DIR}/70-seed-demo.sql" <<'PG_SEED_70_SEED_DEMO_EOF'
-- 70-seed-demo.sql — Postgres
--
-- Las mismas filas que `seed/70-seed-demo.sql`, con los mismos ids, para que el
-- recuento por tabla coincida entre los dos motores y la comparación signifique
-- algo.
--
--   INSERT IGNORE      → ON CONFLICT DO NOTHING.
--   ST_PointFromText   → ST_GeomFromText, que es el nombre en PostGIS.
--   JSON_OBJECT        → jsonb_build_object.
--   1 / 0 en `activa`  → true / false: aquí la columna es booleana de verdad.

SET search_path = demo, public;

INSERT INTO productos (id, nombre, categoria, precio, stock) VALUES
  (1, 'Widget A',           'accesorios',    9.99, 100),
  (2, 'Widget B',           'accesorios',   19.99,  50),
  (3, 'Gadget Pro',         'electronica',  49.99,  25),
  (4, 'Cable USB-C',        'cables',        4.99, 200),
  (5, 'Adaptador HDMI',     'cables',       14.99,  75),
  (6, 'Teclado mecánico',   'periféricos',  89.99,  30),
  (7, 'Ratón inalámbrico',  'periféricos',  34.99,  60),
  (8, 'Monitor 24"',        'pantallas',   249.99,  10)
ON CONFLICT DO NOTHING;

INSERT INTO clientes (id, nombre, email, ciudad) VALUES
  (1, 'Ana García',    'ana@ejemplo.com',    'Madrid'),
  (2, 'Luis Mora',     'luis@ejemplo.com',   'San José'),
  (3, 'María Castro',  'maria@ejemplo.com',  'Ciudad de México'),
  (4, 'Carlos Ruiz',   'carlos@ejemplo.com', 'Buenos Aires')
ON CONFLICT DO NOTHING;

INSERT INTO pedidos (id, cliente_id, producto_id, cantidad, total, estado) VALUES
  (1, 1, 1, 5,  49.95, 'entregado'),
  (2, 2, 3, 2,  99.98, 'enviado'),
  (3, 3, 2, 1,  19.99, 'pendiente'),
  (4, 4, 5, 3,  44.97, 'entregado'),
  (5, 1, 6, 1,  89.99, 'enviado'),
  (6, 2, 7, 2,  69.98, 'pendiente')
ON CONFLICT DO NOTHING;

INSERT INTO categorias (id, slug, nombre, descripcion, padre_id, activa) VALUES
  (1, 'electronica',  'Electrónica',    'Dispositivos electrónicos varios', NULL, true),
  (2, 'cables',       'Cables',         'Cables y conectores', 1, true),
  (3, 'perifericos',  'Periféricos',    'Teclados, ratones, etc.', 1, true),
  (4, 'pantallas',    'Pantallas',      'Monitores y proyectores', 1, true),
  (5, 'accesorios',   'Accesorios',     'Complementos generales', NULL, true),
  (6, 'descatalogado','Descatalogado',  'Categoría obsoleta', NULL, false)
ON CONFLICT DO NOTHING;

INSERT INTO direcciones (id, cliente_id, etiqueta, linea1, ciudad, pais, metadatos, ubicacion) VALUES
  (1, 1, 'principal', 'Calle Mayor 12',      'Madrid',           'ES',
     jsonb_build_object('piso', 3, 'puerta', 'B'),  ST_GeomFromText('POINT(-3.7038 40.4168)')),
  (2, 2, 'principal', 'Av. Central 88',      'San José',         'CR',
     jsonb_build_object('cerca', 'ICE'),            ST_GeomFromText('POINT(-84.0907 9.9281)')),
  (3, 3, 'trabajo',   'Reforma 500 piso 12', 'Ciudad de México', 'MX',
     jsonb_build_object('oficina', 1204),           ST_GeomFromText('POINT(-99.1332 19.4326)')),
  (4, 4, 'principal', 'Av. Corrientes 1200', 'Buenos Aires',     'AR',
     jsonb_build_object('notas', 'timbre 4B'),      ST_GeomFromText('POINT(-58.3816 -34.6037)'))
ON CONFLICT DO NOTHING;

INSERT INTO pedido_lineas (id, pedido_id, producto_id, cantidad, precio_unit, descuento_pct) VALUES
  (1, 1, 1, 5,   9.99, 0.00),
  (2, 2, 3, 2,  49.99, 5.00),
  (3, 3, 2, 1,  19.99, 0.00),
  (4, 4, 5, 3,  14.99, 0.00),
  (5, 5, 6, 1,  89.99, 10.00),
  (6, 6, 7, 2,  34.99, 0.00)
ON CONFLICT DO NOTHING;

-- Las secuencias de identidad **no** avanzan cuando el id se da explícito, y
-- aquí se da en todas las tablas. Sin esto la cama de pruebas nace rota de una
-- forma especialmente mala: el seed queda perfecto, los recuentos cuadran, y el
-- primer INSERT que haga alguien —o el propio `sp_registrar_pedido`— muere con
-- «duplicate key value violates unique constraint». En MySQL no hace falta:
-- AUTO_INCREMENT se recoloca solo al insertar un id mayor.
SELECT setval(pg_get_serial_sequence('demo.productos','id'),     COALESCE((SELECT MAX(id) FROM productos), 1));
SELECT setval(pg_get_serial_sequence('demo.clientes','id'),      COALESCE((SELECT MAX(id) FROM clientes), 1));
SELECT setval(pg_get_serial_sequence('demo.pedidos','id'),       COALESCE((SELECT MAX(id) FROM pedidos), 1));
SELECT setval(pg_get_serial_sequence('demo.categorias','id'),    COALESCE((SELECT MAX(id) FROM categorias), 1));
SELECT setval(pg_get_serial_sequence('demo.direcciones','id'),   COALESCE((SELECT MAX(id) FROM direcciones), 1));
SELECT setval(pg_get_serial_sequence('demo.pedido_lineas','id'), COALESCE((SELECT MAX(id) FROM pedido_lineas), 1));
SELECT setval(pg_get_serial_sequence('demo.log_auditoria','id'), COALESCE((SELECT MAX(id) FROM log_auditoria), 1));
PG_SEED_70_SEED_DEMO_EOF

  cat > "${PG_SEED_DIR}/71-seed-rrhh.sql" <<'PG_SEED_71_SEED_RRHH_EOF'
-- 71-seed-rrhh.sql — Postgres
--
-- Va en la base `rrhh`. El único cambio de forma es `permisos`: en MySQL es un
-- SET y se escribe 'lectura,escritura'; aquí es un array de texto y se escribe
-- '{lectura,escritura}'.

\connect rrhh
SET search_path = rrhh, public;

INSERT INTO departamentos (id, codigo, nombre, presupuesto) VALUES
  (1, 'DIR', 'Dirección',       250000.00),
  (2, 'ING', 'Ingeniería',      450000.00),
  (3, 'COM', 'Comercial',       180000.00),
  (4, 'RRH', 'Recursos Humanos', 90000.00)
ON CONFLICT DO NOTHING;

INSERT INTO empleados (id, nombre, email, departamento_id, jefe_id, fecha_ingreso, salario_base, estado, permisos, notas) VALUES
  (1, 'Ana García',      'ana.g@empresa.test',    1, NULL, '2018-01-15',  8500.00, 'activo',      '{lectura,escritura,aprobacion,admin}', 'CEO'),
  (2, 'Luis Mora',       'luis.m@empresa.test',   2, 1,    '2019-03-22',  6200.00, 'activo',      '{lectura,escritura,aprobacion}',       'Director de Ingeniería'),
  (3, 'María Castro',    'maria.c@empresa.test',  3, 1,    '2019-07-10',  5800.00, 'activo',      '{lectura,escritura,aprobacion}',       'Directora Comercial'),
  (4, 'Carlos Ruiz',     'carlos.r@empresa.test', 2, 2,    '2020-05-04',  4200.00, 'activo',      '{lectura,escritura}',                  'Backend'),
  (5, 'Sofía López',     'sofia.l@empresa.test',  2, 2,    '2021-09-18',  3800.00, 'vacaciones',  '{lectura,escritura}',                  'Frontend'),
  (6, 'Diego Fernández', 'diego.f@empresa.test',  3, 3,    '2022-02-14',  3200.00, 'baja_medica', '{lectura}',                            'Ventas región norte'),
  (7, 'Elena Vega',      'elena.v@empresa.test',  4, 1,    '2020-11-30',  4500.00, 'activo',      '{lectura,escritura,aprobacion}',       'Responsable de RRHH'),
  (8, 'Marco Antonelli', 'marco.a@empresa.test',  2, 2,    '2023-06-01',  3600.00, 'despedido',   '{lectura}',                            'Baja disciplinaria')
ON CONFLICT DO NOTHING;

INSERT INTO nominas (id, empleado_id, periodo, bruto, impuestos, estado, emitida_en) VALUES
  (1, 1, '2026-05', 8500.00, 2380.00, 'pagada',   '2026-05-31 18:00:00'),
  (2, 2, '2026-05', 6200.00, 1550.00, 'pagada',   '2026-05-31 18:05:00'),
  (3, 3, '2026-05', 5800.00, 1450.00, 'pagada',   '2026-05-31 18:10:00'),
  (4, 4, '2026-05', 4200.00, 1050.00, 'emitida',  '2026-05-31 18:15:00'),
  (5, 5, '2026-05', 3800.00,  950.00, 'emitida',  '2026-05-31 18:20:00'),
  (6, 7, '2026-05', 4500.00, 1125.00, 'borrador', NULL)
ON CONFLICT DO NOTHING;

-- Las secuencias de identidad no se mueven cuando el id se da explícito, así
-- que sin esto el primer INSERT sin id chocaría contra la clave primaria. Es un
-- paso que en MySQL no hace falta: allí AUTO_INCREMENT se recoloca solo.
SELECT setval(pg_get_serial_sequence('rrhh.departamentos','id'), COALESCE((SELECT MAX(id) FROM departamentos), 1));
SELECT setval(pg_get_serial_sequence('rrhh.empleados','id'),     COALESCE((SELECT MAX(id) FROM empleados), 1));
SELECT setval(pg_get_serial_sequence('rrhh.nominas','id'),       COALESCE((SELECT MAX(id) FROM nominas), 1));

\connect demo
PG_SEED_71_SEED_RRHH_EOF

  cat > "${PG_SEED_DIR}/72-seed-analytics.sql" <<'PG_SEED_72_SEED_ANALYTICS_EOF'
-- 72-seed-analytics.sql — Postgres
--
--   INET6_ATON('192.168.1.10') → '192.168.1.10'::inet. El tipo `inet` guarda
--   IPv4 e IPv6 en la misma columna sin convertir a binario a mano, así que la
--   función no tiene equivalente porque no hace falta.

SET search_path = analytics, public;

INSERT INTO sesiones_activas (session_id, usuario, ip) VALUES
  ('11111111-1111-1111-1111-111111111111', 'ana',    '192.168.1.10'),
  ('22222222-2222-2222-2222-222222222222', 'luis',   '192.168.1.11'),
  ('33333333-3333-3333-3333-333333333333', 'maria',  '2001:db8::1'),
  ('44444444-4444-4444-4444-444444444444', 'carlos', '10.0.0.42')
ON CONFLICT DO NOTHING;

INSERT INTO logs_ligeros (nivel, origen, mensaje) VALUES
  ('info',  'auth',      'usuario ana inició sesión desde 192.168.1.10'),
  ('warn',  'billing',   'intento de cargo rechazado por saldo insuficiente'),
  ('error', 'jobs',      'fallo al procesar job #4821 — timeout tras 30s'),
  ('info',  'auth',      'usuario luis cerró sesión'),
  ('debug', 'scheduler', 'ev_snapshot_metricas programado para 2026-07-03 09:00'),
  ('info',  'catalog',   'nuevo producto catalogado en la sección electrónica'),
  ('warn',  'sync',      'retraso de replicación detectado en replica2 (5s)'),
  ('info',  'backup',    'backup completo generado — 128 MB comprimidos')
ON CONFLICT DO NOTHING;

INSERT INTO metricas_diarias (fecha, metrica, valor, muestras) VALUES
  ('2025-06-15', 'productos_activos',  120.0000, 24),
  ('2025-09-20', 'clientes_activos',    85.0000, 24),
  ('2025-12-31', 'pedidos_por_dia',    142.5000, 24),
  ('2026-01-10', 'productos_activos',  138.0000, 24),
  ('2026-03-15', 'clientes_activos',   102.0000, 24),
  ('2026-06-30', 'pedidos_por_dia',    167.2500, 24)
ON CONFLICT DO NOTHING;

SELECT setval(pg_get_serial_sequence('analytics.logs_ligeros','id'), COALESCE((SELECT MAX(id) FROM logs_ligeros), 1));
PG_SEED_72_SEED_ANALYTICS_EOF

  cat > "${PG_SEED_DIR}/73-seed-gis.sql" <<'PG_SEED_73_SEED_GIS_EOF'
-- 73-seed-gis.sql — Postgres
--
-- Mismas filas que `seed/73-seed-gis.sql`, SRID 0 y X=longitud. Aquí no hace
-- falta intercambiar nada: el original ya estaba en la convención que PostGIS
-- usa siempre. Los que sí hubo que intercambiar son los del nodo GIS dedicado
-- (74-seed-gis-node.sql).

SET search_path = caliope_gis_test, public;

INSERT INTO spatial_points (id, name, category, location) VALUES
  (1, 'Madrid',           'capital', ST_GeomFromText('POINT(-3.7038 40.4168)')),
  (2, 'Barcelona',        'ciudad',  ST_GeomFromText('POINT(2.1686 41.3874)')),
  (3, 'Lisboa',           'capital', ST_GeomFromText('POINT(-9.1393 38.7223)')),
  (4, 'París',            'capital', ST_GeomFromText('POINT(2.3522 48.8566)')),
  (5, 'Londres',          'capital', ST_GeomFromText('POINT(-0.1278 51.5074)')),
  (6, 'Berlín',           'capital', ST_GeomFromText('POINT(13.4050 52.5200)')),
  (7, 'Roma',             'capital', ST_GeomFromText('POINT(12.4964 41.9028)')),
  (8, 'San José',         'capital', ST_GeomFromText('POINT(-84.0907 9.9281)')),
  (9, 'Ciudad de México', 'capital', ST_GeomFromText('POINT(-99.1332 19.4326)')),
  (10,'Buenos Aires',     'capital', ST_GeomFromText('POINT(-58.3816 -34.6037)')),
  (11,'Nueva York',       'ciudad',  ST_GeomFromText('POINT(-74.0060 40.7128)')),
  (12,'São Paulo',        'ciudad',  ST_GeomFromText('POINT(-46.6333 -23.5505)')),
  (13,'Tokio',            'capital', ST_GeomFromText('POINT(139.6503 35.6762)')),
  (14,'Sídney',           'ciudad',  ST_GeomFromText('POINT(151.2093 -33.8688)')),
  (15,'Ciudad del Cabo',  'capital', ST_GeomFromText('POINT(18.4241 -33.9249)')),
  (16,'Puerto de Limón',  'puerto',  ST_GeomFromText('POINT(-83.0359 9.9907)')),
  (17,'Puerto de Algeciras','puerto',ST_GeomFromText('POINT(-5.4467 36.1408)')),
  (18,'Puerto de Róterdam','puerto', ST_GeomFromText('POINT(4.4777 51.9244)')),
  (19,'Puerto de Singapur','puerto', ST_GeomFromText('POINT(103.8198 1.3521)')),
  (20,'Suva',             'capital', ST_GeomFromText('POINT(178.4419 -18.1416)'))
ON CONFLICT DO NOTHING;

INSERT INTO spatial_regions (id, region_name, boundary) VALUES
  (1, 'Península ibérica', ST_GeomFromText('POLYGON((-9.5 43.8, 3.4 43.8, 3.4 36.0, -9.5 36.0, -9.5 43.8))')),
  (2, 'Costa Rica',        ST_GeomFromText('POLYGON((-85.95 11.22, -82.55 11.22, -82.55 8.03, -85.95 8.03, -85.95 11.22))')),
  (3, 'Gran San José',     ST_GeomFromText('POLYGON((-84.30 10.05, -83.90 10.05, -83.90 9.80, -84.30 9.80, -84.30 10.05))')),
  (4, 'Centro de Madrid',  ST_GeomFromText('POLYGON((-3.73 40.44, -3.66 40.44, -3.66 40.39, -3.73 40.39, -3.73 40.44))')),
  (5, 'Manhattan',         ST_GeomFromText('POLYGON((-74.02 40.88, -73.91 40.88, -73.91 40.70, -74.02 40.70, -74.02 40.88))')),
  (6, 'Anillo con agujero',ST_GeomFromText('POLYGON((-3.90 40.60, -3.50 40.60, -3.50 40.25, -3.90 40.25, -3.90 40.60),(-3.80 40.50, -3.60 40.50, -3.60 40.35, -3.80 40.35, -3.80 40.50))'))
ON CONFLICT DO NOTHING;

INSERT INTO spatial_routes (id, route_name, path) VALUES
  (1, 'Madrid → Barcelona',      ST_GeomFromText('LINESTRING(-3.7038 40.4168, -1.1000 41.1500, 1.0000 41.6200, 2.1686 41.3874)')),
  (2, 'San José → Limón',        ST_GeomFromText('LINESTRING(-84.0907 9.9281, -83.9000 9.9700, -83.6500 10.0800, -83.0359 9.9907)')),
  (3, 'Madrid → Nueva York',     ST_GeomFromText('LINESTRING(-3.7038 40.4168, -20.0 43.5, -35.0 45.0, -50.0 44.0, -74.0060 40.7128)')),
  (4, 'Canal de Panamá',         ST_GeomFromText('LINESTRING(-79.9020 9.3547, -79.8100 9.1800, -79.5600 8.9350)')),
  (5, 'Cruce del antimeridiano', ST_GeomFromText('LINESTRING(178.0 -16.5, 179.9 -17.8, -179.2 -18.3, -177.0 -19.5)'))
ON CONFLICT DO NOTHING;

-- Los casos que un índice espacial no puede sostener: el NULL y los tipos
-- heterogéneos.
INSERT INTO spatial_mixed (id, label, note, shape) VALUES
  (1, 'Punto suelto',      'POINT normal',                          ST_GeomFromText('POINT(-84.0907 9.9281)')),
  (2, 'Sin geometría',     'NULL: debe contarse y decirse',         NULL),
  (3, 'Otra sin geometría','Segundo NULL, para que el recuento sea plural', NULL),
  (4, 'Boyas',             'MULTIPOINT',                            ST_GeomFromText('MULTIPOINT((-84.80 9.50),(-84.60 9.20),(-84.20 8.90))')),
  (5, 'Red fluvial',       'MULTILINESTRING',                       ST_GeomFromText('MULTILINESTRING((-84.90 10.40, -84.60 10.20),(-84.50 10.55, -84.30 10.05))')),
  (6, 'Archipiélago',      'MULTIPOLYGON de dos islas',             ST_GeomFromText('MULTIPOLYGON(((-17.90 28.60, -17.70 28.60, -17.70 28.40, -17.90 28.40, -17.90 28.60)),((-16.30 28.60, -16.10 28.60, -16.10 28.00, -16.30 28.00, -16.30 28.60)))')),
  (7, 'Estación completa', 'GEOMETRYCOLLECTION de tres tipos',      ST_GeomFromText('GEOMETRYCOLLECTION(POINT(-84.0907 9.9281),LINESTRING(-84.0907 9.9281, -84.0500 9.9500),POLYGON((-84.12 9.95, -84.06 9.95, -84.06 9.90, -84.12 9.90, -84.12 9.95)))')),
  (8, 'Polo norte',        'POINT en latitud 90',                   ST_GeomFromText('POINT(0 90)')),
  (9, 'Punto nulo',        'POINT en (0,0), donde acaban las conversiones mal hechas', ST_GeomFromText('POINT(0 0)')),
  (10,'Precisión larga',   'Quince decimales, para comprobar el ida y vuelta', ST_GeomFromText('POINT(-84.090725000000000 9.928069444444444)'))
ON CONFLICT DO NOTHING;

SELECT setval(pg_get_serial_sequence('caliope_gis_test.spatial_points','id'),  COALESCE((SELECT MAX(id) FROM spatial_points), 1));
SELECT setval(pg_get_serial_sequence('caliope_gis_test.spatial_regions','id'), COALESCE((SELECT MAX(id) FROM spatial_regions), 1));
SELECT setval(pg_get_serial_sequence('caliope_gis_test.spatial_routes','id'),  COALESCE((SELECT MAX(id) FROM spatial_routes), 1));
SELECT setval(pg_get_serial_sequence('caliope_gis_test.spatial_mixed','id'),   COALESCE((SELECT MAX(id) FROM spatial_mixed), 1));
PG_SEED_73_SEED_GIS_EOF

  cat > "${PG_SEED_DIR}/74-seed-gis-node.sql" <<'PG_SEED_74_SEED_GIS_NODE_EOF'
-- 74-seed-gis-node.sql — Postgres
--
-- Los datos del nodo GIS dedicado, CON LOS EJES INTERCAMBIADOS respecto al seed
-- de MySQL. El porqué, entero, está en la cabecera de 24-tables-gis-node.sql;
-- en una línea: MySQL 8 guarda estos puntos como (latitud, longitud) porque
-- respeta lo que declara EPSG:4326, y PostGIS es siempre (longitud, latitud).
-- Copiados tal cual, Madrid caería en Somalia y el servidor no se quejaría.
--
-- El intercambio se hizo por transformación sobre el archivo de MySQL, no a
-- mano: son 228 ciudades, 22 rutas, 28 regiones y 20 zonas, y teclearlas otra
-- vez es garantizar que alguna acabe en otro continente.
--
-- `es_capital` pasa de TINYINT(1) a boolean, así que los 0 y 1 son false y true.

SET search_path = gis, public;

INSERT INTO ciudades (nombre, pais, continente, poblacion, es_capital, ubicacion) VALUES
  ('Madrid','ES','Europa',6700000,true,ST_GeomFromText('POINT(-3.7038 40.4168)',4326)),
  ('Barcelona','ES','Europa',5600000,false,ST_GeomFromText('POINT(2.1686 41.3874)',4326)),
  ('Valencia','ES','Europa',1600000,false,ST_GeomFromText('POINT(-0.3763 39.4699)',4326)),
  ('Sevilla','ES','Europa',1500000,false,ST_GeomFromText('POINT(-5.9845 37.3891)',4326)),
  ('Bilbao','ES','Europa',1000000,false,ST_GeomFromText('POINT(-2.9350 43.2630)',4326)),
  ('Lisboa','PT','Europa',2900000,true,ST_GeomFromText('POINT(-9.1393 38.7223)',4326)),
  ('Oporto','PT','Europa',1700000,false,ST_GeomFromText('POINT(-8.6291 41.1579)',4326)),
  ('París','FR','Europa',11100000,true,ST_GeomFromText('POINT(2.3522 48.8566)',4326)),
  ('Marsella','FR','Europa',1600000,false,ST_GeomFromText('POINT(5.3698 43.2965)',4326)),
  ('Londres','GB','Europa',9600000,true,ST_GeomFromText('POINT(-0.1278 51.5074)',4326)),
  ('Mánchester','GB','Europa',2800000,false,ST_GeomFromText('POINT(-2.2426 53.4808)',4326)),
  ('Edimburgo','GB','Europa',900000,false,ST_GeomFromText('POINT(-3.1883 55.9533)',4326)),
  ('Dublín','IE','Europa',1400000,true,ST_GeomFromText('POINT(-6.2603 53.3498)',4326)),
  ('Berlín','DE','Europa',4500000,true,ST_GeomFromText('POINT(13.4050 52.5200)',4326)),
  ('Múnich','DE','Europa',2600000,false,ST_GeomFromText('POINT(11.5820 48.1351)',4326)),
  ('Hamburgo','DE','Europa',2500000,false,ST_GeomFromText('POINT(9.9937 53.5511)',4326)),
  ('Colonia','DE','Europa',2100000,false,ST_GeomFromText('POINT(6.9603 50.9375)',4326)),
  ('Ámsterdam','NL','Europa',2500000,true,ST_GeomFromText('POINT(4.9041 52.3676)',4326)),
  ('Róterdam','NL','Europa',1200000,false,ST_GeomFromText('POINT(4.4777 51.9244)',4326)),
  ('Bruselas','BE','Europa',2100000,true,ST_GeomFromText('POINT(4.3517 50.8503)',4326)),
  ('Zúrich','CH','Europa',1400000,false,ST_GeomFromText('POINT(8.5417 47.3769)',4326)),
  ('Ginebra','CH','Europa',600000,false,ST_GeomFromText('POINT(6.1432 46.2044)',4326)),
  ('Viena','AT','Europa',2000000,true,ST_GeomFromText('POINT(16.3738 48.2082)',4326)),
  ('Roma','IT','Europa',4300000,true,ST_GeomFromText('POINT(12.4964 41.9028)',4326)),
  ('Milán','IT','Europa',3200000,false,ST_GeomFromText('POINT(9.1900 45.4642)',4326)),
  ('Nápoles','IT','Europa',3100000,false,ST_GeomFromText('POINT(14.2681 40.8518)',4326)),
  ('Venecia','IT','Europa',600000,false,ST_GeomFromText('POINT(12.3155 45.4408)',4326)),
  ('Praga','CZ','Europa',1300000,true,ST_GeomFromText('POINT(14.4378 50.0755)',4326)),
  ('Varsovia','PL','Europa',3100000,true,ST_GeomFromText('POINT(21.0122 52.2297)',4326)),
  ('Cracovia','PL','Europa',1000000,false,ST_GeomFromText('POINT(19.9450 50.0647)',4326)),
  ('Budapest','HU','Europa',1800000,true,ST_GeomFromText('POINT(19.0402 47.4979)',4326)),
  ('Bucarest','RO','Europa',2200000,true,ST_GeomFromText('POINT(26.1025 44.4268)',4326)),
  ('Sofía','BG','Europa',1300000,true,ST_GeomFromText('POINT(23.3219 42.6977)',4326)),
  ('Belgrado','RS','Europa',1700000,true,ST_GeomFromText('POINT(20.4489 44.7866)',4326)),
  ('Zagreb','HR','Europa',800000,true,ST_GeomFromText('POINT(15.9819 45.8150)',4326)),
  ('Atenas','GR','Europa',3200000,true,ST_GeomFromText('POINT(23.7275 37.9838)',4326)),
  ('Estocolmo','SE','Europa',2400000,true,ST_GeomFromText('POINT(18.0686 59.3293)',4326)),
  ('Oslo','NO','Europa',1100000,true,ST_GeomFromText('POINT(10.7522 59.9139)',4326)),
  ('Copenhague','DK','Europa',2100000,true,ST_GeomFromText('POINT(12.5683 55.6761)',4326)),
  ('Helsinki','FI','Europa',1300000,true,ST_GeomFromText('POINT(24.9384 60.1699)',4326)),
  ('Reikiavik','IS','Europa',230000,true,ST_GeomFromText('POINT(-21.9426 64.1466)',4326)),
  ('Estambul','TR','Europa',15500000,false,ST_GeomFromText('POINT(28.9784 41.0082)',4326)),
  ('Moscú','RU','Europa',12600000,true,ST_GeomFromText('POINT(37.6173 55.7558)',4326)),
  ('San Petersburgo','RU','Europa',5400000,false,ST_GeomFromText('POINT(30.3609 59.9311)',4326)),
  ('Kiev','UA','Europa',3000000,true,ST_GeomFromText('POINT(30.5234 50.4501)',4326)),
  ('Longyearbyen','NO','Europa',2400,false,ST_GeomFromText('POINT(15.6267 78.2232)',4326)),
  ('Nueva York','US','América del Norte',18800000,false,ST_GeomFromText('POINT(-74.0060 40.7128)',4326)),
  ('Los Ángeles','US','América del Norte',12500000,false,ST_GeomFromText('POINT(-118.2437 34.0522)',4326)),
  ('Chicago','US','América del Norte',8900000,false,ST_GeomFromText('POINT(-87.6298 41.8781)',4326)),
  ('Houston','US','América del Norte',7100000,false,ST_GeomFromText('POINT(-95.3698 29.7604)',4326)),
  ('Phoenix','US','América del Norte',4900000,false,ST_GeomFromText('POINT(-112.0740 33.4484)',4326)),
  ('Filadelfia','US','América del Norte',6200000,false,ST_GeomFromText('POINT(-75.1652 39.9526)',4326)),
  ('San Antonio','US','América del Norte',2600000,false,ST_GeomFromText('POINT(-98.4936 29.4241)',4326)),
  ('San Diego','US','América del Norte',3300000,false,ST_GeomFromText('POINT(-117.1611 32.7157)',4326)),
  ('Dallas','US','América del Norte',7600000,false,ST_GeomFromText('POINT(-96.7970 32.7767)',4326)),
  ('San Francisco','US','América del Norte',4700000,false,ST_GeomFromText('POINT(-122.4194 37.7749)',4326)),
  ('Seattle','US','América del Norte',4000000,false,ST_GeomFromText('POINT(-122.3321 47.6062)',4326)),
  ('Denver','US','América del Norte',2900000,false,ST_GeomFromText('POINT(-104.9903 39.7392)',4326)),
  ('Boston','US','América del Norte',4900000,false,ST_GeomFromText('POINT(-71.0589 42.3601)',4326)),
  ('Miami','US','América del Norte',6100000,false,ST_GeomFromText('POINT(-80.1918 25.7617)',4326)),
  ('Atlanta','US','América del Norte',6100000,false,ST_GeomFromText('POINT(-84.3880 33.7490)',4326)),
  ('Washington','US','América del Norte',6400000,true,ST_GeomFromText('POINT(-77.0369 38.9072)',4326)),
  ('Nueva Orleans','US','América del Norte',1300000,false,ST_GeomFromText('POINT(-90.0715 29.9511)',4326)),
  ('Anchorage','US','América del Norte',290000,false,ST_GeomFromText('POINT(-149.9003 61.2181)',4326)),
  ('Honolulu','US','Oceanía',1000000,false,ST_GeomFromText('POINT(-157.8583 21.3069)',4326)),
  ('Utqiagvik','US','América del Norte',4400,false,ST_GeomFromText('POINT(-156.7886 71.2906)',4326)),
  ('Toronto','CA','América del Norte',6200000,false,ST_GeomFromText('POINT(-79.3832 43.6532)',4326)),
  ('Montreal','CA','América del Norte',4300000,false,ST_GeomFromText('POINT(-73.5673 45.5017)',4326)),
  ('Vancouver','CA','América del Norte',2600000,false,ST_GeomFromText('POINT(-123.1207 49.2827)',4326)),
  ('Ottawa','CA','América del Norte',1500000,true,ST_GeomFromText('POINT(-75.6972 45.4215)',4326)),
  ('Ciudad de México','MX','América del Norte',21800000,true,ST_GeomFromText('POINT(-99.1332 19.4326)',4326)),
  ('Guadalajara','MX','América del Norte',5300000,false,ST_GeomFromText('POINT(-103.3496 20.6597)',4326)),
  ('Monterrey','MX','América del Norte',5300000,false,ST_GeomFromText('POINT(-100.3161 25.6866)',4326)),
  ('Cancún','MX','América del Norte',900000,false,ST_GeomFromText('POINT(-86.8515 21.1619)',4326)),
  ('San José','CR','América Central',1400000,true,ST_GeomFromText('POINT(-84.0907 9.9281)',4326)),
  ('Limón','CR','América Central',60000,false,ST_GeomFromText('POINT(-83.0359 9.9907)',4326)),
  ('Liberia','CR','América Central',70000,false,ST_GeomFromText('POINT(-85.4377 10.6346)',4326)),
  ('Ciudad de Panamá','PA','América Central',1900000,true,ST_GeomFromText('POINT(-79.5199 8.9824)',4326)),
  ('Ciudad de Guatemala','GT','América Central',3000000,true,ST_GeomFromText('POINT(-90.5069 14.6349)',4326)),
  ('San Salvador','SV','América Central',1800000,true,ST_GeomFromText('POINT(-89.2182 13.6929)',4326)),
  ('Tegucigalpa','HN','América Central',1400000,true,ST_GeomFromText('POINT(-87.1921 14.0723)',4326)),
  ('Managua','NI','América Central',1400000,true,ST_GeomFromText('POINT(-86.2362 12.1150)',4326)),
  ('La Habana','CU','América Central',2100000,true,ST_GeomFromText('POINT(-82.3666 23.1136)',4326)),
  ('Santo Domingo','DO','América Central',3500000,true,ST_GeomFromText('POINT(-69.9312 18.4861)',4326)),
  ('San Juan','PR','América Central',2400000,false,ST_GeomFromText('POINT(-66.1057 18.4655)',4326)),
  ('Kingston','JM','América Central',1200000,true,ST_GeomFromText('POINT(-76.7931 17.9714)',4326)),
  ('Bogotá','CO','América del Sur',11000000,true,ST_GeomFromText('POINT(-74.0721 4.7110)',4326)),
  ('Medellín','CO','América del Sur',4000000,false,ST_GeomFromText('POINT(-75.5812 6.2442)',4326)),
  ('Cartagena','CO','América del Sur',1000000,false,ST_GeomFromText('POINT(-75.4794 10.3910)',4326)),
  ('Caracas','VE','América del Sur',2900000,true,ST_GeomFromText('POINT(-66.9036 10.4806)',4326)),
  ('Quito','EC','América del Sur',2000000,true,ST_GeomFromText('POINT(-78.4678 -0.1807)',4326)),
  ('Guayaquil','EC','América del Sur',2700000,false,ST_GeomFromText('POINT(-79.8891 -2.1894)',4326)),
  ('Lima','PE','América del Sur',10700000,true,ST_GeomFromText('POINT(-77.0428 -12.0464)',4326)),
  ('Cusco','PE','América del Sur',430000,false,ST_GeomFromText('POINT(-71.9675 -13.5320)',4326)),
  ('La Paz','BO','América del Sur',1900000,true,ST_GeomFromText('POINT(-68.1193 -16.4897)',4326)),
  ('Santiago','CL','América del Sur',7100000,true,ST_GeomFromText('POINT(-70.6693 -33.4489)',4326)),
  ('Valparaíso','CL','América del Sur',1000000,false,ST_GeomFromText('POINT(-71.6127 -33.0472)',4326)),
  ('Punta Arenas','CL','América del Sur',130000,false,ST_GeomFromText('POINT(-70.9171 -53.1638)',4326)),
  ('Buenos Aires','AR','América del Sur',15400000,true,ST_GeomFromText('POINT(-58.3816 -34.6037)',4326)),
  ('Córdoba','AR','América del Sur',1600000,false,ST_GeomFromText('POINT(-64.1888 -31.4201)',4326)),
  ('Mendoza','AR','América del Sur',1100000,false,ST_GeomFromText('POINT(-68.8458 -32.8895)',4326)),
  ('Ushuaia','AR','América del Sur',82000,false,ST_GeomFromText('POINT(-68.3030 -54.8019)',4326)),
  ('Montevideo','UY','América del Sur',1800000,true,ST_GeomFromText('POINT(-56.1645 -34.9011)',4326)),
  ('Asunción','PY','América del Sur',2300000,true,ST_GeomFromText('POINT(-57.5759 -25.2637)',4326)),
  ('São Paulo','BR','América del Sur',22400000,false,ST_GeomFromText('POINT(-46.6333 -23.5505)',4326)),
  ('Río de Janeiro','BR','América del Sur',13500000,false,ST_GeomFromText('POINT(-43.1729 -22.9068)',4326)),
  ('Brasilia','BR','América del Sur',4800000,true,ST_GeomFromText('POINT(-47.8919 -15.7975)',4326)),
  ('Salvador','BR','América del Sur',3900000,false,ST_GeomFromText('POINT(-38.5016 -12.9777)',4326)),
  ('Fortaleza','BR','América del Sur',4100000,false,ST_GeomFromText('POINT(-38.5267 -3.7319)',4326)),
  ('Manaos','BR','América del Sur',2200000,false,ST_GeomFromText('POINT(-60.0217 -3.1190)',4326)),
  ('El Cairo','EG','África',21300000,true,ST_GeomFromText('POINT(31.2357 30.0444)',4326)),
  ('Alejandría','EG','África',5400000,false,ST_GeomFromText('POINT(29.9187 31.2001)',4326)),
  ('Lagos','NG','África',15400000,false,ST_GeomFromText('POINT(3.3792 6.5244)',4326)),
  ('Abuya','NG','África',3600000,true,ST_GeomFromText('POINT(7.3986 9.0765)',4326)),
  ('Kinsasa','CD','África',15600000,true,ST_GeomFromText('POINT(15.2663 -4.4419)',4326)),
  ('Johannesburgo','ZA','África',6000000,false,ST_GeomFromText('POINT(28.0473 -26.2041)',4326)),
  ('Ciudad del Cabo','ZA','África',4700000,true,ST_GeomFromText('POINT(18.4241 -33.9249)',4326)),
  ('Nairobi','KE','África',5100000,true,ST_GeomFromText('POINT(36.8219 -1.2921)',4326)),
  ('Adís Abeba','ET','África',5200000,true,ST_GeomFromText('POINT(38.7469 9.0320)',4326)),
  ('Casablanca','MA','África',3800000,false,ST_GeomFromText('POINT(-7.5898 33.5731)',4326)),
  ('Rabat','MA','África',1900000,true,ST_GeomFromText('POINT(-6.8416 34.0209)',4326)),
  ('Argel','DZ','África',3400000,true,ST_GeomFromText('POINT(3.0588 36.7538)',4326)),
  ('Túnez','TN','África',2400000,true,ST_GeomFromText('POINT(10.1815 36.8065)',4326)),
  ('Accra','GH','África',2600000,true,ST_GeomFromText('POINT(-0.1870 5.6037)',4326)),
  ('Dakar','SN','África',3100000,true,ST_GeomFromText('POINT(-17.4677 14.7167)',4326)),
  ('Luanda','AO','África',8300000,true,ST_GeomFromText('POINT(13.2894 -8.8390)',4326)),
  ('Jartum','SD','África',5800000,true,ST_GeomFromText('POINT(32.5599 15.5007)',4326)),
  ('Harare','ZW','África',1600000,true,ST_GeomFromText('POINT(31.0335 -17.8252)',4326)),
  ('Antananarivo','MG','África',1400000,true,ST_GeomFromText('POINT(47.5079 -18.8792)',4326)),
  ('Tokio','JP','Asia',37400000,true,ST_GeomFromText('POINT(139.6503 35.6762)',4326)),
  ('Osaka','JP','Asia',19000000,false,ST_GeomFromText('POINT(135.5023 34.6937)',4326)),
  ('Sapporo','JP','Asia',2700000,false,ST_GeomFromText('POINT(141.3545 43.0618)',4326)),
  ('Seúl','KR','Asia',25600000,true,ST_GeomFromText('POINT(126.9780 37.5665)',4326)),
  ('Pekín','CN','Asia',21500000,true,ST_GeomFromText('POINT(116.4074 39.9042)',4326)),
  ('Shanghái','CN','Asia',28500000,false,ST_GeomFromText('POINT(121.4737 31.2304)',4326)),
  ('Cantón','CN','Asia',18700000,false,ST_GeomFromText('POINT(113.2644 23.1291)',4326)),
  ('Shenzhen','CN','Asia',17500000,false,ST_GeomFromText('POINT(114.0579 22.5431)',4326)),
  ('Hong Kong','HK','Asia',7500000,false,ST_GeomFromText('POINT(114.1694 22.3193)',4326)),
  ('Taipéi','TW','Asia',7000000,true,ST_GeomFromText('POINT(121.5654 25.0330)',4326)),
  ('Manila','PH','Asia',14200000,true,ST_GeomFromText('POINT(120.9842 14.5995)',4326)),
  ('Yakarta','ID','Asia',34500000,true,ST_GeomFromText('POINT(106.8456 -6.2088)',4326)),
  ('Bangkok','TH','Asia',10700000,true,ST_GeomFromText('POINT(100.5018 13.7563)',4326)),
  ('Singapur','SG','Asia',5900000,true,ST_GeomFromText('POINT(103.8198 1.3521)',4326)),
  ('Kuala Lumpur','MY','Asia',8200000,true,ST_GeomFromText('POINT(101.6869 3.1390)',4326)),
  ('Hanói','VN','Asia',8100000,true,ST_GeomFromText('POINT(105.8542 21.0285)',4326)),
  ('Ciudad Ho Chi Minh','VN','Asia',9300000,false,ST_GeomFromText('POINT(106.6297 10.8231)',4326)),
  ('Delhi','IN','Asia',32900000,true,ST_GeomFromText('POINT(77.1025 28.7041)',4326)),
  ('Bombay','IN','Asia',21300000,false,ST_GeomFromText('POINT(72.8777 19.0760)',4326)),
  ('Bangalore','IN','Asia',13600000,false,ST_GeomFromText('POINT(77.5946 12.9716)',4326)),
  ('Calcuta','IN','Asia',15100000,false,ST_GeomFromText('POINT(88.3639 22.5726)',4326)),
  ('Chennai','IN','Asia',11500000,false,ST_GeomFromText('POINT(80.2707 13.0827)',4326)),
  ('Karachi','PK','Asia',16800000,false,ST_GeomFromText('POINT(67.0011 24.8607)',4326)),
  ('Lahore','PK','Asia',13500000,false,ST_GeomFromText('POINT(74.3587 31.5204)',4326)),
  ('Daca','BD','Asia',22500000,true,ST_GeomFromText('POINT(90.4125 23.8103)',4326)),
  ('Colombo','LK','Asia',800000,true,ST_GeomFromText('POINT(79.8612 6.9271)',4326)),
  ('Katmandú','NP','Asia',1500000,true,ST_GeomFromText('POINT(85.3240 27.7172)',4326)),
  ('Teherán','IR','Asia',9500000,true,ST_GeomFromText('POINT(51.3890 35.6892)',4326)),
  ('Bagdad','IQ','Asia',7500000,true,ST_GeomFromText('POINT(44.3661 33.3152)',4326)),
  ('Riad','SA','Asia',7700000,true,ST_GeomFromText('POINT(46.6753 24.7136)',4326)),
  ('Dubái','AE','Asia',3500000,false,ST_GeomFromText('POINT(55.2708 25.2048)',4326)),
  ('Doha','QA','Asia',2400000,true,ST_GeomFromText('POINT(51.5310 25.2854)',4326)),
  ('Kuwait','KW','Asia',3100000,true,ST_GeomFromText('POINT(47.9774 29.3759)',4326)),
  ('Ammán','JO','Asia',4300000,true,ST_GeomFromText('POINT(35.9284 31.9454)',4326)),
  ('Beirut','LB','Asia',2400000,true,ST_GeomFromText('POINT(35.5018 33.8938)',4326)),
  ('Taskent','UZ','Asia',2900000,true,ST_GeomFromText('POINT(69.2401 41.2995)',4326)),
  ('Almaty','KZ','Asia',2000000,false,ST_GeomFromText('POINT(76.8512 43.2220)',4326)),
  ('Novosibirsk','RU','Asia',1600000,false,ST_GeomFromText('POINT(82.9357 55.0084)',4326)),
  ('Vladivostok','RU','Asia',600000,false,ST_GeomFromText('POINT(131.9113 43.1332)',4326)),
  ('Anadyr','RU','Asia',13000,false,ST_GeomFromText('POINT(177.5083 64.7314)',4326)),
  ('Sídney','AU','Oceanía',5300000,false,ST_GeomFromText('POINT(151.2093 -33.8688)',4326)),
  ('Melbourne','AU','Oceanía',5100000,false,ST_GeomFromText('POINT(144.9631 -37.8136)',4326)),
  ('Brisbane','AU','Oceanía',2600000,false,ST_GeomFromText('POINT(153.0251 -27.4698)',4326)),
  ('Perth','AU','Oceanía',2100000,false,ST_GeomFromText('POINT(115.8605 -31.9505)',4326)),
  ('Canberra','AU','Oceanía',460000,true,ST_GeomFromText('POINT(149.1300 -35.2809)',4326)),
  ('Auckland','NZ','Oceanía',1700000,false,ST_GeomFromText('POINT(174.7633 -36.8485)',4326)),
  ('Wellington','NZ','Oceanía',420000,true,ST_GeomFromText('POINT(174.7762 -41.2865)',4326)),
  ('Suva','FJ','Oceanía',95000,true,ST_GeomFromText('POINT(178.4419 -18.1416)',4326)),
  ('Nukualofa','TO','Oceanía',23000,true,ST_GeomFromText('POINT(-175.2018 -21.1393)',4326)),
  ('Apia','WS','Oceanía',37000,true,ST_GeomFromText('POINT(-171.7513 -13.8506)',4326)),
  ('Numea','NC','Oceanía',94000,false,ST_GeomFromText('POINT(166.4572 -22.2758)',4326)),
  ('Port Moresby','PG','Oceanía',380000,true,ST_GeomFromText('POINT(147.1803 -9.4438)',4326)),
  ('Base Esperanza','AQ','Antártida',100,false,ST_GeomFromText('POINT(-56.9967 -63.3975)',4326)),
  ('Base McMurdo','AQ','Antártida',1200,false,ST_GeomFromText('POINT(166.6863 -77.8419)',4326));

-- Aeropuertos, algunos sin ciudad enlazada a propósito, para que la clave
-- foránea sea anulable en la práctica y no sólo en el esquema.
INSERT INTO aeropuertos (iata, nombre, ciudad_id, altitud_m, ubicacion) VALUES
  ('MAD','Adolfo Suárez Madrid-Barajas',(SELECT id FROM ciudades WHERE nombre='Madrid'),610,ST_GeomFromText('POINT(-3.5626 40.4719)',4326)),
  ('BCN','Josep Tarradellas Barcelona-El Prat',(SELECT id FROM ciudades WHERE nombre='Barcelona'),4,ST_GeomFromText('POINT(2.0833 41.2974)',4326)),
  ('LIS','Humberto Delgado',(SELECT id FROM ciudades WHERE nombre='Lisboa'),114,ST_GeomFromText('POINT(-9.1342 38.7742)',4326)),
  ('CDG','Charles de Gaulle',(SELECT id FROM ciudades WHERE nombre='París'),119,ST_GeomFromText('POINT(2.5479 49.0097)',4326)),
  ('LHR','Heathrow',(SELECT id FROM ciudades WHERE nombre='Londres'),25,ST_GeomFromText('POINT(-0.4543 51.4700)',4326)),
  ('AMS','Schiphol',(SELECT id FROM ciudades WHERE nombre='Ámsterdam'),-3,ST_GeomFromText('POINT(4.7683 52.3105)',4326)),
  ('FRA','Fráncfort del Meno',NULL,111,ST_GeomFromText('POINT(8.5622 50.0379)',4326)),
  ('FCO','Leonardo da Vinci-Fiumicino',(SELECT id FROM ciudades WHERE nombre='Roma'),5,ST_GeomFromText('POINT(12.2389 41.8003)',4326)),
  ('IST','Estambul',(SELECT id FROM ciudades WHERE nombre='Estambul'),99,ST_GeomFromText('POINT(28.7519 41.2753)',4326)),
  ('JFK','John F. Kennedy',(SELECT id FROM ciudades WHERE nombre='Nueva York'),4,ST_GeomFromText('POINT(-73.7781 40.6413)',4326)),
  ('LAX','Los Ángeles',(SELECT id FROM ciudades WHERE nombre='Los Ángeles'),38,ST_GeomFromText('POINT(-118.4085 33.9416)',4326)),
  ('ORD','O''Hare',(SELECT id FROM ciudades WHERE nombre='Chicago'),204,ST_GeomFromText('POINT(-87.9073 41.9742)',4326)),
  ('SFO','San Francisco',(SELECT id FROM ciudades WHERE nombre='San Francisco'),4,ST_GeomFromText('POINT(-122.3790 37.6213)',4326)),
  ('MIA','Miami',(SELECT id FROM ciudades WHERE nombre='Miami'),3,ST_GeomFromText('POINT(-80.2871 25.7959)',4326)),
  ('YYZ','Toronto Pearson',(SELECT id FROM ciudades WHERE nombre='Toronto'),173,ST_GeomFromText('POINT(-79.6248 43.6777)',4326)),
  ('MEX','Benito Juárez',(SELECT id FROM ciudades WHERE nombre='Ciudad de México'),2230,ST_GeomFromText('POINT(-99.0719 19.4361)',4326)),
  ('SJO','Juan Santamaría',(SELECT id FROM ciudades WHERE nombre='San José'),921,ST_GeomFromText('POINT(-84.2088 9.9939)',4326)),
  ('LIR','Daniel Oduber Quirós',(SELECT id FROM ciudades WHERE nombre='Liberia'),44,ST_GeomFromText('POINT(-85.5444 10.5933)',4326)),
  ('PTY','Tocumen',(SELECT id FROM ciudades WHERE nombre='Ciudad de Panamá'),41,ST_GeomFromText('POINT(-79.3835 9.0714)',4326)),
  ('BOG','El Dorado',(SELECT id FROM ciudades WHERE nombre='Bogotá'),2548,ST_GeomFromText('POINT(-74.1469 4.7016)',4326)),
  ('LIM','Jorge Chávez',(SELECT id FROM ciudades WHERE nombre='Lima'),34,ST_GeomFromText('POINT(-77.1143 -12.0219)',4326)),
  ('SCL','Arturo Merino Benítez',(SELECT id FROM ciudades WHERE nombre='Santiago'),474,ST_GeomFromText('POINT(-70.7858 -33.3930)',4326)),
  ('EZE','Ministro Pistarini',(SELECT id FROM ciudades WHERE nombre='Buenos Aires'),20,ST_GeomFromText('POINT(-58.5358 -34.8222)',4326)),
  ('GRU','Guarulhos',(SELECT id FROM ciudades WHERE nombre='São Paulo'),750,ST_GeomFromText('POINT(-46.4731 -23.4356)',4326)),
  ('JNB','O. R. Tambo',(SELECT id FROM ciudades WHERE nombre='Johannesburgo'),1694,ST_GeomFromText('POINT(28.2411 -26.1367)',4326)),
  ('CPT','Ciudad del Cabo',(SELECT id FROM ciudades WHERE nombre='Ciudad del Cabo'),46,ST_GeomFromText('POINT(18.6017 -33.9649)',4326)),
  ('CAI','El Cairo',(SELECT id FROM ciudades WHERE nombre='El Cairo'),116,ST_GeomFromText('POINT(31.4056 30.1219)',4326)),
  ('NBO','Jomo Kenyatta',(SELECT id FROM ciudades WHERE nombre='Nairobi'),1624,ST_GeomFromText('POINT(36.9278 -1.3192)',4326)),
  ('DXB','Dubái',(SELECT id FROM ciudades WHERE nombre='Dubái'),19,ST_GeomFromText('POINT(55.3657 25.2532)',4326)),
  ('DOH','Hamad',(SELECT id FROM ciudades WHERE nombre='Doha'),4,ST_GeomFromText('POINT(51.6081 25.2731)',4326)),
  ('DEL','Indira Gandhi',(SELECT id FROM ciudades WHERE nombre='Delhi'),237,ST_GeomFromText('POINT(77.1000 28.5562)',4326)),
  ('BOM','Chhatrapati Shivaji',(SELECT id FROM ciudades WHERE nombre='Bombay'),11,ST_GeomFromText('POINT(72.8656 19.0896)',4326)),
  ('SIN','Changi',(SELECT id FROM ciudades WHERE nombre='Singapur'),7,ST_GeomFromText('POINT(103.9915 1.3644)',4326)),
  ('BKK','Suvarnabhumi',(SELECT id FROM ciudades WHERE nombre='Bangkok'),2,ST_GeomFromText('POINT(100.7501 13.6900)',4326)),
  ('HKG','Hong Kong',(SELECT id FROM ciudades WHERE nombre='Hong Kong'),9,ST_GeomFromText('POINT(113.9185 22.3080)',4326)),
  ('PVG','Shanghái Pudong',(SELECT id FROM ciudades WHERE nombre='Shanghái'),4,ST_GeomFromText('POINT(121.8083 31.1443)',4326)),
  ('NRT','Narita',(SELECT id FROM ciudades WHERE nombre='Tokio'),43,ST_GeomFromText('POINT(140.3929 35.7720)',4326)),
  ('ICN','Incheon',(SELECT id FROM ciudades WHERE nombre='Seúl'),7,ST_GeomFromText('POINT(126.4407 37.4602)',4326)),
  ('SYD','Kingsford Smith',(SELECT id FROM ciudades WHERE nombre='Sídney'),6,ST_GeomFromText('POINT(151.1753 -33.9399)',4326)),
  ('AKL','Auckland',(SELECT id FROM ciudades WHERE nombre='Auckland'),7,ST_GeomFromText('POINT(174.7850 -37.0082)',4326)),
  ('NAN','Nadi',NULL,18,ST_GeomFromText('POINT(177.4434 -17.7554)',4326)),
  ('HNL','Daniel K. Inouye',(SELECT id FROM ciudades WHERE nombre='Honolulu'),4,ST_GeomFromText('POINT(-157.9224 21.3187)',4326)),
  ('KEF','Keflavík',(SELECT id FROM ciudades WHERE nombre='Reikiavik'),52,ST_GeomFromText('POINT(-22.6056 63.9850)',4326)),
  ('ANC','Ted Stevens',(SELECT id FROM ciudades WHERE nombre='Anchorage'),46,ST_GeomFromText('POINT(-149.9962 61.1743)',4326)),
  ('USH','Malvinas Argentinas',(SELECT id FROM ciudades WHERE nombre='Ushuaia'),31,ST_GeomFromText('POINT(-68.2958 -54.8433)',4326));

INSERT INTO rutas (nombre, tipo, distancia_km, trazado) VALUES
  ('Madrid → Nueva York','aérea',5760.00,ST_GeomFromText('LINESTRING(-3.7038 40.4168, -20.0 43.5, -35.0 45.0, -50.0 44.0, -65.0 41.5, -74.0060 40.7128)',4326)),
  ('Madrid → Buenos Aires','aérea',10050.00,ST_GeomFromText('LINESTRING(-3.7038 40.4168, -18.0 28.0, -28.0 10.0, -35.0 -8.0, -48.0 -25.0, -58.3816 -34.6037)',4326)),
  ('Madrid → San José','aérea',8720.00,ST_GeomFromText('LINESTRING(-3.7038 40.4168, -20.0 38.0, -40.0 32.0, -60.0 24.0, -75.0 15.0, -84.0907 9.9281)',4326)),
  ('Londres → Tokio','aérea',9560.00,ST_GeomFromText('LINESTRING(-0.1278 51.5074, 20.0 60.0, 50.0 66.0, 90.0 64.0, 120.0 55.0, 139.6503 35.6762)',4326)),
  ('París → Nueva York','aérea',5840.00,ST_GeomFromText('LINESTRING(2.3522 48.8566, -10.0 51.0, -25.0 52.0, -40.0 50.0, -55.0 46.0, -74.0060 40.7128)',4326)),
  ('Los Ángeles → Sídney','aérea',12060.00,ST_GeomFromText('LINESTRING(-118.2437 34.0522, -140.0 21.0, -160.0 5.0, -178.0 -10.0, 165.0 -25.0, 151.2093 -33.8688)',4326)),
  ('Santiago → Auckland','aérea',9670.00,ST_GeomFromText('LINESTRING(-70.6693 -33.4489, -100.0 -40.0, -130.0 -45.0, -160.0 -44.0, 175.0 -40.0, 174.7633 -36.8485)',4326)),
  ('Ciudad del Cabo → Perth','aérea',8690.00,ST_GeomFromText('LINESTRING(18.4241 -33.9249, 40.0 -38.0, 60.0 -40.0, 85.0 -38.0, 105.0 -34.0, 115.8605 -31.9505)',4326)),
  ('São Paulo → Lisboa','aérea',7940.00,ST_GeomFromText('LINESTRING(-46.6333 -23.5505, -35.0 -10.0, -28.0 5.0, -22.0 20.0, -14.0 32.0, -9.1393 38.7223)',4326)),
  ('Dubái → Singapur','aérea',5840.00,ST_GeomFromText('LINESTRING(55.2708 25.2048, 65.0 22.0, 78.0 17.0, 90.0 11.0, 98.0 5.0, 103.8198 1.3521)',4326)),
  ('Canal de Panamá','marítima',82.00,ST_GeomFromText('LINESTRING(-79.9020 9.3547, -79.9200 9.2800, -79.8100 9.1800, -79.7000 9.1000, -79.5600 8.9350)',4326)),
  ('Canal de Suez','marítima',193.00,ST_GeomFromText('LINESTRING(32.3020 31.2650, 32.3400 30.9000, 32.3300 30.5000, 32.5600 30.1000, 32.5600 29.9300)',4326)),
  ('Estrecho de Gibraltar','marítima',60.00,ST_GeomFromText('LINESTRING(-5.3500 36.1400, -5.6000 35.9500, -5.9400 35.8900)',4326)),
  ('Ruta del Cabo de Hornos','marítima',680.00,ST_GeomFromText('LINESTRING(-68.0000 -52.5000, -66.5000 -54.0000, -67.2700 -55.9800, -68.5000 -56.5000, -70.5000 -55.0000)',4326)),
  ('Costa Rica: San José → Limón','terrestre',160.00,ST_GeomFromText('LINESTRING(-84.0907 9.9281, -83.9000 9.9700, -83.6500 10.0800, -83.3500 10.0200, -83.0359 9.9907)',4326)),
  ('Costa Rica: Interamericana Norte','terrestre',215.00,ST_GeomFromText('LINESTRING(-84.0907 9.9281, -84.2200 10.0200, -84.5000 10.1500, -84.9000 10.3500, -85.4377 10.6346)',4326)),
  ('Ruta 66 (tramo)','terrestre',1400.00,ST_GeomFromText('LINESTRING(-87.6298 41.8781, -89.6500 39.8000, -90.1994 38.6270, -94.5100 37.0800, -97.5164 35.4676)',4326)),
  ('Carretera Panamericana (tramo andino)','terrestre',2100.00,ST_GeomFromText('LINESTRING(-74.0721 4.7110, -77.6000 0.8000, -78.4678 -0.1807, -79.8891 -2.1894, -79.8400 -6.7700, -77.0428 -12.0464)',4326)),
  ('Transiberiano (tramo)','ferroviaria',3300.00,ST_GeomFromText('LINESTRING(37.6173 55.7558, 44.0000 56.3300, 53.2100 56.8400, 61.4000 55.1600, 82.9357 55.0084)',4326)),
  ('AVE Madrid → Barcelona','ferroviaria',621.00,ST_GeomFromText('LINESTRING(-3.7038 40.4168, -2.5000 40.6800, -1.1000 41.1500, -0.8770 41.6560, 1.0000 41.6200, 2.1686 41.3874)',4326)),
  ('Shinkansen Tokio → Osaka','ferroviaria',515.00,ST_GeomFromText('LINESTRING(139.6503 35.6762, 138.9000 35.1800, 138.3800 34.9700, 136.8800 35.1700, 135.7600 34.9800, 135.5023 34.6937)',4326)),
  ('Eurostar Londres → París','ferroviaria',492.00,ST_GeomFromText('LINESTRING(-0.1278 51.5074, 1.3100 51.1300, 1.8600 50.9600, 3.0600 50.6300, 2.3000 49.8900, 2.3522 48.8566)',4326));


INSERT INTO regiones (nombre, clase, area_km2, contorno) VALUES
  ('Península ibérica','país',583254.00,ST_GeomFromText('POLYGON((-9.5 43.8, 3.4 43.8, 3.4 36.0, -9.5 36.0, -9.5 43.8))',4326)),
  ('Francia continental','país',551695.00,ST_GeomFromText('POLYGON((-4.8 51.1, 8.2 51.1, 8.2 42.3, -4.8 42.3, -4.8 51.1))',4326)),
  ('Italia','país',301340.00,ST_GeomFromText('POLYGON((6.6 47.1, 18.5 47.1, 18.5 36.6, 6.6 36.6, 6.6 47.1))',4326)),
  ('Alemania','país',357022.00,ST_GeomFromText('POLYGON((5.9 55.1, 15.0 55.1, 15.0 47.3, 5.9 47.3, 5.9 55.1))',4326)),
  ('Reino Unido','país',242495.00,ST_GeomFromText('POLYGON((-8.2 58.7, 1.8 58.7, 1.8 49.9, -8.2 49.9, -8.2 58.7))',4326)),
  ('Costa Rica','país',51100.00,ST_GeomFromText('POLYGON((-85.95 11.22, -82.55 11.22, -82.55 8.03, -85.95 8.03, -85.95 11.22))',4326)),
  ('Panamá','país',75417.00,ST_GeomFromText('POLYGON((-83.05 9.65, -77.17 9.65, -77.17 7.20, -83.05 7.20, -83.05 9.65))',4326)),
  ('México','país',1964375.00,ST_GeomFromText('POLYGON((-118.40 32.72, -86.71 32.72, -86.71 14.53, -118.40 14.53, -118.40 32.72))',4326)),
  ('Argentina','país',2780400.00,ST_GeomFromText('POLYGON((-73.58 -21.78, -53.64 -21.78, -53.64 -55.06, -73.58 -55.06, -73.58 -21.78))',4326)),
  ('Chile','país',756102.00,ST_GeomFromText('POLYGON((-75.64 -17.50, -66.42 -17.50, -66.42 -55.98, -75.64 -55.98, -75.64 -17.50))',4326)),
  ('Brasil','país',8515767.00,ST_GeomFromText('POLYGON((-73.99 5.27, -34.79 5.27, -34.79 -33.75, -73.99 -33.75, -73.99 5.27))',4326)),
  ('Japón','país',377975.00,ST_GeomFromText('POLYGON((128.99 45.52, 145.82 45.52, 145.82 24.25, 128.99 24.25, 128.99 45.52))',4326)),
  ('Australia','país',7692024.00,ST_GeomFromText('POLYGON((112.92 -10.06, 153.64 -10.06, 153.64 -43.64, 112.92 -43.64, 112.92 -10.06))',4326)),
  ('Sudáfrica','país',1221037.00,ST_GeomFromText('POLYGON((16.45 -22.13, 32.89 -22.13, 32.89 -34.84, 16.45 -34.84, 16.45 -22.13))',4326)),
  ('Egipto','país',1002450.00,ST_GeomFromText('POLYGON((24.70 31.67, 36.90 31.67, 36.90 21.99, 24.70 21.99, 24.70 31.67))',4326)),
  ('Cataluña','provincia',32108.00,ST_GeomFromText('POLYGON((0.16 42.86, 3.33 42.86, 3.33 40.52, 0.16 40.52, 0.16 42.86))',4326)),
  ('Andalucía','provincia',87268.00,ST_GeomFromText('POLYGON((-7.52 38.73, -1.63 38.73, -1.63 36.00, -7.52 36.00, -7.52 38.73))',4326)),
  ('Baviera','provincia',70550.00,ST_GeomFromText('POLYGON((8.98 50.56, 13.84 50.56, 13.84 47.27, 8.98 47.27, 8.98 50.56))',4326)),
  ('Provincia de Guanacaste','provincia',10141.00,ST_GeomFromText('POLYGON((-86.00 11.22, -84.65 11.22, -84.65 9.85, -86.00 9.85, -86.00 11.22))',4326)),
  ('Parque Nacional de Yellowstone','parque',8991.00,ST_GeomFromText('POLYGON((-111.16 45.11, -109.82 45.11, -109.82 44.13, -111.16 44.13, -111.16 45.11))',4326)),
  ('Parque Nacional Manuel Antonio','parque',19.83,ST_GeomFromText('POLYGON((-84.17 9.42, -84.12 9.42, -84.12 9.36, -84.17 9.36, -84.17 9.42))',4326)),
  ('Parque Nacional Corcovado','parque',424.00,ST_GeomFromText('POLYGON((-83.75 8.63, -83.34 8.63, -83.34 8.38, -83.75 8.38, -83.75 8.63))',4326)),
  ('Parque Nacional del Teide','parque',189.90,ST_GeomFromText('POLYGON((-16.75 28.34, -16.48 28.34, -16.48 28.17, -16.75 28.17, -16.75 28.34))',4326)),
  ('Reserva de Doñana','reserva',543.00,ST_GeomFromText('POLYGON((-6.60 37.19, -6.19 37.19, -6.19 36.79, -6.60 36.79, -6.60 37.19))',4326)),
  ('Reserva del Serengueti','reserva',14763.00,ST_GeomFromText('POLYGON((33.90 -1.52, 35.31 -1.52, 35.31 -3.36, 33.90 -3.36, 33.90 -1.52))',4326)),
  ('Centro de Madrid','zona urbana',42.00,ST_GeomFromText('POLYGON((-3.73 40.44, -3.66 40.44, -3.66 40.39, -3.73 40.39, -3.73 40.44))',4326)),
  ('Manhattan','zona urbana',59.10,ST_GeomFromText('POLYGON((-74.02 40.88, -73.91 40.88, -73.91 40.70, -74.02 40.70, -74.02 40.88))',4326)),
  ('Gran San José','zona urbana',2044.00,ST_GeomFromText('POLYGON((-84.30 10.05, -83.90 10.05, -83.90 9.80, -84.30 9.80, -84.30 10.05))',4326));

-- Cada fila de aquí existe para romper algo concreto. `motivo` dice qué, así
-- que un fallo apunta a su propia causa en vez de parecer un dato malo.
INSERT INTO zonas_complejas (nombre, motivo, forma) VALUES

  ('Anillo con agujero',
   'POLYGON de dos anillos: exterior e interior. Un parser que asuma un solo anillo dibuja el agujero como relleno',
   ST_GeomFromText('POLYGON((-3.90 40.60, -3.50 40.60, -3.50 40.25, -3.90 40.25, -3.90 40.60),(-3.80 40.50, -3.60 40.50, -3.60 40.35, -3.80 40.35, -3.80 40.50))',4326)),

  ('Anillo con dos agujeros',
   'POLYGON de tres anillos: dos interiores disjuntos',
   ST_GeomFromText('POLYGON((-84.40 10.20, -83.80 10.20, -83.80 9.70, -84.40 9.70, -84.40 10.20),(-84.30 10.10, -84.15 10.10, -84.15 10.00, -84.30 10.00, -84.30 10.10),(-84.05 9.90, -83.90 9.90, -83.90 9.80, -84.05 9.80, -84.05 9.90))',4326)),

  ('Archipiélago',
   'MULTIPOLYGON de tres islas disjuntas',
   ST_GeomFromText('MULTIPOLYGON(((-17.90 28.60, -17.70 28.60, -17.70 28.40, -17.90 28.40, -17.90 28.60)),((-16.30 28.60, -16.10 28.60, -16.10 28.00, -16.30 28.00, -16.30 28.60)),((-13.60 29.30, -13.40 29.30, -13.40 28.85, -13.60 28.85, -13.60 29.30)))',4326)),

  ('Boyas de medición',
   'MULTIPOINT: varios puntos en una sola celda',
   ST_GeomFromText('MULTIPOINT((-84.80 9.50),(-84.60 9.20),(-84.20 8.90),(-83.80 8.60),(-83.40 8.30))',4326)),

  ('Red fluvial',
   'MULTILINESTRING: afluentes que no se tocan entre sí',
   ST_GeomFromText('MULTILINESTRING((-84.90 10.40, -84.60 10.20, -84.30 10.05),(-84.50 10.55, -84.35 10.30, -84.30 10.05),(-84.30 10.05, -84.10 9.85, -83.85 9.70))',4326)),

  ('Estación completa',
   'GEOMETRYCOLLECTION: punto, línea y polígono en un solo valor. Rompe cualquier lector que asuma un tipo homogéneo',
   ST_GeomFromText('GEOMETRYCOLLECTION(POINT(-84.0907 9.9281),LINESTRING(-84.0907 9.9281, -84.0500 9.9500, -84.0100 9.9700),POLYGON((-84.12 9.95, -84.06 9.95, -84.06 9.90, -84.12 9.90, -84.12 9.95)))',4326)),

  ('Colección anidada',
   'GEOMETRYCOLLECTION que contiene otra GEOMETRYCOLLECTION: prueba el techo de profundidad del parser',
   ST_GeomFromText('GEOMETRYCOLLECTION(POINT(-3.7038 40.4168),GEOMETRYCOLLECTION(POINT(2.1686 41.3874),MULTIPOINT((-0.3763 39.4699),(-5.9845 37.3891))))',4326)),

  ('Cruce del antimeridiano',
   'LINESTRING que salta de +179 a -179: en el mapa debe cruzar el Pacífico, no dar la vuelta al mundo',
   ST_GeomFromText('LINESTRING(178.00 -16.50, 179.00 -17.20, 179.90 -17.80, -179.20 -18.30, -178.00 -18.90, -177.00 -19.50)',4326)),

  ('Isla sobre el antimeridiano',
   'POLYGON con vértices a ambos lados de ±180',
   ST_GeomFromText('POLYGON((179.00 -16.00, -179.50 -16.00, -179.50 -17.20, 179.00 -17.20, 179.00 -16.00))',4326)),

  ('Polo norte',
   'POINT en latitud 90: los límites del sistema de referencia',
   ST_GeomFromText('POINT(0 90)',4326)),

  ('Polo sur',
   'POINT en latitud -90',
   ST_GeomFromText('POINT(0 -90)',4326)),

  ('Punto nulo de la isla',
   'POINT en (0,0), donde acaban las coordenadas mal convertidas',
   ST_GeomFromText('POINT(0 0)',4326)),

  ('Meridiano de Greenwich',
   'LINESTRING de longitud exactamente 0',
   ST_GeomFromText('LINESTRING(0 51.4779, 0 45.0, 0 30.0, 0 15.0, 0 0)',4326)),

  ('Ecuador',
   'LINESTRING de latitud exactamente 0, de lado a lado del mundo',
   ST_GeomFromText('LINESTRING(-80 0, -40 0, 0 0, 40 0, 80 0, 120 0, 178 0)',4326)),

  ('Sin geometría',
   'NULL: la fila existe y la columna espacial está vacía. Debe contarse y decirse, no desaparecer',
   NULL),

  ('Sin geometría (segunda)',
   'Segundo NULL, para que el recuento de filas sin geometría sea plural',
   NULL),

  ('Línea de dos puntos idénticos',
   'LINESTRING degenerada: longitud cero',
   ST_GeomFromText('LINESTRING(-84.0907 9.9281, -84.0907 9.9281)',4326)),

  ('Triángulo mínimo',
   'POLYGON de tres vértices, el anillo más pequeño que admite el formato',
   ST_GeomFromText('POLYGON((-84.10 9.90, -84.09 9.91, -84.09 9.89, -84.10 9.90))',4326)),

  ('Precisión larga',
   'POINT con quince decimales: comprueba que no se redondea al ir y volver',
   ST_GeomFromText('POINT(-84.090725000000000 9.928069444444444)',4326)),

  ('Coordenadas diminutas',
   'POINT muy cerca del origen, con exponente negativo grande',
   ST_GeomFromText('POINT(0.000001 0.000001)',4326));

-- El círculo denso. En MySQL es un PROCEDURE con un WHILE que va concatenando
-- texto; aquí es una consulta, que es la forma natural de Postgres. Mismo
-- resultado y mismo motivo: una sola fila con más vértices de los que un
-- renderizador ingenuo espera.
--
-- `i % vertices` cierra el anillo sin repetir: en i = N el módulo vuelve a 0 y
-- el último vértice es exactamente el primero. La versión de MySQL tiene que
-- esquivarlo a mano —para no dejar el punto de cierre repetido tres veces— y
-- aquí sale gratis.
INSERT INTO zonas_complejas (nombre, motivo, forma)
SELECT c.nombre,
       'POLYGON de ' || c.vertices || ' vértices generado — una sola fila con muchos puntos',
       ST_SetSRID(ST_MakePolygon(p.anillo), 4326)
  FROM (VALUES
          ('Círculo denso sobre San José',  9.9281::float8, -84.0907::float8, 0.30::float8, 512),
          ('Círculo sobre Madrid',         40.4168::float8,  -3.7038::float8, 0.50::float8, 128)
       ) AS c(nombre, lat, lon, radio, vertices)
  CROSS JOIN LATERAL (
        SELECT ST_MakeLine(
                 ST_MakePoint(
                   c.lon + c.radio * SIN(2 * PI() * (g.i % c.vertices) / c.vertices) / COS(RADIANS(c.lat)),
                   c.lat + c.radio * COS(2 * PI() * (g.i % c.vertices) / c.vertices)
                 ) ORDER BY g.i) AS anillo
          FROM generate_series(0, c.vertices) AS g(i)
       ) AS p;

-- Las secuencias de identidad no avanzan cuando el id se da explícito, y aquí
-- no se da: aun así se recolocan, porque el seed se lee entero como una unidad
-- y dejar la mitad de las tablas con la secuencia a cero es una trampa que
-- estalla en el primer INSERT que haga un usuario.
SELECT setval(pg_get_serial_sequence('gis.ciudades','id'),        COALESCE((SELECT MAX(id) FROM ciudades), 1));
SELECT setval(pg_get_serial_sequence('gis.aeropuertos','id'),     COALESCE((SELECT MAX(id) FROM aeropuertos), 1));
SELECT setval(pg_get_serial_sequence('gis.rutas','id'),           COALESCE((SELECT MAX(id) FROM rutas), 1));
SELECT setval(pg_get_serial_sequence('gis.regiones','id'),        COALESCE((SELECT MAX(id) FROM regiones), 1));
SELECT setval(pg_get_serial_sequence('gis.zonas_complejas','id'), COALESCE((SELECT MAX(id) FROM zonas_complejas), 1));
PG_SEED_74_SEED_GIS_NODE_EOF

  cat > "${PG_SEED_DIR}/75-bulk-gis.sql" <<'PG_SEED_75_BULK_GIS_EOF'
-- 75-bulk-gis.sql — Postgres
--
-- Volumen, generado y no tecleado. Equivalente de `gis-seed/50-bulk.sql`, con
-- los mismos números: 25 000 sensores y una rejilla de 45×45 parcelas.
--
-- `sensores` está dimensionado por encima del techo de 10 000 geometrías del
-- mapa **a propósito**: el aviso de recorte tiene que ser alcanzable con un
-- `SELECT * FROM sensores` y sin que nadie tenga que inventarse una consulta.
--
-- En MySQL esto son dos PROCEDURE con bucles WHILE, `autocommit = 0` y un
-- COMMIT cada mil filas. Aquí son dos INSERT … SELECT sobre `generate_series`:
-- una sola sentencia, una sola transacción, y sin procedimiento que después
-- quede colgando en el catálogo pareciendo parte del esquema de ejemplo.
--
-- Los centros de las cinco cuencas son los mismos, ya en (longitud, latitud).

SET search_path = gis, public;

INSERT INTO sensores (codigo, lote, temperatura, activo, ubicacion, medido_en)
SELECT 'SN-' || LPAD((i + 1)::text, 6, '0'),
       cuenca.lote,
       ROUND((5 + random() * 35)::numeric, 2),
       random() < 0.92,
       ST_SetSRID(ST_MakePoint(cuenca.lon + (random() - 0.5) * 3.0,
                               cuenca.lat + (random() - 0.5) * 3.0), 4326),
       now() - (FLOOR(random() * 90) || ' days')::interval
  FROM generate_series(0, 24999) AS g(i)
  CROSS JOIN LATERAL (
        SELECT * FROM (VALUES
            (0::smallint,  -84.0907::float8,   9.9281::float8),  -- Valle Central
            (1::smallint,   -3.7038::float8,  40.4168::float8),  -- Meseta
            (2::smallint,  -70.6693::float8, -33.4489::float8),  -- Cono Sur
            (3::smallint,  139.6503::float8,  35.6762::float8),  -- Kanto
            (4::smallint,   28.0473::float8, -26.2041::float8)   -- Highveld
        ) AS v(lote, lon, lat)
        WHERE v.lote = (g.i % 5)::smallint
       ) AS cuenca;

-- Una rejilla de parcelas cuadradas al norte de San José: muchos polígonos
-- pequeños, que es una carga distinta para un renderizador que muchos puntos.
INSERT INTO parcelas (referencia, cultivo, contorno)
SELECT 'P-' || LPAD(f::text, 3, '0') || '-' || LPAD(c::text, 3, '0'),
       (ARRAY['café','caña','piña','banano','pasto'])[1 + FLOOR(random() * 5)],
       ST_SetSRID(ST_MakePolygon(ST_MakeLine(ARRAY[
           ST_MakePoint(lo,                la),
           ST_MakePoint(lo + paso * 0.9,   la),
           ST_MakePoint(lo + paso * 0.9,   la - paso * 0.9),
           ST_MakePoint(lo,                la - paso * 0.9),
           ST_MakePoint(lo,                la)
       ])), 4326)
  FROM generate_series(0, 44) AS gf(f)
  CROSS JOIN generate_series(0, 44) AS gc(c)
  CROSS JOIN LATERAL (SELECT 0.004::float8 AS paso) AS k
  CROSS JOIN LATERAL (SELECT 10.10 - gf.f * k.paso AS la,
                            -84.35 + gc.c * k.paso AS lo) AS p;
PG_SEED_75_BULK_GIS_EOF
  # ── Seed de SQL Server (plan 12, tarea 2) ─────────────────────────────────
  # Escrito para SQL Server, no traducido del de Postgres: cada archivo dice en
  # su cabecera qué forma tiene aquí y por qué. Lo aplica `00-init.sh` desde
  # dentro del contenedor, que es el `command:` del nodo `sqlserver`.
  cat > "${MSSQL_SEED_DIR}/00-init.sh" <<'MSSQL_SEED_00_INIT_EOF'
#!/bin/bash
# 00-init.sh — arranque del nodo `sqlserver` con su seed.
#
# La imagen de SQL Server no tiene `docker-entrypoint-initdb.d`: nada ejecuta
# un seed por su cuenta. Así que el `command:` del nodo es este script, que
# arranca `sqlservr` en segundo plano, espera a que conteste, aplica los
# `.sql` de /seed en orden si la base `demo` no existe, y se queda esperando al
# servidor. El `ENTRYPOINT` de la imagen (`launch_sqlservr.sh`) no se toca: él
# hace la comprobación de permisos y luego ejecuta este script.
#
# El healthcheck del compose mira `/tmp/caliope-seed-ok`, que sólo aparece
# cuando el seed terminó entero —o cuando ya estaba de un arranque anterior—:
# sin eso el nodo sale «healthy» en cuanto el servidor acepta conexiones, con
# la base a medio crear, y el primer paso de QA ve tablas que faltan.
#
# Si un archivo falla, la base `demo` se borra entera y el nodo se queda sin la
# marca: el arranque siguiente vuelve a empezar desde cero. Dejarla a medias
# haría que el siguiente arranque la diera por sembrada.

set -u
# `-I` enciende QUOTED_IDENTIFIER, que `sqlcmd` deja APAGADO por omisión —al
# revés que cualquier driver—. Sin él, el índice espacial, las columnas
# calculadas persistidas y las vistas con esquema mueren con «Msg 1934: … the
# following SET options have incorrect settings: 'QUOTED_IDENTIFIER'».
SQLCMD=(/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P "${MSSQL_SA_PASSWORD}" -C -I -f 65001 -b)

# Certificado CONFIGURADO, no el que el servidor se genera solo (plan 12, tarea 9).
# Con el autogenerado, SQL Server 2022+ rechaza el TLS estricto de TDS 8.0 —«A valid
# TLS certificate is not configured to accept strict (TDS 8.0 and above)
# connections»— y corta el saludo; con uno configurado por `mssql-conf`, aunque sea
# autofirmado, lo acepta. Este nodo lleva el suyo para que Calíope entre por el
# camino estricto; `sqlserver-restore` se queda a propósito con el autogenerado, que
# es lo que tiene una instalación por omisión, y ejercita el segundo intento por el
# TLS de TDS 7.x. Vive en el volumen: se genera una vez y sobrevive a los arranques.
TLS_DIR=/var/opt/mssql/tls
if [ ! -f "${TLS_DIR}/mssql.pem" ]; then
  mkdir -p "${TLS_DIR}"
  openssl req -x509 -newkey rsa:2048 -nodes -days 3650 -subj "/CN=localhost" \
    -addext "subjectAltName=DNS:localhost,IP:127.0.0.1" \
    -addext "extendedKeyUsage=serverAuth" \
    -addext "keyUsage=digitalSignature,keyEncipherment" \
    -keyout "${TLS_DIR}/mssql.key" -out "${TLS_DIR}/mssql.pem" 2>/dev/null
fi
/opt/mssql/bin/mssql-conf set network.tlscert "${TLS_DIR}/mssql.pem" >/dev/null
/opt/mssql/bin/mssql-conf set network.tlskey "${TLS_DIR}/mssql.key" >/dev/null

rm -f /tmp/caliope-seed-ok
/opt/mssql/bin/sqlservr &
pid=$!

for _ in $(seq 1 120); do
  "${SQLCMD[@]}" -Q "SELECT 1" -o /dev/null 2>/dev/null && break
  sleep 1
done

existe="$("${SQLCMD[@]}" -h -1 -W -Q "SET NOCOUNT ON; SELECT CASE WHEN DB_ID('demo') IS NULL THEN 0 ELSE 1 END" 2>/dev/null | tr -d '[:space:]')"
if [ "${existe}" = "0" ]; then
  ok=1
  for f in /seed/[1-9]*.sql; do
    echo "[caliope] seed: ${f}"
    if ! "${SQLCMD[@]}" -i "${f}"; then
      echo "[caliope] seed: FALLÓ ${f}; se borra la base demo para reintentar en el próximo arranque"
      "${SQLCMD[@]}" -Q "IF DB_ID('demo') IS NOT NULL BEGIN ALTER DATABASE demo SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE demo; END"
      ok=0
      break
    fi
  done
  [ "${ok}" = "1" ] && touch /tmp/caliope-seed-ok && echo "[caliope] seed: terminado"
elif [ "${existe}" = "1" ]; then
  echo "[caliope] seed: la base demo ya existe; no se vuelve a sembrar"
  touch /tmp/caliope-seed-ok
else
  echo "[caliope] seed: el servidor no contestó; se arranca sin seed"
fi

wait "${pid}"
MSSQL_SEED_00_INIT_EOF

  cat > "${MSSQL_SEED_DIR}/10-database.sql" <<'MSSQL_SEED_10_DATABASE_EOF'
-- 10-database.sql — SQL Server
--
-- Una base, `demo`, con DOS esquemas: `dbo` y `rrhh`.
--
-- En SQL Server la base es la unidad de conexión, como en Postgres, y el
-- esquema es un nivel dentro. A diferencia de Postgres, aquí `rrhh` va como
-- ESQUEMA de `demo` y no como base aparte: el cambio de base ya lo ejercita el
-- nodo Postgres, y lo que este motor necesita enseñar es el nivel del esquema
-- —`dbo` es el esquema por omisión, y el árbol de la app tiene que distinguirlo
-- de los demás (tarea 23 del plan 12)—, más una foránea que cruza de un esquema
-- al otro (tarea 51).
--
-- La colación de la base es la del servidor (`MSSQL_COLLATION` del compose,
-- `Latin1_General_100_CI_AS_SC_UTF8`). Todo el texto va además en `NVARCHAR` y
-- todo literal con acentos lleva el prefijo `N'…'`: sin él, el literal se
-- convierte a la página de códigos de la base antes de llegar a la columna.
--
-- Lo que NO está, a propósito: `analytics`, `gis` y `caliope_gis_test`. Existen
-- en Postgres por diferencias de Postgres —PostGIS, particiones, el nodo
-- espacial— y copiarlos aquí por simetría daría cobertura aparente: tablas que
-- ninguna tarea de este motor abre. Lo espacial de SQL Server se ejercita con
-- la columna `geography` de `dbo.direcciones`.

CREATE DATABASE demo;
GO

USE demo;
GO

CREATE SCHEMA rrhh;
GO
MSSQL_SEED_10_DATABASE_EOF

  cat > "${MSSQL_SEED_DIR}/20-tables-dbo.sql" <<'MSSQL_SEED_20_TABLES_DBO_EOF'
-- 20-tables-dbo.sql — SQL Server
--
-- Las siete tablas del esquema `demo` de Postgres, con los mismos nombres y los
-- mismos ids, en `dbo`. Lo que tiene otra forma aquí:
--
--   IDENTITY            → `INT IDENTITY(1,1)`. El seed da ids explícitos con
--                         `SET IDENTITY_INSERT … ON`, y SQL Server recoloca el
--                         contador solo cuando el id insertado es mayor: no hace
--                         falta el `setval` de Postgres.
--   ENUM                → `NVARCHAR` con un `CHECK`. SQL Server no tiene tipos
--                         enumerados.
--   boolean             → `BIT`.
--   jsonb               → `NVARCHAR(MAX)` con `CHECK (ISJSON(…) = 1)`. La 2025
--                         trae un tipo `json` nativo, pero un cliente que no lo
--                         negocia lo recibe como texto, y el driver TDS del plan
--                         12 lo decide en su tarea de tipos, no aquí.
--   geometry(Point, 0)  → `geography`, SRID 4326. El WKT de `geography` es
--                         (longitud latitud), como el de PostGIS; el constructor
--                         `geography::Point(lat, long, srid)` va al revés, y por
--                         eso el seed usa `STGeomFromText`.
--   bytea               → `VARBINARY`.
--   timestamptz         → `DATETIMEOFFSET`, con `SYSDATETIMEOFFSET()`.
--   text                → `NVARCHAR(MAX)`.
--   ON UPDATE           → disparador `AFTER UPDATE`, en 50-triggers.sql.
--
-- Las restricciones llevan nombre: sin él SQL Server inventa uno con un sufijo
-- aleatorio (`PK__producto__3213E83F…`) distinto en cada arranque, y una
-- comparación de esquemas entre dos siembras vería diferencias que no existen.

USE demo;
GO

CREATE TABLE dbo.productos (
  id         INT IDENTITY(1,1) NOT NULL CONSTRAINT pk_productos PRIMARY KEY,
  nombre     NVARCHAR(100) NOT NULL,
  categoria  NVARCHAR(50)  NOT NULL CONSTRAINT df_productos_categoria DEFAULT N'general',
  precio     DECIMAL(10,2) NOT NULL,
  stock      INT NOT NULL CONSTRAINT df_productos_stock DEFAULT 0,
  activo     BIT NOT NULL CONSTRAINT df_productos_activo DEFAULT 1,
  creado_en  DATETIMEOFFSET(0) NULL CONSTRAINT df_productos_creado DEFAULT SYSDATETIMEOFFSET()
);
CREATE INDEX idx_categoria ON dbo.productos (categoria);

CREATE TABLE dbo.clientes (
  id         INT IDENTITY(1,1) NOT NULL CONSTRAINT pk_clientes PRIMARY KEY,
  nombre     NVARCHAR(100) NOT NULL,
  email      NVARCHAR(150) NOT NULL CONSTRAINT uk_clientes_email UNIQUE,
  ciudad     NVARCHAR(80) NULL,
  creado_en  DATETIMEOFFSET(0) NULL CONSTRAINT df_clientes_creado DEFAULT SYSDATETIMEOFFSET()
);

CREATE TABLE dbo.pedidos (
  id           INT IDENTITY(1,1) NOT NULL CONSTRAINT pk_pedidos PRIMARY KEY,
  cliente_id   INT NOT NULL CONSTRAINT fk_pedidos_cliente  REFERENCES dbo.clientes(id),
  producto_id  INT NOT NULL CONSTRAINT fk_pedidos_producto REFERENCES dbo.productos(id),
  cantidad     INT NOT NULL,
  total        DECIMAL(10,2) NOT NULL,
  estado       NVARCHAR(10) NULL CONSTRAINT df_pedidos_estado DEFAULT N'pendiente'
               CONSTRAINT ck_pedidos_estado CHECK (estado IN (N'pendiente', N'enviado', N'entregado', N'cancelado')),
  creado_en    DATETIMEOFFSET(0) NULL CONSTRAINT df_pedidos_creado DEFAULT SYSDATETIMEOFFSET()
);
CREATE INDEX idx_estado  ON dbo.pedidos (estado);
CREATE INDEX idx_cliente ON dbo.pedidos (cliente_id);

-- Catálogo autorreferenciado. `ON DELETE SET NULL` sobre la misma tabla no lo
-- admite SQL Server («may cause cycles or multiple cascade paths», error 1785):
-- la autorreferencia va con `NO ACTION`, y es una diferencia de forma, no un
-- descuido.
CREATE TABLE dbo.categorias (
  id           INT IDENTITY(1,1) NOT NULL CONSTRAINT pk_categorias PRIMARY KEY,
  slug         NVARCHAR(50)  NOT NULL CONSTRAINT uk_categorias_slug UNIQUE,
  nombre       NVARCHAR(100) NOT NULL,
  descripcion  NVARCHAR(MAX) NULL,
  padre_id     INT NULL CONSTRAINT fk_categorias_padre REFERENCES dbo.categorias(id),
  activa       BIT NOT NULL CONSTRAINT df_categorias_activa DEFAULT 1,
  creada_en    DATETIMEOFFSET(0) NULL CONSTRAINT df_categorias_creada DEFAULT SYSDATETIMEOFFSET()
);
CREATE INDEX idx_activa ON dbo.categorias (activa);

CREATE TABLE dbo.direcciones (
  id            INT IDENTITY(1,1) NOT NULL CONSTRAINT pk_direcciones PRIMARY KEY,
  cliente_id    INT NOT NULL CONSTRAINT fk_direcciones_cliente REFERENCES dbo.clientes(id) ON DELETE CASCADE,
  etiqueta      NVARCHAR(30) NOT NULL CONSTRAINT df_direcciones_etiqueta DEFAULT N'principal',
  linea1        NVARCHAR(200) NOT NULL,
  linea2        NVARCHAR(200) NULL,
  ciudad        NVARCHAR(80) NOT NULL,
  pais          NCHAR(2) NOT NULL,
  metadatos     NVARCHAR(MAX) NULL CONSTRAINT ck_direcciones_metadatos CHECK (ISJSON(metadatos) = 1),
  ubicacion     GEOGRAPHY NOT NULL,
  actualizada   DATETIMEOFFSET(0) NULL CONSTRAINT df_direcciones_actualizada DEFAULT SYSDATETIMEOFFSET()
);
-- Un índice espacial exige clave primaria agrupada, que es la de por omisión.
CREATE SPATIAL INDEX idx_ubicacion ON dbo.direcciones (ubicacion);
CREATE INDEX idx_pais ON dbo.direcciones (pais);

CREATE TABLE dbo.pedido_lineas (
  id            INT IDENTITY(1,1) NOT NULL CONSTRAINT pk_pedido_lineas PRIMARY KEY,
  pedido_id     INT NOT NULL CONSTRAINT fk_lineas_pedido   REFERENCES dbo.pedidos(id)   ON DELETE CASCADE,
  producto_id   INT NOT NULL CONSTRAINT fk_lineas_producto REFERENCES dbo.productos(id) ON DELETE NO ACTION,
  cantidad      INT NOT NULL CONSTRAINT df_lineas_cantidad DEFAULT 1,
  precio_unit   DECIMAL(10,2) NOT NULL,
  descuento_pct DECIMAL(5,2) NOT NULL CONSTRAINT df_lineas_descuento DEFAULT 0.00
);
CREATE INDEX idx_pedido ON dbo.pedido_lineas (pedido_id);

CREATE TABLE dbo.log_auditoria (
  id            BIGINT IDENTITY(1,1) NOT NULL CONSTRAINT pk_log_auditoria PRIMARY KEY,
  ocurrido_en   DATETIMEOFFSET(3) NULL CONSTRAINT df_log_ocurrido DEFAULT SYSDATETIMEOFFSET(),
  actor         NVARCHAR(80) NULL,
  entidad       NVARCHAR(80) NOT NULL,
  entidad_id    BIGINT NULL,
  accion        NVARCHAR(6) NOT NULL
                CONSTRAINT ck_log_accion CHECK (accion IN (N'INSERT', N'UPDATE', N'DELETE')),
  payload_json  NVARCHAR(MAX) NULL CONSTRAINT ck_log_payload CHECK (ISJSON(payload_json) = 1),
  payload_hash  VARBINARY(32) NULL
);
CREATE INDEX idx_entidad  ON dbo.log_auditoria (entidad, entidad_id);
CREATE INDEX idx_ocurrido ON dbo.log_auditoria (ocurrido_en);
GO
MSSQL_SEED_20_TABLES_DBO_EOF

  cat > "${MSSQL_SEED_DIR}/21-tables-rrhh.sql" <<'MSSQL_SEED_21_TABLES_RRHH_EOF'
-- 21-tables-rrhh.sql — SQL Server
--
-- Las tres tablas de la base `rrhh` de Postgres, aquí como esquema `rrhh` de
-- `demo` (el porqué, en 10-database.sql). Lo que cambia:
--
--   text[] (`permisos`)      → `NVARCHAR(200)` con los valores separados por
--       comas, como el `SET` de MySQL. SQL Server no tiene arrays, y una tabla
--       puente cambiaría el recuento de tablas que se compara entre motores.
--   GENERATED … STORED       → columna calculada `PERSISTED`: la que este motor
--       tiene de serie. `neto` se guarda en disco y se puede indexar.
--   COMMENT ON COLUMN        → propiedad extendida `MS_Description`, que es lo
--       que leen las herramientas de Microsoft como comentario.
--   ON DELETE SET NULL en la autorreferencia de `jefe_id` → `NO ACTION`: SQL
--       Server no admite acciones en cascada sobre la misma tabla (error 1785).
--
-- **La foránea que cruza de esquema.** `rrhh.empleados.cliente_asignado_id →
-- dbo.clientes(id)`: el comercial que lleva cada cliente. No existe en el seed
-- de Postgres —allí `rrhh` es otra base y una foránea no puede cruzar bases—,
-- y es la columna que sobra respecto a Postgres, a propósito: sin ella no hay
-- relación entre esquemas que dibujar (tarea 51 del plan 12). Es anulable y no
-- cambia ningún recuento de filas.

USE demo;
GO

CREATE TABLE rrhh.departamentos (
  id           INT IDENTITY(1,1) NOT NULL CONSTRAINT pk_departamentos PRIMARY KEY,
  codigo       NCHAR(4) NOT NULL CONSTRAINT uk_departamentos_codigo UNIQUE,
  nombre       NVARCHAR(120) NOT NULL,
  presupuesto  DECIMAL(14,2) NOT NULL CONSTRAINT df_departamentos_presupuesto DEFAULT 0.00,
  creado_en    DATETIMEOFFSET(0) NULL CONSTRAINT df_departamentos_creado DEFAULT SYSDATETIMEOFFSET()
);

CREATE TABLE rrhh.empleados (
  id                   INT IDENTITY(1,1) NOT NULL CONSTRAINT pk_empleados PRIMARY KEY,
  nombre               NVARCHAR(120) NOT NULL,
  email                NVARCHAR(150) NOT NULL CONSTRAINT uk_empleados_email UNIQUE,
  departamento_id      INT NOT NULL CONSTRAINT fk_empleados_departamento REFERENCES rrhh.departamentos(id),
  jefe_id              INT NULL     CONSTRAINT fk_empleados_jefe         REFERENCES rrhh.empleados(id),
  cliente_asignado_id  INT NULL     CONSTRAINT fk_empleados_cliente      REFERENCES dbo.clientes(id),
  fecha_ingreso        DATE NOT NULL,
  salario_base         DECIMAL(12,2) NOT NULL,
  estado               NVARCHAR(12) NULL CONSTRAINT df_empleados_estado DEFAULT N'activo'
                       CONSTRAINT ck_empleados_estado CHECK (estado IN (N'activo', N'baja_medica', N'vacaciones', N'despedido')),
  permisos             NVARCHAR(200) NOT NULL CONSTRAINT df_empleados_permisos DEFAULT N'lectura',
  notas                NVARCHAR(MAX) NULL
);
CREATE INDEX idx_departamento ON rrhh.empleados (departamento_id);
CREATE INDEX idx_estado       ON rrhh.empleados (estado);

CREATE TABLE rrhh.nominas (
  id             BIGINT IDENTITY(1,1) NOT NULL CONSTRAINT pk_nominas PRIMARY KEY,
  empleado_id    INT NOT NULL CONSTRAINT fk_nominas_empleado REFERENCES rrhh.empleados(id) ON DELETE CASCADE,
  periodo        NCHAR(7) NOT NULL,
  bruto          DECIMAL(12,2) NOT NULL,
  impuestos      DECIMAL(12,2) NOT NULL,
  neto           AS (bruto - impuestos) PERSISTED,
  estado         NVARCHAR(10) NULL CONSTRAINT df_nominas_estado DEFAULT N'borrador'
                 CONSTRAINT ck_nominas_estado CHECK (estado IN (N'borrador', N'emitida', N'pagada', N'anulada')),
  documento_pdf  VARBINARY(MAX) NULL,
  emitida_en     DATETIMEOFFSET(0) NULL,
  CONSTRAINT uk_empleado_periodo UNIQUE (empleado_id, periodo)
);
CREATE INDEX idx_periodo ON rrhh.nominas (periodo);
GO

EXEC sys.sp_addextendedproperty @name = N'MS_Description', @value = N'YYYY-MM',
     @level0type = N'SCHEMA', @level0name = N'rrhh',
     @level1type = N'TABLE',  @level1name = N'nominas',
     @level2type = N'COLUMN', @level2name = N'periodo';
EXEC sys.sp_addextendedproperty @name = N'MS_Description', @value = N'Varios permisos por empleado, separados por comas: SQL Server no tiene arrays',
     @level0type = N'SCHEMA', @level0name = N'rrhh',
     @level1type = N'TABLE',  @level1name = N'empleados',
     @level2type = N'COLUMN', @level2name = N'permisos';
EXEC sys.sp_addextendedproperty @name = N'MS_Description', @value = N'Comercial que lleva el cliente; la foránea que cruza de rrhh a dbo',
     @level0type = N'SCHEMA', @level0name = N'rrhh',
     @level1type = N'TABLE',  @level1name = N'empleados',
     @level2type = N'COLUMN', @level2name = N'cliente_asignado_id';
GO
MSSQL_SEED_21_TABLES_RRHH_EOF

  cat > "${MSSQL_SEED_DIR}/30-views.sql" <<'MSSQL_SEED_30_VIEWS_EOF'
-- 30-views.sql — SQL Server
--
-- Las mismas cuatro vistas y las mismas combinaciones. Lo que cambia:
--
--   security_invoker       → sin equivalente. En SQL Server una vista se
--       resuelve por encadenamiento de propietarios: si la vista y la tabla
--       tienen el mismo dueño, no se comprueban permisos sobre la tabla. No es
--       una opción del DDL.
--   WITH CASCADED CHECK OPTION → `WITH CHECK OPTION`, que aquí siempre es en
--       cascada.
--   `v_top_productos` va `WITH SCHEMABINDING`, que es lo que este motor tiene y
--       los otros no: impide alterar las tablas de debajo mientras la vista
--       exista, y es el requisito de una vista indexada. Obliga a nombrar las
--       tablas con su esquema y las columnas una a una.
--   `CREATE OR ALTER`      → el equivalente de `CREATE OR REPLACE`. Cada vista
--       tiene que ser la primera sentencia de su lote: de ahí los `GO`.

USE demo;
GO

-- 1) Ranking de productos por pedidos. GROUP BY → no actualizable.
CREATE OR ALTER VIEW dbo.v_top_productos
WITH SCHEMABINDING AS
SELECT
  p.id,
  p.nombre,
  p.categoria,
  COUNT_BIG(pe.id)            AS total_pedidos,
  COALESCE(SUM(pe.total), 0)  AS ingresos
FROM dbo.productos p
LEFT JOIN dbo.pedidos pe ON pe.producto_id = p.id
GROUP BY p.id, p.nombre, p.categoria;
GO

-- 2) Pedidos recientes (últimos 30 días) — actualizable, con CHECK OPTION.
CREATE OR ALTER VIEW dbo.v_pedidos_recientes AS
SELECT
  id, cliente_id, producto_id, cantidad, total, estado, creado_en
FROM dbo.pedidos
WHERE creado_en >= DATEADD(DAY, -30, SYSDATETIMEOFFSET())
WITH CHECK OPTION;
GO

-- 3) Clientes activos.
CREATE OR ALTER VIEW dbo.v_clientes_activos AS
SELECT
  c.id,
  c.nombre,
  c.email,
  c.ciudad,
  COUNT(pe.id) AS pedidos_totales
FROM dbo.clientes c
LEFT JOIN dbo.pedidos pe ON pe.cliente_id = c.id
GROUP BY c.id, c.nombre, c.email, c.ciudad;
GO

-- 4) Resumen de ventas vía UNION — no actualizable por definición.
CREATE OR ALTER VIEW dbo.v_resumen_ventas AS
SELECT CAST(N'pendiente' AS NVARCHAR(10)) AS estado, COUNT(*) AS pedidos, COALESCE(SUM(total), 0) AS monto
  FROM dbo.pedidos WHERE estado = N'pendiente'
UNION ALL
SELECT N'enviado',   COUNT(*), COALESCE(SUM(total), 0)
  FROM dbo.pedidos WHERE estado = N'enviado'
UNION ALL
SELECT N'entregado', COUNT(*), COALESCE(SUM(total), 0)
  FROM dbo.pedidos WHERE estado = N'entregado'
UNION ALL
SELECT N'cancelado', COUNT(*), COALESCE(SUM(total), 0)
  FROM dbo.pedidos WHERE estado = N'cancelado';
GO
MSSQL_SEED_30_VIEWS_EOF

  cat > "${MSSQL_SEED_DIR}/40-routines.sql" <<'MSSQL_SEED_40_ROUTINES_EOF'
-- 40-routines.sql — SQL Server
--
-- Los mismos seis objetos con los mismos nombres. Reescritura en T-SQL, no
-- traducción. Lo que hay que saber:
--
--   · `sp_reporte_ventas` vuelve a ser un PROCEDIMIENTO, como en MySQL: en
--     SQL Server un procedimiento sí devuelve un conjunto de filas con un
--     `SELECT` suelto. Se invoca con `EXEC`, no con `SELECT … FROM`.
--   · Parámetros de salida: `OUTPUT`, y quien llama también escribe `OUTPUT`
--     o el valor no vuelve. `INOUT` de Postgres es lo mismo.
--   · `SECURITY DEFINER`   → `WITH EXECUTE AS OWNER`.
--   · `IMMUTABLE`          → `WITH SCHEMABINDING` en las funciones escalares
--     que no leen tablas: es lo que le deja a SQL Server marcarlas como
--     deterministas (`OBJECTPROPERTY(…, 'IsDeterministic')`).
--   · `RETURNING … INTO`   → `SCOPE_IDENTITY()`, que es el de este ámbito y no
--     ve los ids que inserte un disparador (el de auditoría inserta en
--     `log_auditoria` en el mismo `INSERT`: `@@IDENTITY` devolvería ése).
--   · Los nombres `sp_…` en SQL Server tienen un coste: el motor los busca
--     primero en `master`. Se conservan porque los pasos de QA los buscan por
--     nombre en los tres motores.
--   · `COMMENT ON`         → `MS_Description`, al final.

USE demo;
GO

CREATE OR ALTER PROCEDURE dbo.sp_registrar_pedido
    @in_cliente_id  INT,
    @in_producto_id INT,
    @in_cantidad    INT,
    @out_pedido_id  INT OUTPUT
WITH EXECUTE AS OWNER
AS
BEGIN
    SET NOCOUNT ON;
    DECLARE @v_precio DECIMAL(10,2);
    SELECT @v_precio = precio FROM dbo.productos WHERE id = @in_producto_id;

    IF @v_precio IS NULL
        SET @out_pedido_id = NULL;
    ELSE
    BEGIN
        INSERT INTO dbo.pedidos (cliente_id, producto_id, cantidad, total, estado)
        VALUES (@in_cliente_id, @in_producto_id, @in_cantidad,
                @v_precio * @in_cantidad, N'pendiente');
        SET @out_pedido_id = CAST(SCOPE_IDENTITY() AS INT);
    END
END;
GO

CREATE OR ALTER PROCEDURE dbo.sp_reporte_ventas
    @in_desde DATE,
    @in_hasta DATE
WITH EXECUTE AS OWNER
AS
BEGIN
    SET NOCOUNT ON;
    SELECT p.estado,
           COUNT_BIG(*)              AS pedidos,
           COALESCE(SUM(p.total), 0) AS monto
      FROM dbo.pedidos p
     WHERE CAST(p.creado_en AS DATE) BETWEEN @in_desde AND @in_hasta
     GROUP BY p.estado;
END;
GO

CREATE OR ALTER PROCEDURE dbo.sp_ajustar_stock
    @in_producto_id INT,
    @io_delta       INT OUTPUT
WITH EXECUTE AS OWNER
AS
BEGIN
    SET NOCOUNT ON;
    UPDATE dbo.productos
       SET stock = CASE WHEN stock + @io_delta < 0 THEN 0 ELSE stock + @io_delta END
     WHERE id = @in_producto_id;
    SELECT @io_delta = stock FROM dbo.productos WHERE id = @in_producto_id;
END;
GO

CREATE OR ALTER FUNCTION dbo.fn_precio_con_iva(@p_precio DECIMAL(10,2), @p_iva_pct DECIMAL(5,2))
RETURNS DECIMAL(10,2)
WITH SCHEMABINDING
AS
BEGIN
    RETURN ROUND(@p_precio * (1 + @p_iva_pct / 100), 2);
END;
GO

CREATE OR ALTER FUNCTION dbo.fn_stock_producto(@p_producto_id INT)
RETURNS INT
WITH EXECUTE AS OWNER
AS
BEGIN
    DECLARE @v_stock INT;
    SELECT @v_stock = stock FROM dbo.productos WHERE id = @p_producto_id;
    RETURN COALESCE(@v_stock, 0);
END;
GO

CREATE OR ALTER FUNCTION dbo.fn_slugify(@p_texto NVARCHAR(200))
RETURNS NVARCHAR(200)
WITH SCHEMABINDING
AS
BEGIN
    RETURN LOWER(REPLACE(REPLACE(TRIM(@p_texto), N' ', N'-'), N'_', N'-'));
END;
GO

EXEC sys.sp_addextendedproperty N'MS_Description', N'Registra un pedido calculando el total desde productos.precio',
     N'SCHEMA', N'dbo', N'PROCEDURE', N'sp_registrar_pedido';
EXEC sys.sp_addextendedproperty N'MS_Description', N'Resumen de ventas por estado en un rango; devuelve filas como en MySQL',
     N'SCHEMA', N'dbo', N'PROCEDURE', N'sp_reporte_ventas';
EXEC sys.sp_addextendedproperty N'MS_Description', N'Aplica un delta al stock; devuelve en @io_delta el stock resultante',
     N'SCHEMA', N'dbo', N'PROCEDURE', N'sp_ajustar_stock';
EXEC sys.sp_addextendedproperty N'MS_Description', N'Aplica un porcentaje de IVA sobre un precio base',
     N'SCHEMA', N'dbo', N'FUNCTION', N'fn_precio_con_iva';
EXEC sys.sp_addextendedproperty N'MS_Description', N'Devuelve el stock actual de un producto por id',
     N'SCHEMA', N'dbo', N'FUNCTION', N'fn_stock_producto';
EXEC sys.sp_addextendedproperty N'MS_Description', N'Normaliza un texto a slug en minúsculas',
     N'SCHEMA', N'dbo', N'FUNCTION', N'fn_slugify';
GO
MSSQL_SEED_40_ROUTINES_EOF

  cat > "${MSSQL_SEED_DIR}/50-triggers.sql" <<'MSSQL_SEED_50_TRIGGERS_EOF'
-- 50-triggers.sql — SQL Server
--
-- Los mismos comportamientos que en Postgres, y aquí la diferencia es de
-- modelo, no de sintaxis:
--
--   · **Un disparador de SQL Server es por SENTENCIA, no por fila.** No hay
--     `FOR EACH ROW`: el cuerpo ve las pseudotablas `inserted` y `deleted` con
--     todas las filas de la sentencia, y se escribe en conjuntos. Un cuerpo que
--     lee `inserted` con `SELECT @x = …` funciona con una fila y pierde las
--     demás en silencio: es el fallo típico, y por eso aquí todo es `INSERT …
--     SELECT` y `UPDATE … JOIN`.
--   · **No hay `BEFORE`.** Sólo `AFTER` e `INSTEAD OF`. Lo que en Postgres
--     corrige la fila antes de escribirla aquí la corrige después, con un
--     `UPDATE` sobre lo recién escrito. Con `RECURSIVE_TRIGGERS` apagado —el
--     valor por omisión— ese `UPDATE` no vuelve a disparar el mismo disparador.
--   · `WHEN (…)` del `CREATE TRIGGER` → la condición va en el `JOIN` entre
--     `inserted` y `deleted`.
--   · `jsonb_build_object`  → `JSON_OBJECT('clave': valor)`, desde la 2022.
--   · `sha256(convert_to(x, 'UTF8'))` → `HASHBYTES('SHA2_256', CAST(x AS
--     VARCHAR))`: el `CAST` a `VARCHAR` con la colación UTF-8 da los mismos
--     bytes que el UTF-8 de Postgres; sobre un `NVARCHAR` se hashearía UTF-16.
--   · `current_user`        → `SUSER_SNAME()`, el inicio de sesión. `USER_NAME()`
--     daría `dbo` para `sa`, que no dice quién fue.
--   · Sin el disparador de `gis.ciudades`: ese esquema no existe aquí.

USE demo;
GO

-- ── 1 y 2) Normalización del slug ───────────────────────────────────────────

CREATE OR ALTER TRIGGER dbo.bi_categorias_slug ON dbo.categorias
AFTER INSERT AS
BEGIN
    SET NOCOUNT ON;
    UPDATE c
       SET slug = dbo.fn_slugify(c.nombre)
      FROM dbo.categorias c
      JOIN inserted i ON i.id = c.id
     WHERE i.slug = N'';
END;
GO

CREATE OR ALTER TRIGGER dbo.bu_categorias_slug ON dbo.categorias
AFTER UPDATE AS
BEGIN
    SET NOCOUNT ON;
    UPDATE c
       SET slug = dbo.fn_slugify(c.nombre)
      FROM dbo.categorias c
      JOIN inserted i ON i.id = c.id
      JOIN deleted  d ON d.id = i.id
     WHERE i.nombre <> d.nombre AND i.slug = d.slug;
END;
GO

-- ── 3, 4 y 5) Auditoría de pedidos: sólo escribe en log_auditoria ───────────

CREATE OR ALTER TRIGGER dbo.ai_pedidos_audit ON dbo.pedidos
AFTER INSERT AS
BEGIN
    SET NOCOUNT ON;
    INSERT INTO dbo.log_auditoria (actor, entidad, entidad_id, accion, payload_json, payload_hash)
    SELECT SUSER_SNAME(), N'pedidos', i.id, N'INSERT',
           JSON_OBJECT('cliente_id': i.cliente_id, 'producto_id': i.producto_id,
                       'cantidad': i.cantidad, 'total': i.total, 'estado': i.estado),
           HASHBYTES('SHA2_256', CAST(CONCAT_WS('|', i.cliente_id, i.producto_id, i.total) AS VARCHAR(400)))
      FROM inserted i;
END;
GO

CREATE OR ALTER TRIGGER dbo.au_pedidos_audit ON dbo.pedidos
AFTER UPDATE AS
BEGIN
    SET NOCOUNT ON;
    INSERT INTO dbo.log_auditoria (actor, entidad, entidad_id, accion, payload_json, payload_hash)
    SELECT SUSER_SNAME(), N'pedidos', i.id, N'UPDATE',
           JSON_OBJECT('estado_anterior': d.estado, 'estado_nuevo': i.estado),
           HASHBYTES('SHA2_256', CAST(CONCAT_WS('|', i.id, d.estado, i.estado) AS VARCHAR(400)))
      FROM inserted i
      JOIN deleted  d ON d.id = i.id
     WHERE EXISTS (SELECT i.estado EXCEPT SELECT d.estado);  -- IS DISTINCT FROM, con NULL
END;
GO

CREATE OR ALTER TRIGGER dbo.ad_pedidos_audit ON dbo.pedidos
AFTER DELETE AS
BEGIN
    SET NOCOUNT ON;
    INSERT INTO dbo.log_auditoria (actor, entidad, entidad_id, accion, payload_json)
    SELECT SUSER_SNAME(), N'pedidos', d.id, N'DELETE',
           JSON_OBJECT('cliente_id': d.cliente_id, 'producto_id': d.producto_id,
                       'total': d.total, 'estado': d.estado)
      FROM deleted d;
END;
GO

-- ── 6) Auditoría de altas de cliente ────────────────────────────────────────

CREATE OR ALTER TRIGGER dbo.ai_clientes_audit ON dbo.clientes
AFTER INSERT AS
BEGIN
    SET NOCOUNT ON;
    INSERT INTO dbo.log_auditoria (actor, entidad, entidad_id, accion, payload_json)
    SELECT SUSER_SNAME(), N'clientes', i.id, N'INSERT',
           JSON_OBJECT('email': i.email, 'ciudad': i.ciudad)
      FROM inserted i;
END;
GO

-- ── 7) El equivalente de ON UPDATE CURRENT_TIMESTAMP ────────────────────────
-- `TRIGGER_NESTLEVEL` evita que el UPDATE de dentro, si alguien enciende los
-- disparadores recursivos, se vuelva un bucle.

CREATE OR ALTER TRIGGER dbo.bu_direcciones_touch ON dbo.direcciones
AFTER UPDATE AS
BEGIN
    SET NOCOUNT ON;
    IF TRIGGER_NESTLEVEL(@@PROCID) > 1 RETURN;
    UPDATE d
       SET actualizada = SYSDATETIMEOFFSET()
      FROM dbo.direcciones d
      JOIN inserted i ON i.id = d.id;
END;
GO
MSSQL_SEED_50_TRIGGERS_EOF

  cat > "${MSSQL_SEED_DIR}/60-solo-sqlserver.sql" <<'MSSQL_SEED_60_SOLO_SQLSERVER_EOF'
-- 60-solo-sqlserver.sql — lo que este motor tiene y los otros no
--
-- Cuatro cosas, y cada una está porque una herramienta de Calíope tiene que
-- saber enseñarla o respaldarla sin romperse:
--
--   · Un índice COLUMNSTORE no agrupado sobre `dbo.pedido_lineas`. Aparece en
--     `sys.indexes` con `type = 6`, no tiene columnas «de clave» y un
--     comparador de esquemas que sólo sepa de B-tree lo pierde.
--   · Una columna calculada PERSISTED: `rrhh.nominas.neto`, en 21-tables-rrhh.sql.
--   · Una SEQUENCE independiente de toda tabla: `dbo.seq_referencia_pedido`.
--     `IDENTITY` es una propiedad de la columna; la secuencia es un objeto de
--     esquema con su propio `CREATE`, y el respaldo tiene que llevarla.
--   · Un tipo `geography`: `dbo.direcciones.ubicacion`, en 20-tables-dbo.sql,
--     con su índice espacial.

USE demo;
GO

CREATE NONCLUSTERED COLUMNSTORE INDEX ncci_pedido_lineas
    ON dbo.pedido_lineas (pedido_id, producto_id, cantidad, precio_unit, descuento_pct);
GO

CREATE SEQUENCE dbo.seq_referencia_pedido
    AS BIGINT
    START WITH 1000
    INCREMENT BY 1
    CACHE 10;
GO
MSSQL_SEED_60_SOLO_SQLSERVER_EOF

  cat > "${MSSQL_SEED_DIR}/70-seed-dbo.sql" <<'MSSQL_SEED_70_SEED_DBO_EOF'
-- 70-seed-dbo.sql — SQL Server
--
-- Las mismas filas que el seed de Postgres, con los mismos ids, para que el
-- recuento por tabla coincida y la comparación signifique algo.
--
--   · Ids explícitos → `SET IDENTITY_INSERT … ON` tabla a tabla (sólo puede
--     haber una encendida por sesión) y una lista de columnas obligatoria.
--   · `ST_GeomFromText` → `geography::STGeomFromText(…, 4326)`, en el mismo
--     orden (longitud latitud).
--   · Todo literal de texto con `N'…'`.
--   · `log_auditoria` no se siembra: la llenan los disparadores de 50, igual
--     que en Postgres (4 altas de cliente + 6 de pedido = 10 filas).

USE demo;
GO

SET IDENTITY_INSERT dbo.productos ON;
INSERT INTO dbo.productos (id, nombre, categoria, precio, stock) VALUES
  (1, N'Widget A',           N'accesorios',    9.99, 100),
  (2, N'Widget B',           N'accesorios',   19.99,  50),
  (3, N'Gadget Pro',         N'electronica',  49.99,  25),
  (4, N'Cable USB-C',        N'cables',        4.99, 200),
  (5, N'Adaptador HDMI',     N'cables',       14.99,  75),
  (6, N'Teclado mecánico',   N'periféricos',  89.99,  30),
  (7, N'Ratón inalámbrico',  N'periféricos',  34.99,  60),
  (8, N'Monitor 24"',        N'pantallas',   249.99,  10);
SET IDENTITY_INSERT dbo.productos OFF;

SET IDENTITY_INSERT dbo.clientes ON;
INSERT INTO dbo.clientes (id, nombre, email, ciudad) VALUES
  (1, N'Ana García',    N'ana@ejemplo.com',    N'Madrid'),
  (2, N'Luis Mora',     N'luis@ejemplo.com',   N'San José'),
  (3, N'María Castro',  N'maria@ejemplo.com',  N'Ciudad de México'),
  (4, N'Carlos Ruiz',   N'carlos@ejemplo.com', N'Buenos Aires');
SET IDENTITY_INSERT dbo.clientes OFF;

SET IDENTITY_INSERT dbo.pedidos ON;
INSERT INTO dbo.pedidos (id, cliente_id, producto_id, cantidad, total, estado) VALUES
  (1, 1, 1, 5,  49.95, N'entregado'),
  (2, 2, 3, 2,  99.98, N'enviado'),
  (3, 3, 2, 1,  19.99, N'pendiente'),
  (4, 4, 5, 3,  44.97, N'entregado'),
  (5, 1, 6, 1,  89.99, N'enviado'),
  (6, 2, 7, 2,  69.98, N'pendiente');
SET IDENTITY_INSERT dbo.pedidos OFF;

SET IDENTITY_INSERT dbo.categorias ON;
INSERT INTO dbo.categorias (id, slug, nombre, descripcion, padre_id, activa) VALUES
  (1, N'electronica',   N'Electrónica',   N'Dispositivos electrónicos varios', NULL, 1),
  (2, N'cables',        N'Cables',        N'Cables y conectores',              1,    1),
  (3, N'perifericos',   N'Periféricos',   N'Teclados, ratones, etc.',          1,    1),
  (4, N'pantallas',     N'Pantallas',     N'Monitores y proyectores',          1,    1),
  (5, N'accesorios',    N'Accesorios',    N'Complementos generales',           NULL, 1),
  (6, N'descatalogado', N'Descatalogado', N'Categoría obsoleta',               NULL, 0);
SET IDENTITY_INSERT dbo.categorias OFF;

SET IDENTITY_INSERT dbo.direcciones ON;
INSERT INTO dbo.direcciones (id, cliente_id, etiqueta, linea1, ciudad, pais, metadatos, ubicacion) VALUES
  (1, 1, N'principal', N'Calle Mayor 12',      N'Madrid',           N'ES',
     JSON_OBJECT('piso': 3, 'puerta': 'B'),  geography::STGeomFromText('POINT(-3.7038 40.4168)', 4326)),
  (2, 2, N'principal', N'Av. Central 88',      N'San José',         N'CR',
     JSON_OBJECT('cerca': 'ICE'),            geography::STGeomFromText('POINT(-84.0907 9.9281)', 4326)),
  (3, 3, N'trabajo',   N'Reforma 500 piso 12', N'Ciudad de México', N'MX',
     JSON_OBJECT('oficina': 1204),           geography::STGeomFromText('POINT(-99.1332 19.4326)', 4326)),
  (4, 4, N'principal', N'Av. Corrientes 1200', N'Buenos Aires',     N'AR',
     JSON_OBJECT('notas': 'timbre 4B'),      geography::STGeomFromText('POINT(-58.3816 -34.6037)', 4326));
SET IDENTITY_INSERT dbo.direcciones OFF;

SET IDENTITY_INSERT dbo.pedido_lineas ON;
INSERT INTO dbo.pedido_lineas (id, pedido_id, producto_id, cantidad, precio_unit, descuento_pct) VALUES
  (1, 1, 1, 5,   9.99,  0.00),
  (2, 2, 3, 2,  49.99,  5.00),
  (3, 3, 2, 1,  19.99,  0.00),
  (4, 4, 5, 3,  14.99,  0.00),
  (5, 5, 6, 1,  89.99, 10.00),
  (6, 6, 7, 2,  34.99,  0.00);
SET IDENTITY_INSERT dbo.pedido_lineas OFF;
GO
MSSQL_SEED_70_SEED_DBO_EOF

  cat > "${MSSQL_SEED_DIR}/71-seed-rrhh.sql" <<'MSSQL_SEED_71_SEED_RRHH_EOF'
-- 71-seed-rrhh.sql — SQL Server
--
-- Las mismas filas que la base `rrhh` de Postgres. `permisos` va como lista
-- separada por comas (en Postgres, `'{lectura,escritura}'`), y
-- `cliente_asignado_id` —la foránea que cruza a `dbo`, ver 21-tables-rrhh.sql—
-- se rellena en los dos comerciales y queda NULL en el resto.

USE demo;
GO

SET IDENTITY_INSERT rrhh.departamentos ON;
INSERT INTO rrhh.departamentos (id, codigo, nombre, presupuesto) VALUES
  (1, N'DIR', N'Dirección',        250000.00),
  (2, N'ING', N'Ingeniería',       450000.00),
  (3, N'COM', N'Comercial',        180000.00),
  (4, N'RRH', N'Recursos Humanos',  90000.00);
SET IDENTITY_INSERT rrhh.departamentos OFF;

SET IDENTITY_INSERT rrhh.empleados ON;
INSERT INTO rrhh.empleados (id, nombre, email, departamento_id, jefe_id, cliente_asignado_id, fecha_ingreso, salario_base, estado, permisos, notas) VALUES
  (1, N'Ana García',      N'ana.g@empresa.test',    1, NULL, NULL, '2018-01-15', 8500.00, N'activo',      N'lectura,escritura,aprobacion,admin', N'CEO'),
  (2, N'Luis Mora',       N'luis.m@empresa.test',   2, 1,    NULL, '2019-03-22', 6200.00, N'activo',      N'lectura,escritura,aprobacion',       N'Director de Ingeniería'),
  (3, N'María Castro',    N'maria.c@empresa.test',  3, 1,    3,    '2019-07-10', 5800.00, N'activo',      N'lectura,escritura,aprobacion',       N'Directora Comercial'),
  (4, N'Carlos Ruiz',     N'carlos.r@empresa.test', 2, 2,    NULL, '2020-05-04', 4200.00, N'activo',      N'lectura,escritura',                  N'Backend'),
  (5, N'Sofía López',     N'sofia.l@empresa.test',  2, 2,    NULL, '2021-09-18', 3800.00, N'vacaciones',  N'lectura,escritura',                  N'Frontend'),
  (6, N'Diego Fernández', N'diego.f@empresa.test',  3, 3,    1,    '2022-02-14', 3200.00, N'baja_medica', N'lectura',                            N'Ventas región norte'),
  (7, N'Elena Vega',      N'elena.v@empresa.test',  4, 1,    NULL, '2020-11-30', 4500.00, N'activo',      N'lectura,escritura,aprobacion',       N'Responsable de RRHH'),
  (8, N'Marco Antonelli', N'marco.a@empresa.test',  2, 2,    NULL, '2023-06-01', 3600.00, N'despedido',   N'lectura',                            N'Baja disciplinaria');
SET IDENTITY_INSERT rrhh.empleados OFF;

SET IDENTITY_INSERT rrhh.nominas ON;
INSERT INTO rrhh.nominas (id, empleado_id, periodo, bruto, impuestos, estado, emitida_en) VALUES
  (1, 1, N'2026-05', 8500.00, 2380.00, N'pagada',   '2026-05-31 18:00:00 +00:00'),
  (2, 2, N'2026-05', 6200.00, 1550.00, N'pagada',   '2026-05-31 18:05:00 +00:00'),
  (3, 3, N'2026-05', 5800.00, 1450.00, N'pagada',   '2026-05-31 18:10:00 +00:00'),
  (4, 4, N'2026-05', 4200.00, 1050.00, N'emitida',  '2026-05-31 18:15:00 +00:00'),
  (5, 5, N'2026-05', 3800.00,  950.00, N'emitida',  '2026-05-31 18:20:00 +00:00'),
  (6, 7, N'2026-05', 4500.00, 1125.00, N'borrador', NULL);
SET IDENTITY_INSERT rrhh.nominas OFF;
GO
MSSQL_SEED_71_SEED_RRHH_EOF
  # ── Seed de ClickHouse (plan 34, tarea 2) ─────────────────────────────────
  # Escrito para ClickHouse, no traducido del de Postgres: cada archivo dice en
  # su cabecera qué forma tiene aquí y por qué. Va DENTRO de la imagen del nodo
  # `clickhouse` (`Dockerfile.seeded`, destino `/docker-entrypoint-initdb.d`), y
  # el entrypoint de la imagen lo aplica en orden con la cuenta de arranque,
  # sólo cuando el volumen de datos está vacío.
  cat > "${CLICKHOUSE_SEED_DIR}/10-tablas-comunes.sql" <<'CH_SEED_10_TABLAS_COMUNES_EOF'
-- 10-tablas-comunes.sql — ClickHouse
--
-- Las siete tablas del esquema `demo` de Postgres, con los mismos nombres, los
-- mismos ids y las mismas filas: la tarea 46 del plan 34 compara recuentos
-- motor contra motor, y no hay recuento que comparar si las tablas difieren.
-- Escritas para ClickHouse, no traducidas. Lo que tiene otra forma aquí:
--
--   PRIMARY KEY / IDENTITY → no hay. Cada tabla es `MergeTree` con su `ORDER BY`,
--                            que es la llave de ordenamiento y el índice
--                            primario disperso; el id va explícito en el INSERT
--                            porque el motor no numera filas.
--   FOREIGN KEY            → no hay, y no se finge: el motor no tiene
--                            integridad referencial. `pedidos.cliente_id` es un
--                            número y nada más.
--   UNIQUE                 → no hay. `clientes.email` puede repetirse.
--   ENUM                   → `Enum8`, que sí existe aquí.
--   boolean                → `Bool`.
--   jsonb                  → `String` con el texto JSON. El tipo `JSON` de
--                            ClickHouse guarda rutas tipadas y reescribe el
--                            documento; aquí interesa el texto tal cual.
--   geometry(Point)        → `Point`, que es `Tuple(Float64, Float64)` en
--                            (longitud, latitud), el mismo orden que el WKT.
--   bytea                  → `String`: aquí una cadena es una secuencia de bytes.
--   timestamptz            → `DateTime64(…, 'UTC')`: la zona va en el tipo.
--   ON UPDATE / DEFAULT    → `DEFAULT now64()`: sólo al insertar; no hay
--                            disparadores que lo actualicen.

USE demo;

CREATE TABLE productos (
  id         UInt32,
  nombre     String,
  categoria  LowCardinality(String) DEFAULT 'general',
  precio     Decimal(10, 2),
  stock      Int32 DEFAULT 0,
  activo     Bool DEFAULT true,
  creado_en  DateTime64(6, 'UTC') DEFAULT now64(6)
) ENGINE = MergeTree ORDER BY id
COMMENT 'Catálogo de productos; el mismo de los otros motores';

CREATE TABLE clientes (
  id         UInt32,
  nombre     String,
  email      String,
  ciudad     Nullable(String),
  creado_en  DateTime64(6, 'UTC') DEFAULT now64(6)
) ENGINE = MergeTree ORDER BY id;

CREATE TABLE pedidos (
  id           UInt32,
  cliente_id   UInt32,
  producto_id  UInt32,
  cantidad     Int32,
  total        Decimal(10, 2),
  estado       Enum8('pendiente' = 1, 'enviado' = 2, 'entregado' = 3, 'cancelado' = 4) DEFAULT 'pendiente',
  creado_en    DateTime64(6, 'UTC') DEFAULT now64(6)
) ENGINE = MergeTree ORDER BY id;

CREATE TABLE categorias (
  id           UInt32,
  slug         String,
  nombre       String,
  descripcion  Nullable(String),
  padre_id     Nullable(UInt32),
  activa       Bool DEFAULT true,
  creada_en    DateTime64(6, 'UTC') DEFAULT now64(6)
) ENGINE = MergeTree ORDER BY id;

CREATE TABLE direcciones (
  id            UInt32,
  cliente_id    UInt32,
  etiqueta      String DEFAULT 'principal',
  linea1        String,
  linea2        Nullable(String),
  ciudad        String,
  pais          FixedString(2),
  metadatos     Nullable(String),
  ubicacion     Point,
  actualizada   DateTime64(6, 'UTC') DEFAULT now64(6)
) ENGINE = MergeTree ORDER BY id;

CREATE TABLE pedido_lineas (
  id             UInt32,
  pedido_id      UInt32,
  producto_id    UInt32,
  cantidad       Int32 DEFAULT 1,
  precio_unit    Decimal(10, 2),
  descuento_pct  Decimal(5, 2) DEFAULT 0
) ENGINE = MergeTree ORDER BY (pedido_id, id);

CREATE TABLE log_auditoria (
  id            UInt64,
  ocurrido_en   DateTime64(3, 'UTC') DEFAULT now64(3),
  actor         Nullable(String),
  entidad       LowCardinality(String),
  entidad_id    Nullable(UInt64),
  accion        Enum8('INSERT' = 1, 'UPDATE' = 2, 'DELETE' = 3),
  payload_json  Nullable(String),
  payload_hash  Nullable(String)
) ENGINE = MergeTree ORDER BY (entidad, ocurrido_en, id);

INSERT INTO productos (id, nombre, categoria, precio, stock) VALUES
  (1, 'Widget A',          'accesorios',   9.99,  100),
  (2, 'Widget B',          'accesorios',   19.99, 50),
  (3, 'Gadget Pro',        'electronica',  49.99, 25),
  (4, 'Cable USB-C',       'cables',       4.99,  200),
  (5, 'Adaptador HDMI',    'cables',       14.99, 75),
  (6, 'Teclado mecánico',  'periféricos',  89.99, 30),
  (7, 'Ratón inalámbrico', 'periféricos',  34.99, 60),
  (8, 'Monitor 24"',       'pantallas',    249.99, 10);

INSERT INTO clientes (id, nombre, email, ciudad) VALUES
  (1, 'Ana García',   'ana@ejemplo.com',    'Madrid'),
  (2, 'Luis Mora',    'luis@ejemplo.com',   'San José'),
  (3, 'María Castro', 'maria@ejemplo.com',  'Ciudad de México'),
  (4, 'Carlos Ruiz',  'carlos@ejemplo.com', 'Buenos Aires');

INSERT INTO pedidos (id, cliente_id, producto_id, cantidad, total, estado) VALUES
  (1, 1, 1, 5, 49.95, 'entregado'),
  (2, 2, 3, 2, 99.98, 'enviado'),
  (3, 3, 2, 1, 19.99, 'pendiente'),
  (4, 4, 5, 3, 44.97, 'entregado'),
  (5, 1, 6, 1, 89.99, 'enviado'),
  (6, 2, 7, 2, 69.98, 'pendiente');

INSERT INTO categorias (id, slug, nombre, descripcion, padre_id, activa) VALUES
  (1, 'electronica',   'Electrónica',   'Dispositivos electrónicos varios', NULL, true),
  (2, 'cables',        'Cables',        'Cables y conectores',              1,    true),
  (3, 'perifericos',   'Periféricos',   'Teclados, ratones, etc.',          1,    true),
  (4, 'pantallas',     'Pantallas',     'Monitores y proyectores',          1,    true),
  (5, 'accesorios',    'Accesorios',    'Complementos generales',           NULL, true),
  (6, 'descatalogado', 'Descatalogado', 'Categoría obsoleta',               NULL, false);

INSERT INTO direcciones (id, cliente_id, etiqueta, linea1, ciudad, pais, metadatos, ubicacion) VALUES
  (1, 1, 'principal', 'Calle Mayor 12',       'Madrid',           'ES', '{"piso": 3, "puerta": "B"}', (-3.7038, 40.4168)),
  (2, 2, 'principal', 'Av. Central 88',       'San José',         'CR', '{"cerca": "ICE"}',           (-84.0907, 9.9281)),
  (3, 3, 'trabajo',   'Reforma 500 piso 12',  'Ciudad de México', 'MX', '{"oficina": 1204}',          (-99.1332, 19.4326)),
  (4, 4, 'principal', 'Av. Corrientes 1200',  'Buenos Aires',     'AR', '{"notas": "timbre 4B"}',     (-58.3816, -34.6037));

INSERT INTO pedido_lineas (id, pedido_id, producto_id, cantidad, precio_unit, descuento_pct) VALUES
  (1, 1, 1, 5, 9.99,  0.00),
  (2, 2, 3, 2, 49.99, 5.00),
  (3, 3, 2, 1, 19.99, 0.00),
  (4, 4, 5, 3, 14.99, 0.00),
  (5, 5, 6, 1, 89.99, 10.00),
  (6, 6, 7, 2, 34.99, 0.00);

-- La auditoría la escriben disparadores en los otros motores; aquí no hay
-- disparadores, así que las diez filas van escritas, con el hash SHA-256 del
-- JSON de cada pedido calculado por el servidor como en Postgres.
INSERT INTO log_auditoria (id, actor, entidad, entidad_id, accion, payload_json, payload_hash)
SELECT id, 'caliope', entidad, entidad_id, 'INSERT', payload,
       if(entidad = 'pedidos', SHA256(payload), NULL)
FROM VALUES('id UInt64, entidad String, entidad_id UInt64, payload String',
  (1,  'clientes', 1, '{"email": "ana@ejemplo.com", "ciudad": "Madrid"}'),
  (2,  'clientes', 2, '{"email": "luis@ejemplo.com", "ciudad": "San José"}'),
  (3,  'clientes', 3, '{"email": "maria@ejemplo.com", "ciudad": "Ciudad de México"}'),
  (4,  'clientes', 4, '{"email": "carlos@ejemplo.com", "ciudad": "Buenos Aires"}'),
  (5,  'pedidos',  1, '{"total": 49.95, "estado": "entregado", "cantidad": 5, "cliente_id": 1, "producto_id": 1}'),
  (6,  'pedidos',  2, '{"total": 99.98, "estado": "enviado", "cantidad": 2, "cliente_id": 2, "producto_id": 3}'),
  (7,  'pedidos',  3, '{"total": 19.99, "estado": "pendiente", "cantidad": 1, "cliente_id": 3, "producto_id": 2}'),
  (8,  'pedidos',  4, '{"total": 44.97, "estado": "entregado", "cantidad": 3, "cliente_id": 4, "producto_id": 5}'),
  (9,  'pedidos',  5, '{"total": 89.99, "estado": "enviado", "cantidad": 1, "cliente_id": 1, "producto_id": 6}'),
  (10, 'pedidos',  6, '{"total": 69.98, "estado": "pendiente", "cantidad": 2, "cliente_id": 2, "producto_id": 7}'));
CH_SEED_10_TABLAS_COMUNES_EOF

  cat > "${CLICKHOUSE_SEED_DIR}/20-solo-clickhouse.sql" <<'CH_SEED_20_SOLO_CLICKHOUSE_EOF'
-- 20-solo-clickhouse.sql — ClickHouse
--
-- Lo que este motor tiene y los otros no: lo que la interfaz tiene que saber
-- ENSEÑAR con su nombre (plan 34, §B.8) y lo que hace verificable cada tarea
-- del plan. Cada objeto dice qué tarea lo usa.
--
-- Lo que NO se siembra, y por qué: ni claves foráneas, ni disparadores, ni
-- procedimientos almacenados, ni eventos programados. El motor no los tiene, y
-- sembrar algo parecido por simetría con los otros motores produciría
-- cobertura aparente: una pantalla que «enseña disparadores» de un motor sin
-- disparadores. Lo más cercano a un disparador, la vista materializada, sí va,
-- y con su nombre.

USE demo;

-- Particionada por mes, con tres meses de datos para que la partición no sea
-- una sola (tareas 25, 36, 37 y 47). Lleva `caliope-buscame` en `mensaje`
-- (tarea 42) y alimenta las vistas materializadas de más abajo.
CREATE TABLE eventos (
  id       UInt64,
  fecha    DateTime('UTC'),
  tipo     LowCardinality(String),
  usuario  String,
  mensaje  String
) ENGINE = MergeTree
PARTITION BY toYYYYMM(fecha)
ORDER BY (fecha, id)
COMMENT 'Eventos de aplicación, una partición por mes';

-- Una `ReplacingMergeTree`: al fusionar partes se queda con la fila de mayor
-- `version` por llave. Lleva `caliope-buscame` en `nota` (tarea 42), en la
-- versión que sobrevive: en la que pierde, la fusión se la lleva (medido).
CREATE TABLE inventario_versiones (
  producto_id  UInt32,
  stock        Int32,
  nota         String,
  version      UInt32
) ENGINE = ReplacingMergeTree(version)
ORDER BY producto_id;

-- Una `SummingMergeTree`: al fusionar suma las columnas numéricas de las filas
-- con la misma llave.
CREATE TABLE ventas_sumadas (
  dia          Date,
  producto_id  UInt32,
  unidades     UInt64,
  importe      Decimal(18, 2)
) ENGINE = SummingMergeTree
ORDER BY (dia, producto_id);

-- Con índice de salto: descarta gránulos, no busca filas (§B.8). Tareas 25 y 47.
CREATE TABLE lecturas_sensor (
  sensor_id  UInt32,
  medido_en  DateTime('UTC'),
  valor      Float64,
  INDEX idx_valor valor TYPE minmax GRANULARITY 4
) ENGINE = MergeTree
ORDER BY (sensor_id, medido_en);

-- Con TTL de filas: lo que pasa de un año se borra solo al fusionar. Las filas
-- se siembran relativas al momento de crear el volumen, así que viven un año.
-- Lleva `caliope-buscame` en `agente` (tarea 42).
CREATE TABLE sesiones_ttl (
  id      UInt64,
  inicio  DateTime('UTC'),
  agente  String
) ENGINE = MergeTree
ORDER BY (inicio, id)
TTL inicio + INTERVAL 1 YEAR;

-- Los tipos que destapan los errores de lectura (§B.6.2 y §B.6.3), uno por
-- columna. La fila 1 lleva los extremos: el `UInt64` por encima de 2^53, la
-- `Decimal(38,10)` con 30 dígitos significativos, el `Int128` mínimo, `inf` y
-- `nan`. La 2, valores corrientes y el nulo. La 3 lleva la comilla simple
-- (`it's`), un salto de línea y la hora que no existe en Madrid (el cambio de
-- marzo); la 4, la barra invertida (`C:\ruta\nueva`), `1e300`, `-0.0` y la
-- hora que existe dos veces (el cambio de octubre): son los valores que la
-- tarea 8 escribe como literal y compara byte a byte. `texto_no_utf8` de la 1
-- son los bytes FF 00 FE, que por JSON llegan saneados y por `SQLInsert`
-- enteros (tarea 17).
CREATE TABLE tipos_raros (
  id             UInt32,
  etiqueta       LowCardinality(String),
  quiza          Nullable(Int64),
  etiquetas      Array(String),
  contadores     Map(String, UInt64),
  importe        Decimal(38, 10),
  momento        DateTime64(3, 'Europe/Madrid'),
  uid            UUID,
  nivel          Enum8('bajo' = 1, 'medio' = 2, 'alto' = 3),
  ip             IPv4,
  enorme         UInt64,
  muy_grande     Int128,
  infinito       Float64,
  no_numero      Float64,
  activo         Bool,
  texto_no_utf8  String
) ENGINE = MergeTree
ORDER BY id;

-- Un millón de filas, para que el respaldo (tarea 45), la transmisión (20) y el
-- progreso (16) midan contra algo sin fabricarlo cada vez.
CREATE TABLE grande (
  id        UInt64,
  grupo     UInt16,
  valor     Float64,
  etiqueta  String,
  creado    DateTime('UTC')
) ENGINE = MergeTree
ORDER BY id;

-- Las tres vistas materializadas que la tarea 49 tiene que distinguir. Se
-- crean ANTES de insertar en `eventos`: una vista materializada sólo ve lo que
-- se inserta después de existir.

-- 1. Con `TO`: escribe en una tabla destino que el usuario creó y nombra.
CREATE TABLE eventos_por_tipo (
  mes    UInt32,
  tipo   LowCardinality(String),
  total  UInt64
) ENGINE = SummingMergeTree
ORDER BY (mes, tipo);

CREATE MATERIALIZED VIEW mv_eventos_por_tipo TO eventos_por_tipo AS
SELECT toYYYYMM(fecha) AS mes, tipo, count() AS total
FROM eventos
GROUP BY mes, tipo;

-- 2. Sin `TO`: el servidor crea su tabla interna `.inner_id.<uuid>` en esta
-- base, que no es del usuario y no se cuenta como suya (§B.7, aviso 4).
CREATE MATERIALIZED VIEW mv_eventos_por_dia
ENGINE = SummingMergeTree ORDER BY dia AS
SELECT toDate(fecha) AS dia, count() AS total
FROM eventos
GROUP BY dia;

-- 3. Refrescable: recalcula la consulta entera cada hora y reemplaza el destino.
-- Aparece en `system.view_refreshes`.
CREATE TABLE resumen_clientes (
  cliente_id  UInt32,
  pedidos     UInt64,
  gastado     Decimal(18, 2)
) ENGINE = MergeTree
ORDER BY cliente_id;

CREATE MATERIALIZED VIEW mv_resumen_clientes
REFRESH EVERY 1 HOUR TO resumen_clientes AS
SELECT cliente_id, count() AS pedidos, sum(total) AS gastado
FROM pedidos
GROUP BY cliente_id;

-- Una vista normal.
CREATE VIEW v_pedidos_detalle AS
SELECT p.id, c.nombre AS cliente, pr.nombre AS producto, p.cantidad, p.total, p.estado
FROM pedidos AS p
INNER JOIN clientes AS c ON c.id = p.cliente_id
INNER JOIN productos AS pr ON pr.id = p.producto_id;

-- Un diccionario sobre una tabla del seed. Se conecta como la cuenta de
-- arranque: un diccionario de origen ClickHouse sin `USER` entra como
-- `default`, que esta imagen no deja usar.
CREATE DICTIONARY dic_productos (
  id      UInt64,
  nombre  String,
  precio  Decimal(10, 2)
)
PRIMARY KEY id
SOURCE(CLICKHOUSE(DB 'demo' TABLE 'productos' USER 'caliope' PASSWORD 'caliopepass'))
LAYOUT(FLAT())
LIFETIME(0);

-- Una función definida por el usuario. Es del servidor, no de la base.
CREATE FUNCTION precio_con_iva AS (precio) -> round(precio * 1.21, 2);

-- ── Datos ───────────────────────────────────────────────────────────────────

-- Tres meses fijos (julio, agosto y septiembre de 2026), 30 eventos por mes;
-- uno de cada diez lleva la cadena que busca la tarea 42.
INSERT INTO eventos
SELECT number + 1,
       toDateTime('2026-07-01 00:00:00', 'UTC') + toIntervalDay(intDiv(number, 30) * 31 + number % 28) + toIntervalHour(number % 24),
       ['login', 'logout', 'compra', 'error'][number % 4 + 1],
       concat('usuario', toString(number % 7)),
       if(number % 10 = 0, 'aviso caliope-buscame del sistema', concat('evento ', toString(number + 1)))
FROM numbers(90);

INSERT INTO inventario_versiones VALUES
  (1, 100, 'alta inicial', 1),
  (1, 95,  'venta de cinco', 2),
  (2, 50,  'alta inicial', 1),
  (3, 25,  'alta inicial', 1),
  (3, 20,  'revisar caliope-buscame', 2);

INSERT INTO ventas_sumadas VALUES
  ('2026-09-01', 1, 5, 49.95),
  ('2026-09-01', 1, 2, 19.98),
  ('2026-09-01', 3, 2, 99.98),
  ('2026-09-02', 5, 3, 44.97);

INSERT INTO lecturas_sensor
SELECT number % 10 + 1,
       toDateTime('2026-09-01 00:00:00', 'UTC') + toIntervalMinute(number),
       round(20 + 10 * sin(number / 50), 3)
FROM numbers(10000);

INSERT INTO sesiones_ttl
SELECT number + 1,
       now('UTC') - toIntervalHour(number * 6),
       if(number = 3, 'Calíope caliope-buscame/1.0', concat('Agente/', toString(number % 3 + 1), '.0'))
FROM numbers(12);

INSERT INTO tipos_raros VALUES
  (1, 'extremos', NULL, ['a', 'b', 'c'], {'uno': 1, 'max': 18446744073709551615},
   toDecimal128('12345678901234567890.1234567891', 10),
   toDateTime64('2026-09-30 10:00:00.123', 3, 'Europe/Madrid'),
   '6f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b', 'alto', '192.168.10.20',
   18446744073709551615, -170141183460469231731687303715884105728,
   inf, nan, true, unhex('FF00FE')),
  (2, 'corriente', 42, [], {},
   toDecimal128('0.5', 10),
   toDateTime64('2026-01-15 08:30:00.000', 3, 'Europe/Madrid'),
   '00000000-0000-4000-8000-000000000002', 'bajo', '10.0.0.1',
   9007199254740993, 170141183460469231731687303715884105727,
   -inf, 3.14159, false, 'texto normal'),
  (3, 'it''s', NULL, ['con \'comilla\''], {'k': 0},
   toDecimal128('-1.0000000001', 10),
   toDateTime64('2026-03-29 02:30:00.500', 3, 'Europe/Madrid'),
   '00000000-0000-4000-8000-000000000003', 'medio', '0.0.0.0',
   0, 0, 0, 0, false, 'línea uno\nlínea dos'),
  (4, 'C:\\ruta\\nueva', 7, ['a\\b'], {'x': 1},
   toDecimal128('0', 10),
   toDateTime64('2026-10-25 02:30:00.000', 3, 'Europe/Madrid'),
   '00000000-0000-4000-8000-000000000004', 'bajo', '255.255.255.255',
   1, -1, 1e300, -0.0, true, '');

INSERT INTO grande
SELECT number + 1,
       number % 100,
       number * 0.5,
       concat('fila-', toString(number + 1)),
       toDateTime('2026-01-01 00:00:00', 'UTC') + toIntervalSecond(number * 30)
FROM numbers(1000000);

-- El primer refresco se fuerza aquí para que el destino tenga sus filas en
-- cuanto el seed termina, sin depender de cuándo lo programe el planificador.
SYSTEM REFRESH VIEW mv_resumen_clientes;
CH_SEED_20_SOLO_CLICKHOUSE_EOF

  cat > "${CLICKHOUSE_SEED_DIR}/30-cuentas.sql" <<'CH_SEED_30_CUENTAS_EOF'
-- 30-cuentas.sql — ClickHouse
--
-- Las dos cuentas SQL del plan 34 (decisión 31). La cuenta de arranque,
-- `caliope`, la crea la imagen desde las variables de entorno en `users_xml`, y
-- ahí es de sólo lectura para `ALTER USER` (`ACCESS_STORAGE_READONLY`): no sirve
-- para probar la herramienta de Usuarios. Éstas viven en almacenamiento SQL y
-- admiten todo. Los perfiles del testbed conectan como `caliope_admin`.
--
--   caliope_admin  todo, con opción de concederlo, y gestión de accesos.
--   caliope_ro     sólo lectura de `demo`, y de `system` para que las pantallas
--                  de sólo lectura (procesos, servidor, catálogo) funcionen.
--
-- `GRANT CURRENT GRANTS` y no `GRANT ALL`: la cuenta de arranque no tiene
-- `NAMED COLLECTION ADMIN`, así que no puede conceder `ALL` (medido en 26.8:
-- `ACCESS_DENIED`), y `CURRENT GRANTS` concede todo lo que ella sí tiene.

CREATE USER caliope_admin IDENTIFIED WITH sha256_password BY 'caliopepass';
GRANT CURRENT GRANTS ON *.* TO caliope_admin WITH GRANT OPTION;
GRANT ACCESS MANAGEMENT ON *.* TO caliope_admin WITH GRANT OPTION;

CREATE USER caliope_ro IDENTIFIED WITH sha256_password BY 'caliopepass';
GRANT SELECT ON demo.* TO caliope_ro;
GRANT SELECT ON system.* TO caliope_ro;
CH_SEED_30_CUENTAS_EOF

  generate_clickhouse_tls
  generate_clickhouse_replicas

  # ── Seed SQL: 10-schemas.sql ──────────────────────────────────────────────
  cat > "${SEED_DIR}/10-schemas.sql" <<'SEED_10_EOF'
-- 10-schemas.sql
-- Crea los tres esquemas del entorno de pruebas y garantiza que el usuario
-- caliope tenga privilegios absolutos. Idempotente: se puede reejecutar
-- sin efectos secundarios.

SET NAMES utf8mb4;  -- el cliente del entrypoint de mysql lee el archivo como latin1 sin esto (doble codificación, 2026-10-02)

CREATE DATABASE IF NOT EXISTS demo
  CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

CREATE DATABASE IF NOT EXISTS rrhh
  CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

CREATE DATABASE IF NOT EXISTS analytics
  CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

CREATE USER IF NOT EXISTS 'caliope'@'%' IDENTIFIED BY 'caliopepass';
GRANT ALL PRIVILEGES ON *.* TO 'caliope'@'%' WITH GRANT OPTION;

FLUSH PRIVILEGES;
SEED_10_EOF

  # ── Seed SQL: 20-tables-demo.sql ──────────────────────────────────────────
  cat > "${SEED_DIR}/20-tables-demo.sql" <<'SEED_20_EOF'
-- 20-tables-demo.sql
-- Tablas del esquema demo. Las 3 tablas que consume el loadgen
-- (productos, clientes, pedidos) se dejan EXACTAMENTE como las crea
-- setup-replication.sh en su heredoc original: no se altera su schema
-- para no interferir con las queries persistentes del load-generator.
--
-- Las tablas nuevas viven al margen (categorias sin FK obligatoria en
-- productos, direcciones referencia a clientes con ON DELETE CASCADE,
-- pedido_lineas es tabla suelta no usada por loadgen, log_auditoria es
-- write-only desde los triggers).

SET NAMES utf8mb4;  -- el cliente del entrypoint de mysql lee el archivo como latin1 sin esto (doble codificación, 2026-10-02)

USE demo;

-- ── Tablas del loadgen (idénticas al heredoc original) ───────────────────
CREATE TABLE IF NOT EXISTS productos (
  id         INT AUTO_INCREMENT PRIMARY KEY,
  nombre     VARCHAR(100) NOT NULL,
  categoria  VARCHAR(50)  NOT NULL DEFAULT 'general',
  precio     DECIMAL(10,2) NOT NULL,
  stock      INT NOT NULL DEFAULT 0,
  activo     TINYINT(1) NOT NULL DEFAULT 1,
  creado_en  TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  INDEX idx_categoria (categoria)
) ENGINE=InnoDB;

CREATE TABLE IF NOT EXISTS clientes (
  id         INT AUTO_INCREMENT PRIMARY KEY,
  nombre     VARCHAR(100) NOT NULL,
  email      VARCHAR(150) NOT NULL UNIQUE,
  ciudad     VARCHAR(80),
  creado_en  TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB;

CREATE TABLE IF NOT EXISTS pedidos (
  id           INT AUTO_INCREMENT PRIMARY KEY,
  cliente_id   INT NOT NULL,
  producto_id  INT NOT NULL,
  cantidad     INT NOT NULL,
  total        DECIMAL(10,2) NOT NULL,
  estado       ENUM('pendiente','enviado','entregado','cancelado') DEFAULT 'pendiente',
  creado_en    TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  FOREIGN KEY (cliente_id)  REFERENCES clientes(id),
  FOREIGN KEY (producto_id) REFERENCES productos(id),
  INDEX idx_estado  (estado),
  INDEX idx_cliente (cliente_id)
) ENGINE=InnoDB;

-- ── Tablas nuevas (aisladas del loadgen) ─────────────────────────────────

-- Catálogo suelto de categorías. No hay FK desde productos.categoria por
-- compatibilidad con el loadgen (que hace INSERTs con ELT sobre 5 strings).
CREATE TABLE IF NOT EXISTS categorias (
  id           INT AUTO_INCREMENT PRIMARY KEY,
  slug         VARCHAR(50)  NOT NULL UNIQUE,
  nombre       VARCHAR(100) NOT NULL,
  descripcion  TEXT,
  padre_id     INT NULL,
  activa       TINYINT(1) NOT NULL DEFAULT 1,
  creada_en    TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  FOREIGN KEY (padre_id) REFERENCES categorias(id) ON DELETE SET NULL,
  INDEX idx_activa (activa)
) ENGINE=InnoDB;

-- Direcciones vinculadas a clientes. Incluye JSON y GEOMETRY para
-- ejercitar los editores de tipos complejos de Calíope.
-- ubicacion es NOT NULL porque MariaDB exige que todas las columnas
-- que forman parte de un SPATIAL INDEX sean NOT NULL (error 1252).
CREATE TABLE IF NOT EXISTS direcciones (
  id            INT AUTO_INCREMENT PRIMARY KEY,
  cliente_id    INT NOT NULL,
  etiqueta      VARCHAR(30) NOT NULL DEFAULT 'principal',
  linea1        VARCHAR(200) NOT NULL,
  linea2        VARCHAR(200),
  ciudad        VARCHAR(80) NOT NULL,
  pais          CHAR(2) NOT NULL,
  metadatos     JSON,
  ubicacion     POINT NOT NULL,
  actualizada   TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  FOREIGN KEY (cliente_id) REFERENCES clientes(id) ON DELETE CASCADE,
  SPATIAL INDEX idx_ubicacion (ubicacion),
  INDEX idx_pais (pais)
) ENGINE=InnoDB;

-- Modelo alternativo N:M pedido → productos, para ejercitar joins y FKs
-- con distintos ON DELETE. NO reemplaza pedidos.producto_id (el loadgen
-- lo sigue usando); es una tabla adicional que Calíope puede explorar.
CREATE TABLE IF NOT EXISTS pedido_lineas (
  id            INT AUTO_INCREMENT PRIMARY KEY,
  pedido_id     INT NOT NULL,
  producto_id   INT NOT NULL,
  cantidad      INT NOT NULL DEFAULT 1,
  precio_unit   DECIMAL(10,2) NOT NULL,
  descuento_pct DECIMAL(5,2) NOT NULL DEFAULT 0.00,
  FOREIGN KEY (pedido_id)   REFERENCES pedidos(id)   ON DELETE CASCADE,
  FOREIGN KEY (producto_id) REFERENCES productos(id) ON DELETE RESTRICT,
  INDEX idx_pedido (pedido_id)
) ENGINE=InnoDB;

-- Log write-only desde triggers. Payload en JSON, hash SHA-256 en BLOB.
CREATE TABLE IF NOT EXISTS log_auditoria (
  id            BIGINT AUTO_INCREMENT PRIMARY KEY,
  ocurrido_en   TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3),
  actor         VARCHAR(80),
  entidad       VARCHAR(80) NOT NULL,
  entidad_id    BIGINT,
  accion        ENUM('INSERT','UPDATE','DELETE') NOT NULL,
  payload_json  JSON,
  payload_hash  VARBINARY(32),
  INDEX idx_entidad (entidad, entidad_id),
  INDEX idx_ocurrido (ocurrido_en)
) ENGINE=InnoDB;
SEED_20_EOF

  # ── Seed SQL: 21-tables-rrhh.sql ──────────────────────────────────────────
  cat > "${SEED_DIR}/21-tables-rrhh.sql" <<'SEED_21_EOF'
-- 21-tables-rrhh.sql
-- Esquema rrhh: jerarquía autoreferencia, BLOB, ENUM, SET. Todo InnoDB.

SET NAMES utf8mb4;  -- el cliente del entrypoint de mysql lee el archivo como latin1 sin esto (doble codificación, 2026-10-02)

USE rrhh;

CREATE TABLE IF NOT EXISTS departamentos (
  id           INT AUTO_INCREMENT PRIMARY KEY,
  codigo       CHAR(4) NOT NULL UNIQUE,
  nombre       VARCHAR(120) NOT NULL,
  presupuesto  DECIMAL(14,2) NOT NULL DEFAULT 0.00,
  creado_en    TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB;

CREATE TABLE IF NOT EXISTS empleados (
  id                INT AUTO_INCREMENT PRIMARY KEY,
  nombre            VARCHAR(120) NOT NULL,
  email             VARCHAR(150) NOT NULL UNIQUE,
  departamento_id   INT NOT NULL,
  jefe_id           INT NULL,
  fecha_ingreso     DATE NOT NULL,
  salario_base      DECIMAL(12,2) NOT NULL,
  estado            ENUM('activo','baja_medica','vacaciones','despedido') DEFAULT 'activo',
  permisos          SET('lectura','escritura','aprobacion','admin') NOT NULL DEFAULT 'lectura',
  notas             TEXT,
  FOREIGN KEY (departamento_id) REFERENCES departamentos(id) ON DELETE RESTRICT,
  FOREIGN KEY (jefe_id)         REFERENCES empleados(id)     ON DELETE SET NULL,
  INDEX idx_departamento (departamento_id),
  INDEX idx_estado       (estado)
) ENGINE=InnoDB;

CREATE TABLE IF NOT EXISTS nominas (
  id             BIGINT AUTO_INCREMENT PRIMARY KEY,
  empleado_id    INT NOT NULL,
  periodo        CHAR(7) NOT NULL COMMENT 'YYYY-MM',
  bruto          DECIMAL(12,2) NOT NULL,
  impuestos      DECIMAL(12,2) NOT NULL,
  neto           DECIMAL(12,2) GENERATED ALWAYS AS (bruto - impuestos) STORED,
  estado         ENUM('borrador','emitida','pagada','anulada') DEFAULT 'borrador',
  documento_pdf  MEDIUMBLOB,
  emitida_en     TIMESTAMP NULL,
  FOREIGN KEY (empleado_id) REFERENCES empleados(id) ON DELETE CASCADE,
  UNIQUE KEY uk_empleado_periodo (empleado_id, periodo),
  INDEX idx_periodo (periodo)
) ENGINE=InnoDB;
SEED_21_EOF

  # ── Seed SQL: 22-tables-analytics.sql ─────────────────────────────────────
  cat > "${SEED_DIR}/22-tables-analytics.sql" <<'SEED_22_EOF'
-- 22-tables-analytics.sql
-- Esquema analytics: 3 tablas con MOTORES DISTINTOS (MEMORY, MyISAM, InnoDB
-- particionada) para ejercitar TableMaintenanceView (CHECK/REPAIR sólo
-- aplican a MyISAM) y SchemaTypes.IndexMethod (HASH sólo en MEMORY).

SET NAMES utf8mb4;  -- el cliente del entrypoint de mysql lee el archivo como latin1 sin esto (doble codificación, 2026-10-02)

USE analytics;

-- MEMORY con índice HASH. Sin persistencia entre reinicios del server.
CREATE TABLE IF NOT EXISTS sesiones_activas (
  session_id   CHAR(36) NOT NULL PRIMARY KEY,
  usuario      VARCHAR(80) NOT NULL,
  ip           VARBINARY(16) NOT NULL,
  iniciada_en  TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  INDEX idx_usuario_hash USING HASH (usuario)
) ENGINE=MEMORY;

-- MyISAM con FULLTEXT. TableMaintenanceView expone CHECK/REPAIR aquí.
CREATE TABLE IF NOT EXISTS logs_ligeros (
  id         BIGINT AUTO_INCREMENT PRIMARY KEY,
  nivel      ENUM('debug','info','warn','error') NOT NULL DEFAULT 'info',
  origen     VARCHAR(80) NOT NULL,
  mensaje    TEXT NOT NULL,
  registrado TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  FULLTEXT idx_mensaje (mensaje),
  INDEX idx_nivel (nivel),
  INDEX idx_registrado (registrado)
) ENGINE=MyISAM;

-- InnoDB particionada por RANGE sobre fecha. Ejercita SchemaProviding.detailedTables
-- con particionamiento y permite ver múltiples segmentos en CatalogsView.
CREATE TABLE IF NOT EXISTS metricas_diarias (
  fecha       DATE NOT NULL,
  metrica     VARCHAR(60) NOT NULL,
  valor       DECIMAL(18,4) NOT NULL,
  muestras    INT UNSIGNED NOT NULL DEFAULT 1,
  PRIMARY KEY (fecha, metrica)
) ENGINE=InnoDB
PARTITION BY RANGE (TO_DAYS(fecha)) (
  PARTITION p_hist   VALUES LESS THAN (TO_DAYS('2025-01-01')),
  PARTITION p_2025q1 VALUES LESS THAN (TO_DAYS('2025-04-01')),
  PARTITION p_2025q2 VALUES LESS THAN (TO_DAYS('2025-07-01')),
  PARTITION p_2025q3 VALUES LESS THAN (TO_DAYS('2025-10-01')),
  PARTITION p_2025q4 VALUES LESS THAN (TO_DAYS('2026-01-01')),
  PARTITION p_2026q1 VALUES LESS THAN (TO_DAYS('2026-04-01')),
  PARTITION p_2026q2 VALUES LESS THAN (TO_DAYS('2026-07-01')),
  PARTITION p_2026q3 VALUES LESS THAN (TO_DAYS('2026-10-01')),
  PARTITION p_futuro VALUES LESS THAN MAXVALUE
);
SEED_22_EOF

  # ── Seed SQL: 30-views.sql ────────────────────────────────────────────────
  cat > "${SEED_DIR}/23-tables-gis.sql" <<'SEED_23_EOF'
-- El entrypoint de MySQL aplica este archivo con el cliente `mysql`, que no negocia
-- el juego de caracteres: sin esta línea lee el fichero como latin1 y guarda cada
-- acento doblemente codificado (C383C2A9 en vez de C3A9 para «é»).
SET NAMES utf8mb4;
-- Spatial database of the shared seed: it reaches the MariaDB primary (and
-- from there every replica by GTID), the standalone node and both MySQL
-- nodes. The dedicated GIS node does NOT get this file — it has its own seed.
--
-- Everything here is SRID 0 and X=longitude, which is the only convention
-- both engines agree on:
--   · `POINT … SRID 4326` as a column attribute is MySQL 8 syntax; MariaDB
--     rejects it outright. Copying §5.3 of docs/17 verbatim fails here.
--   · MariaDB stores an SRID but ignores it for axis order, so the same WKT
--     under 4326 would mean one place on the primary and another on mysql8-gis.
-- The latitude-first case is covered by the dedicated node, on purpose.
--
-- A spatial index requires NOT NULL on both engines (MariaDB error 1252).
CREATE DATABASE IF NOT EXISTS caliope_gis_test
  CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE caliope_gis_test;

CREATE TABLE IF NOT EXISTS spatial_points (
  id        INT AUTO_INCREMENT PRIMARY KEY,
  name      VARCHAR(100) NOT NULL,
  category  VARCHAR(50),
  location  POINT NOT NULL,
  SPATIAL INDEX idx_location (location),
  INDEX idx_category (category)
) ENGINE=InnoDB;

CREATE TABLE IF NOT EXISTS spatial_regions (
  id           INT AUTO_INCREMENT PRIMARY KEY,
  region_name  VARCHAR(100) NOT NULL,
  boundary     POLYGON NOT NULL,
  SPATIAL INDEX idx_boundary (boundary)
) ENGINE=InnoDB;

CREATE TABLE IF NOT EXISTS spatial_routes (
  id          INT AUTO_INCREMENT PRIMARY KEY,
  route_name  VARCHAR(100) NOT NULL,
  path        LINESTRING NOT NULL,
  SPATIAL INDEX idx_path (path)
) ENGINE=InnoDB;

-- Nullable geometry lives apart, since it cannot carry a spatial index.
CREATE TABLE IF NOT EXISTS spatial_mixed (
  id        INT AUTO_INCREMENT PRIMARY KEY,
  label     VARCHAR(100) NOT NULL,
  note      VARCHAR(200),
  shape     GEOMETRY NULL
) ENGINE=InnoDB;
SEED_23_EOF

  cat > "${SEED_DIR}/30-views.sql" <<'SEED_30_EOF'
-- 30-views.sql
-- Cuatro vistas cubriendo las combinaciones que distingue ViewEditorSheet:
--   1) Agrupada (no updatable, MERGE algorithm)
--   2) WITH CHECK OPTION
--   3) SECURITY INVOKER (por defecto es DEFINER)
--   4) UNION (no updatable por definición)

SET NAMES utf8mb4;  -- el cliente del entrypoint de mysql lee el archivo como latin1 sin esto (doble codificación, 2026-10-02)

USE demo;

-- 1) Ranking de productos por pedidos. GROUP BY → no updatable.
CREATE OR REPLACE
  ALGORITHM = MERGE
  DEFINER = 'caliope'@'%'
  SQL SECURITY DEFINER
VIEW v_top_productos AS
SELECT
  p.id,
  p.nombre,
  p.categoria,
  COUNT(pe.id) AS total_pedidos,
  COALESCE(SUM(pe.total), 0) AS ingresos
FROM productos p
LEFT JOIN pedidos pe ON pe.producto_id = p.id
GROUP BY p.id, p.nombre, p.categoria;

-- 2) Pedidos recientes (últimos 30 días) — updatable con CHECK OPTION.
CREATE OR REPLACE
  ALGORITHM = MERGE
  DEFINER = 'caliope'@'%'
  SQL SECURITY DEFINER
VIEW v_pedidos_recientes AS
SELECT
  id, cliente_id, producto_id, cantidad, total, estado, creado_en
FROM pedidos
WHERE creado_en >= (NOW() - INTERVAL 30 DAY)
WITH CHECK OPTION;

-- 3) Clientes activos con SECURITY INVOKER (usa privilegios del llamador).
CREATE OR REPLACE
  ALGORITHM = MERGE
  DEFINER = 'caliope'@'%'
  SQL SECURITY INVOKER
VIEW v_clientes_activos AS
SELECT
  c.id,
  c.nombre,
  c.email,
  c.ciudad,
  COUNT(pe.id) AS pedidos_totales
FROM clientes c
LEFT JOIN pedidos pe ON pe.cliente_id = c.id
GROUP BY c.id, c.nombre, c.email, c.ciudad;

-- 4) Resumen ventas via UNION — no updatable.
CREATE OR REPLACE
  ALGORITHM = UNDEFINED
  DEFINER = 'caliope'@'%'
  SQL SECURITY DEFINER
VIEW v_resumen_ventas AS
SELECT 'pendiente'  AS estado, COUNT(*) AS pedidos, COALESCE(SUM(total), 0) AS monto
  FROM pedidos WHERE estado = 'pendiente'
UNION ALL
SELECT 'enviado',    COUNT(*), COALESCE(SUM(total), 0)
  FROM pedidos WHERE estado = 'enviado'
UNION ALL
SELECT 'entregado',  COUNT(*), COALESCE(SUM(total), 0)
  FROM pedidos WHERE estado = 'entregado'
UNION ALL
SELECT 'cancelado',  COUNT(*), COALESCE(SUM(total), 0)
  FROM pedidos WHERE estado = 'cancelado';
SEED_30_EOF

  # ── Seed SQL: 40-routines.sql ─────────────────────────────────────────────
  cat > "${SEED_DIR}/40-routines.sql" <<'SEED_40_EOF'
-- 40-routines.sql
-- 3 procedures + 3 functions, cubriendo IN/OUT/INOUT, DETERMINISTIC on/off,
-- y los 4 valores de RoutineSQLAccess (CONTAINS SQL, NO SQL, READS SQL DATA,
-- MODIFIES SQL DATA).
--
-- Se usa DELIMITER // para que los ; dentro de BEGIN…END no corten los
-- statements. El cliente mariadb interpreta DELIMITER como comando local
-- cuando lee un fichero .sql por stdin.

SET NAMES utf8mb4;  -- el cliente del entrypoint de mysql lee el archivo como latin1 sin esto (doble codificación, 2026-10-02)

USE demo;

DROP PROCEDURE IF EXISTS sp_registrar_pedido;
DROP PROCEDURE IF EXISTS sp_reporte_ventas;
DROP PROCEDURE IF EXISTS sp_ajustar_stock;
DROP FUNCTION  IF EXISTS fn_precio_con_iva;
DROP FUNCTION  IF EXISTS fn_stock_producto;
DROP FUNCTION  IF EXISTS fn_slugify;

DELIMITER //

CREATE DEFINER = 'caliope'@'%' PROCEDURE sp_registrar_pedido(
    IN  in_cliente_id  INT,
    IN  in_producto_id INT,
    IN  in_cantidad    INT,
    OUT out_pedido_id  INT
)
    MODIFIES SQL DATA
    SQL SECURITY DEFINER
    COMMENT 'Registra un pedido calculando el total desde productos.precio'
BEGIN
    DECLARE v_precio DECIMAL(10,2);

    START TRANSACTION;
    SELECT precio INTO v_precio FROM productos WHERE id = in_producto_id;

    IF v_precio IS NULL THEN
        ROLLBACK;
        SET out_pedido_id = NULL;
    ELSE
        SAVEPOINT sp_inicio;
        INSERT INTO pedidos (cliente_id, producto_id, cantidad, total, estado)
          VALUES (in_cliente_id, in_producto_id, in_cantidad,
                  v_precio * in_cantidad, 'pendiente');
        SET out_pedido_id = LAST_INSERT_ID();
        COMMIT;
    END IF;
END //

CREATE DEFINER = 'caliope'@'%' PROCEDURE sp_reporte_ventas(
    IN in_desde DATE,
    IN in_hasta DATE
)
    READS SQL DATA
    SQL SECURITY DEFINER
    COMMENT 'Devuelve resumen de ventas por estado en un rango de fechas'
BEGIN
    SELECT estado,
           COUNT(*)                AS pedidos,
           COALESCE(SUM(total), 0) AS monto
      FROM pedidos
     WHERE DATE(creado_en) BETWEEN in_desde AND in_hasta
     GROUP BY estado;
END //

CREATE DEFINER = 'caliope'@'%' PROCEDURE sp_ajustar_stock(
    IN    in_producto_id INT,
    INOUT io_delta       INT
)
    MODIFIES SQL DATA
    SQL SECURITY DEFINER
    COMMENT 'Aplica un delta al stock; escribe en io_delta el stock resultante'
BEGIN
    UPDATE productos SET stock = GREATEST(0, stock + io_delta)
     WHERE id = in_producto_id;
    SELECT stock INTO io_delta FROM productos WHERE id = in_producto_id;
END //

CREATE DEFINER = 'caliope'@'%' FUNCTION fn_precio_con_iva(
    p_precio DECIMAL(10,2),
    p_iva_pct DECIMAL(5,2)
) RETURNS DECIMAL(10,2)
    DETERMINISTIC
    CONTAINS SQL
    SQL SECURITY DEFINER
    COMMENT 'Aplica un porcentaje de IVA sobre un precio base'
BEGIN
    RETURN ROUND(p_precio * (1 + p_iva_pct / 100), 2);
END //

CREATE DEFINER = 'caliope'@'%' FUNCTION fn_stock_producto(
    p_producto_id INT
) RETURNS INT
    NOT DETERMINISTIC
    READS SQL DATA
    SQL SECURITY DEFINER
    COMMENT 'Devuelve el stock actual de un producto por id'
BEGIN
    DECLARE v_stock INT;
    SELECT stock INTO v_stock FROM productos WHERE id = p_producto_id;
    RETURN COALESCE(v_stock, 0);
END //

CREATE DEFINER = 'caliope'@'%' FUNCTION fn_slugify(
    p_texto VARCHAR(200)
) RETURNS VARCHAR(200)
    DETERMINISTIC
    NO SQL
    SQL SECURITY DEFINER
    COMMENT 'Normaliza un texto a slug ASCII minúsculas'
BEGIN
    RETURN LOWER(REPLACE(REPLACE(TRIM(p_texto), ' ', '-'), '_', '-'));
END //

DELIMITER ;
SEED_40_EOF

  # ── Seed SQL: 50-triggers.sql ─────────────────────────────────────────────
  cat > "${SEED_DIR}/50-triggers.sql" <<'SEED_50_EOF'
-- 50-triggers.sql
-- 6 triggers cubriendo las combinaciones BEFORE/AFTER × INSERT/UPDATE/DELETE.
--
-- Diseño explícito: sobre las 3 tablas del loadgen (productos, clientes,
-- pedidos) SÓLO se instalan triggers AFTER y write-only a log_auditoria.
-- Nada de SIGNAL SQLSTATE, nada de bloquear DML, nada de modificar el
-- schema. El loadgen sigue funcionando sin interferencias.
--
-- Los dos triggers BEFORE viven sobre categorias (nueva, aislada), donde
-- sí es seguro normalizar/enriquecer valores.

SET NAMES utf8mb4;  -- el cliente del entrypoint de mysql lee el archivo como latin1 sin esto (doble codificación, 2026-10-02)

USE demo;

DROP TRIGGER IF EXISTS bi_categorias_slug;
DROP TRIGGER IF EXISTS bu_categorias_slug;
DROP TRIGGER IF EXISTS ai_pedidos_audit;
DROP TRIGGER IF EXISTS au_pedidos_audit;
DROP TRIGGER IF EXISTS ad_pedidos_audit;
DROP TRIGGER IF EXISTS ai_clientes_audit;

DELIMITER //

-- 1) BEFORE INSERT sobre tabla nueva (segura). Normaliza slug.
CREATE DEFINER = 'caliope'@'%'
TRIGGER bi_categorias_slug
BEFORE INSERT ON categorias
FOR EACH ROW
BEGIN
    IF NEW.slug IS NULL OR NEW.slug = '' THEN
        SET NEW.slug = fn_slugify(NEW.nombre);
    END IF;
END //

-- 2) BEFORE UPDATE sobre tabla nueva (segura). Reslugifica si cambia nombre.
CREATE DEFINER = 'caliope'@'%'
TRIGGER bu_categorias_slug
BEFORE UPDATE ON categorias
FOR EACH ROW
BEGIN
    IF NEW.nombre <> OLD.nombre AND NEW.slug = OLD.slug THEN
        SET NEW.slug = fn_slugify(NEW.nombre);
    END IF;
END //

-- 3) AFTER INSERT sobre pedidos (loadgen). SÓLO escribe en log_auditoria.
CREATE DEFINER = 'caliope'@'%'
TRIGGER ai_pedidos_audit
AFTER INSERT ON pedidos
FOR EACH ROW
BEGIN
    INSERT INTO log_auditoria (actor, entidad, entidad_id, accion, payload_json, payload_hash)
    VALUES (
        CURRENT_USER(),
        'pedidos',
        NEW.id,
        'INSERT',
        JSON_OBJECT(
            'cliente_id',  NEW.cliente_id,
            'producto_id', NEW.producto_id,
            'cantidad',    NEW.cantidad,
            'total',       NEW.total,
            'estado',      NEW.estado
        ),
        UNHEX(SHA2(CONCAT_WS('|', NEW.cliente_id, NEW.producto_id, NEW.total), 256))
    );
END //

-- 4) AFTER UPDATE sobre pedidos (loadgen). SÓLO escribe en log_auditoria
--    cuando cambia el estado (el loadgen hace muchos UPDATEs de estado).
CREATE DEFINER = 'caliope'@'%'
TRIGGER au_pedidos_audit
AFTER UPDATE ON pedidos
FOR EACH ROW
BEGIN
    IF NEW.estado <> OLD.estado THEN
        INSERT INTO log_auditoria (actor, entidad, entidad_id, accion, payload_json, payload_hash)
        VALUES (
            CURRENT_USER(),
            'pedidos',
            NEW.id,
            'UPDATE',
            JSON_OBJECT('estado_anterior', OLD.estado, 'estado_nuevo', NEW.estado),
            UNHEX(SHA2(CONCAT_WS('|', NEW.id, OLD.estado, NEW.estado), 256))
        );
    END IF;
END //

-- 5) AFTER DELETE sobre pedidos (loadgen). SÓLO escribe en log_auditoria.
CREATE DEFINER = 'caliope'@'%'
TRIGGER ad_pedidos_audit
AFTER DELETE ON pedidos
FOR EACH ROW
BEGIN
    INSERT INTO log_auditoria (actor, entidad, entidad_id, accion, payload_json)
    VALUES (
        CURRENT_USER(),
        'pedidos',
        OLD.id,
        'DELETE',
        JSON_OBJECT(
            'cliente_id',  OLD.cliente_id,
            'producto_id', OLD.producto_id,
            'total',       OLD.total,
            'estado',      OLD.estado
        )
    );
END //

-- 6) AFTER INSERT sobre clientes (loadgen). SÓLO log.
CREATE DEFINER = 'caliope'@'%'
TRIGGER ai_clientes_audit
AFTER INSERT ON clientes
FOR EACH ROW
BEGIN
    INSERT INTO log_auditoria (actor, entidad, entidad_id, accion, payload_json)
    VALUES (
        CURRENT_USER(),
        'clientes',
        NEW.id,
        'INSERT',
        JSON_OBJECT('email', NEW.email, 'ciudad', NEW.ciudad)
    );
END //

DELIMITER ;
SEED_50_EOF

  # ── Seed SQL: 60-events.sql ───────────────────────────────────────────────
  cat > "${SEED_DIR}/60-events.sql" <<'SEED_60_EOF'
-- 60-events.sql
-- Activa el event scheduler y crea 3 eventos cubriendo:
--   • RECURRING enabled  (ev_snapshot_metricas — cada 1 hora)
--   • RECURRING disabled (ev_purga_analytics — cada 6 horas, DISABLE)
--   • ONE TIME           (ev_bienvenida_marca — dispara +30 días)
--
-- Los eventos actúan SOBRE analytics.* (aisladas del loadgen). Cero
-- interacción con demo.productos/clientes/pedidos.
--
-- En réplicas MariaDB marca los events como SLAVESIDE_DISABLED
-- automáticamente si el event_scheduler está OFF, así que no se ejecutan
-- dos veces.

SET NAMES utf8mb4;  -- el cliente del entrypoint de mysql lee el archivo como latin1 sin esto (doble codificación, 2026-10-02)

USE demo;

SET GLOBAL event_scheduler = ON;

DROP EVENT IF EXISTS ev_snapshot_metricas;
DROP EVENT IF EXISTS ev_purga_analytics;
DROP EVENT IF EXISTS ev_bienvenida_marca;

DELIMITER //

CREATE DEFINER = 'caliope'@'%' EVENT ev_snapshot_metricas
    ON SCHEDULE
        EVERY 1 HOUR
        STARTS CURRENT_TIMESTAMP + INTERVAL 5 MINUTE
    ON COMPLETION PRESERVE
    ENABLE
    COMMENT 'Registra una métrica sintética horaria en analytics.metricas_diarias'
DO
BEGIN
    INSERT INTO analytics.metricas_diarias (fecha, metrica, valor, muestras)
    VALUES (CURRENT_DATE, 'productos_activos', (SELECT COUNT(*) FROM demo.productos WHERE activo = 1), 1)
    ON DUPLICATE KEY UPDATE
        valor    = VALUES(valor),
        muestras = muestras + 1;
END //

CREATE DEFINER = 'caliope'@'%' EVENT ev_purga_analytics
    ON SCHEDULE
        EVERY 6 HOUR
        STARTS CURRENT_TIMESTAMP + INTERVAL 1 DAY
    ON COMPLETION PRESERVE
    DISABLE
    COMMENT 'Purga logs analíticos con más de 90 días — deshabilitado por defecto'
DO
BEGIN
    DELETE FROM analytics.logs_ligeros
     WHERE registrado < (NOW() - INTERVAL 90 DAY);
END //

CREATE DEFINER = 'caliope'@'%' EVENT ev_bienvenida_marca
    ON SCHEDULE
        AT CURRENT_TIMESTAMP + INTERVAL 30 DAY
    ON COMPLETION PRESERVE
    ENABLE
    COMMENT 'Marca one-shot programada 30 días en el futuro'
DO
BEGIN
    INSERT INTO log_auditoria (actor, entidad, entidad_id, accion, payload_json)
    VALUES ('event_scheduler', 'marca_bienvenida', NULL, 'INSERT',
            JSON_OBJECT('mensaje', 'Marca automática 30 días'));
END //

DELIMITER ;
SEED_60_EOF

  # ── Seed SQL: 70-seed-demo.sql ────────────────────────────────────────────
  cat > "${SEED_DIR}/70-seed-demo.sql" <<'SEED_70_EOF'
-- 70-seed-demo.sql
-- Semilla inicial para demo. Las 3 tablas del loadgen (productos, clientes,
-- pedidos) reciben EXACTAMENTE las filas del heredoc original de
-- setup-replication.sh. Las tablas nuevas reciben una decena de filas cada
-- una para poblar los árboles de CatalogsView.
--
-- Todo con INSERT IGNORE para permitir re-aplicación en volúmenes
-- pre-existentes.

SET NAMES utf8mb4;  -- el cliente del entrypoint de mysql lee el archivo como latin1 sin esto (doble codificación, 2026-10-02)

USE demo;

-- ── Filas originales (idénticas al heredoc previo) ───────────────────────
INSERT IGNORE INTO productos (id, nombre, categoria, precio, stock) VALUES
  (1, 'Widget A',           'accesorios',    9.99, 100),
  (2, 'Widget B',           'accesorios',   19.99,  50),
  (3, 'Gadget Pro',         'electronica',  49.99,  25),
  (4, 'Cable USB-C',        'cables',        4.99, 200),
  (5, 'Adaptador HDMI',     'cables',       14.99,  75),
  (6, 'Teclado mecánico',   'periféricos',  89.99,  30),
  (7, 'Ratón inalámbrico',  'periféricos',  34.99,  60),
  (8, 'Monitor 24"',        'pantallas',   249.99,  10);

INSERT IGNORE INTO clientes (id, nombre, email, ciudad) VALUES
  (1, 'Ana García',    'ana@ejemplo.com',    'Madrid'),
  (2, 'Luis Mora',     'luis@ejemplo.com',   'San José'),
  (3, 'María Castro',  'maria@ejemplo.com',  'Ciudad de México'),
  (4, 'Carlos Ruiz',   'carlos@ejemplo.com', 'Buenos Aires');

INSERT IGNORE INTO pedidos (id, cliente_id, producto_id, cantidad, total, estado) VALUES
  (1, 1, 1, 5,  49.95, 'entregado'),
  (2, 2, 3, 2,  99.98, 'enviado'),
  (3, 3, 2, 1,  19.99, 'pendiente'),
  (4, 4, 5, 3,  44.97, 'entregado'),
  (5, 1, 6, 1,  89.99, 'enviado'),
  (6, 2, 7, 2,  69.98, 'pendiente');

-- ── Tablas nuevas ────────────────────────────────────────────────────────

INSERT IGNORE INTO categorias (id, slug, nombre, descripcion, padre_id, activa) VALUES
  (1, 'electronica',  'Electrónica',    'Dispositivos electrónicos varios', NULL, 1),
  (2, 'cables',       'Cables',         'Cables y conectores', 1, 1),
  (3, 'perifericos',  'Periféricos',    'Teclados, ratones, etc.', 1, 1),
  (4, 'pantallas',    'Pantallas',      'Monitores y proyectores', 1, 1),
  (5, 'accesorios',   'Accesorios',     'Complementos generales', NULL, 1),
  (6, 'descatalogado','Descatalogado',  'Categoría obsoleta', NULL, 0);

INSERT IGNORE INTO direcciones (id, cliente_id, etiqueta, linea1, ciudad, pais, metadatos, ubicacion) VALUES
  (1, 1, 'principal', 'Calle Mayor 12',      'Madrid',           'ES',
     JSON_OBJECT('piso', 3, 'puerta', 'B'),         ST_PointFromText('POINT(-3.7038 40.4168)')),
  (2, 2, 'principal', 'Av. Central 88',      'San José',         'CR',
     JSON_OBJECT('cerca', 'ICE'),                    ST_PointFromText('POINT(-84.0907 9.9281)')),
  (3, 3, 'trabajo',   'Reforma 500 piso 12', 'Ciudad de México', 'MX',
     JSON_OBJECT('oficina', 1204),                   ST_PointFromText('POINT(-99.1332 19.4326)')),
  (4, 4, 'principal', 'Av. Corrientes 1200', 'Buenos Aires',     'AR',
     JSON_OBJECT('notas', 'timbre 4B'),              ST_PointFromText('POINT(-58.3816 -34.6037)'));

INSERT IGNORE INTO pedido_lineas (id, pedido_id, producto_id, cantidad, precio_unit, descuento_pct) VALUES
  (1, 1, 1, 5,   9.99, 0.00),
  (2, 2, 3, 2,  49.99, 5.00),
  (3, 3, 2, 1,  19.99, 0.00),
  (4, 4, 5, 3,  14.99, 0.00),
  (5, 5, 6, 1,  89.99, 10.00),
  (6, 6, 7, 2,  34.99, 0.00);
SEED_70_EOF

  # ── Seed SQL: 71-seed-rrhh.sql ────────────────────────────────────────────
  cat > "${SEED_DIR}/71-seed-rrhh.sql" <<'SEED_71_EOF'
-- 71-seed-rrhh.sql
-- Semilla de rrhh: 4 departamentos, 8 empleados con jerarquía autoreferencia
-- (Ana es CEO, Luis y María son managers, el resto son sus subordinados),
-- y 6 nóminas en distintos estados.

SET NAMES utf8mb4;  -- el cliente del entrypoint de mysql lee el archivo como latin1 sin esto (doble codificación, 2026-10-02)

USE rrhh;

INSERT IGNORE INTO departamentos (id, codigo, nombre, presupuesto) VALUES
  (1, 'DIR', 'Dirección',       250000.00),
  (2, 'ING', 'Ingeniería',      450000.00),
  (3, 'COM', 'Comercial',       180000.00),
  (4, 'RRH', 'Recursos Humanos', 90000.00);

INSERT IGNORE INTO empleados (id, nombre, email, departamento_id, jefe_id, fecha_ingreso, salario_base, estado, permisos, notas) VALUES
  (1, 'Ana García',      'ana.g@empresa.test',    1, NULL, '2018-01-15',  8500.00, 'activo',       'lectura,escritura,aprobacion,admin', 'CEO'),
  (2, 'Luis Mora',       'luis.m@empresa.test',   2, 1,    '2019-03-22',  6200.00, 'activo',       'lectura,escritura,aprobacion',       'Director de Ingeniería'),
  (3, 'María Castro',    'maria.c@empresa.test',  3, 1,    '2019-07-10',  5800.00, 'activo',       'lectura,escritura,aprobacion',       'Directora Comercial'),
  (4, 'Carlos Ruiz',     'carlos.r@empresa.test', 2, 2,    '2020-05-04',  4200.00, 'activo',       'lectura,escritura',                  'Backend'),
  (5, 'Sofía López',     'sofia.l@empresa.test',  2, 2,    '2021-09-18',  3800.00, 'vacaciones',   'lectura,escritura',                  'Frontend'),
  (6, 'Diego Fernández', 'diego.f@empresa.test',  3, 3,    '2022-02-14',  3200.00, 'baja_medica',  'lectura',                            'Ventas región norte'),
  (7, 'Elena Vega',      'elena.v@empresa.test',  4, 1,    '2020-11-30',  4500.00, 'activo',       'lectura,escritura,aprobacion',       'Responsable de RRHH'),
  (8, 'Marco Antonelli', 'marco.a@empresa.test',  2, 2,    '2023-06-01',  3600.00, 'despedido',    'lectura',                            'Baja disciplinaria');

INSERT IGNORE INTO nominas (id, empleado_id, periodo, bruto, impuestos, estado, emitida_en) VALUES
  (1, 1, '2026-05', 8500.00, 2380.00, 'pagada',   '2026-05-31 18:00:00'),
  (2, 2, '2026-05', 6200.00, 1550.00, 'pagada',   '2026-05-31 18:05:00'),
  (3, 3, '2026-05', 5800.00, 1450.00, 'pagada',   '2026-05-31 18:10:00'),
  (4, 4, '2026-05', 4200.00, 1050.00, 'emitida',  '2026-05-31 18:15:00'),
  (5, 5, '2026-05', 3800.00,  950.00, 'emitida',  '2026-05-31 18:20:00'),
  (6, 7, '2026-05', 4500.00, 1125.00, 'borrador', NULL);
SEED_71_EOF

  # ── Seed SQL: 72-seed-analytics.sql ───────────────────────────────────────
  cat > "${SEED_DIR}/72-seed-analytics.sql" <<'SEED_72_EOF'
-- 72-seed-analytics.sql
-- Semilla de analytics: filas de ejemplo en las 3 tablas con motores
-- distintos. Los datos son sintéticos y ligeros — el ev_snapshot_metricas
-- irá añadiendo filas nuevas a metricas_diarias cada hora.

SET NAMES utf8mb4;  -- el cliente del entrypoint de mysql lee el archivo como latin1 sin esto (doble codificación, 2026-10-02)

USE analytics;

-- MEMORY: se vacía en cada reinicio del servidor. Sembramos algunas
-- sesiones "vivas" para que CatalogsView muestre filas al primer arranque.
INSERT IGNORE INTO sesiones_activas (session_id, usuario, ip) VALUES
  ('11111111-1111-1111-1111-111111111111', 'ana',      INET6_ATON('192.168.1.10')),
  ('22222222-2222-2222-2222-222222222222', 'luis',     INET6_ATON('192.168.1.11')),
  ('33333333-3333-3333-3333-333333333333', 'maria',    INET6_ATON('2001:db8::1')),
  ('44444444-4444-4444-4444-444444444444', 'carlos',   INET6_ATON('10.0.0.42'));

INSERT IGNORE INTO logs_ligeros (nivel, origen, mensaje) VALUES
  ('info',  'auth',      'usuario ana inició sesión desde 192.168.1.10'),
  ('warn',  'billing',   'intento de cargo rechazado por saldo insuficiente'),
  ('error', 'jobs',      'fallo al procesar job #4821 — timeout tras 30s'),
  ('info',  'auth',      'usuario luis cerró sesión'),
  ('debug', 'scheduler', 'ev_snapshot_metricas programado para 2026-07-03 09:00'),
  ('info',  'catalog',   'nuevo producto catalogado en la sección electrónica'),
  ('warn',  'sync',      'retraso de replicación detectado en replica2 (5s)'),
  ('info',  'backup',    'backup completo generado — 128 MB comprimidos');

INSERT IGNORE INTO metricas_diarias (fecha, metrica, valor, muestras) VALUES
  ('2025-06-15', 'productos_activos',  120.0000, 24),
  ('2025-09-20', 'clientes_activos',    85.0000, 24),
  ('2025-12-31', 'pedidos_por_dia',    142.5000, 24),
  ('2026-01-10', 'productos_activos',  138.0000, 24),
  ('2026-03-15', 'clientes_activos',   102.0000, 24),
  ('2026-06-30', 'pedidos_por_dia',    167.2500, 24);
SEED_72_EOF

  cat > "${SEED_DIR}/73-seed-gis.sql" <<'SEED_73_EOF'
-- El entrypoint de MySQL aplica este archivo con el cliente `mysql`, que no negocia
-- el juego de caracteres: sin esta línea lee el fichero como latin1 y guarda cada
-- acento doblemente codificado (C383C2A9 en vez de C3A9 para «é»).
SET NAMES utf8mb4;
-- 73-seed-gis.sql
-- SRID 0 throughout, X=longitude — see the header of 23-tables-gis.sql for
-- why this seed cannot use 4326. Explicit ids + INSERT IGNORE because the
-- whole seed is re-applied on every `setup` run.

USE caliope_gis_test;

INSERT IGNORE INTO spatial_points (id, name, category, location) VALUES
  (1, 'Madrid',           'capital', ST_GeomFromText('POINT(-3.7038 40.4168)')),
  (2, 'Barcelona',        'ciudad',  ST_GeomFromText('POINT(2.1686 41.3874)')),
  (3, 'Lisboa',           'capital', ST_GeomFromText('POINT(-9.1393 38.7223)')),
  (4, 'París',            'capital', ST_GeomFromText('POINT(2.3522 48.8566)')),
  (5, 'Londres',          'capital', ST_GeomFromText('POINT(-0.1278 51.5074)')),
  (6, 'Berlín',           'capital', ST_GeomFromText('POINT(13.4050 52.5200)')),
  (7, 'Roma',             'capital', ST_GeomFromText('POINT(12.4964 41.9028)')),
  (8, 'San José',         'capital', ST_GeomFromText('POINT(-84.0907 9.9281)')),
  (9, 'Ciudad de México', 'capital', ST_GeomFromText('POINT(-99.1332 19.4326)')),
  (10,'Buenos Aires',     'capital', ST_GeomFromText('POINT(-58.3816 -34.6037)')),
  (11,'Nueva York',       'ciudad',  ST_GeomFromText('POINT(-74.0060 40.7128)')),
  (12,'São Paulo',        'ciudad',  ST_GeomFromText('POINT(-46.6333 -23.5505)')),
  (13,'Tokio',            'capital', ST_GeomFromText('POINT(139.6503 35.6762)')),
  (14,'Sídney',           'ciudad',  ST_GeomFromText('POINT(151.2093 -33.8688)')),
  (15,'Ciudad del Cabo',  'capital', ST_GeomFromText('POINT(18.4241 -33.9249)')),
  (16,'Puerto de Limón',  'puerto',  ST_GeomFromText('POINT(-83.0359 9.9907)')),
  (17,'Puerto de Algeciras','puerto',ST_GeomFromText('POINT(-5.4467 36.1408)')),
  (18,'Puerto de Róterdam','puerto', ST_GeomFromText('POINT(4.4777 51.9244)')),
  (19,'Puerto de Singapur','puerto', ST_GeomFromText('POINT(103.8198 1.3521)')),
  (20,'Suva',             'capital', ST_GeomFromText('POINT(178.4419 -18.1416)'));

INSERT IGNORE INTO spatial_regions (id, region_name, boundary) VALUES
  (1, 'Península ibérica', ST_GeomFromText('POLYGON((-9.5 43.8, 3.4 43.8, 3.4 36.0, -9.5 36.0, -9.5 43.8))')),
  (2, 'Costa Rica',        ST_GeomFromText('POLYGON((-85.95 11.22, -82.55 11.22, -82.55 8.03, -85.95 8.03, -85.95 11.22))')),
  (3, 'Gran San José',     ST_GeomFromText('POLYGON((-84.30 10.05, -83.90 10.05, -83.90 9.80, -84.30 9.80, -84.30 10.05))')),
  (4, 'Centro de Madrid',  ST_GeomFromText('POLYGON((-3.73 40.44, -3.66 40.44, -3.66 40.39, -3.73 40.39, -3.73 40.44))')),
  (5, 'Manhattan',         ST_GeomFromText('POLYGON((-74.02 40.88, -73.91 40.88, -73.91 40.70, -74.02 40.70, -74.02 40.88))')),
  (6, 'Anillo con agujero',ST_GeomFromText('POLYGON((-3.90 40.60, -3.50 40.60, -3.50 40.25, -3.90 40.25, -3.90 40.60),(-3.80 40.50, -3.60 40.50, -3.60 40.35, -3.80 40.35, -3.80 40.50))'));

INSERT IGNORE INTO spatial_routes (id, route_name, path) VALUES
  (1, 'Madrid → Barcelona',      ST_GeomFromText('LINESTRING(-3.7038 40.4168, -1.1000 41.1500, 1.0000 41.6200, 2.1686 41.3874)')),
  (2, 'San José → Limón',        ST_GeomFromText('LINESTRING(-84.0907 9.9281, -83.9000 9.9700, -83.6500 10.0800, -83.0359 9.9907)')),
  (3, 'Madrid → Nueva York',     ST_GeomFromText('LINESTRING(-3.7038 40.4168, -20.0 43.5, -35.0 45.0, -50.0 44.0, -74.0060 40.7128)')),
  (4, 'Canal de Panamá',         ST_GeomFromText('LINESTRING(-79.9020 9.3547, -79.8100 9.1800, -79.5600 8.9350)')),
  (5, 'Cruce del antimeridiano', ST_GeomFromText('LINESTRING(178.0 -16.5, 179.9 -17.8, -179.2 -18.3, -177.0 -19.5)'));

-- The mixed table carries the cases a spatial index cannot hold: NULL, and
-- the heterogeneous types.
INSERT IGNORE INTO spatial_mixed (id, label, note, shape) VALUES
  (1, 'Punto suelto',      'POINT normal',                          ST_GeomFromText('POINT(-84.0907 9.9281)')),
  (2, 'Sin geometría',     'NULL: debe contarse y decirse',         NULL),
  (3, 'Otra sin geometría','Segundo NULL, para que el recuento sea plural', NULL),
  (4, 'Boyas',             'MULTIPOINT',                            ST_GeomFromText('MULTIPOINT((-84.80 9.50),(-84.60 9.20),(-84.20 8.90))')),
  (5, 'Red fluvial',       'MULTILINESTRING',                       ST_GeomFromText('MULTILINESTRING((-84.90 10.40, -84.60 10.20),(-84.50 10.55, -84.30 10.05))')),
  (6, 'Archipiélago',      'MULTIPOLYGON de dos islas',             ST_GeomFromText('MULTIPOLYGON(((-17.90 28.60, -17.70 28.60, -17.70 28.40, -17.90 28.40, -17.90 28.60)),((-16.30 28.60, -16.10 28.60, -16.10 28.00, -16.30 28.00, -16.30 28.60)))')),
  (7, 'Estación completa', 'GEOMETRYCOLLECTION de tres tipos',      ST_GeomFromText('GEOMETRYCOLLECTION(POINT(-84.0907 9.9281),LINESTRING(-84.0907 9.9281, -84.0500 9.9500),POLYGON((-84.12 9.95, -84.06 9.95, -84.06 9.90, -84.12 9.90, -84.12 9.95)))')),
  (8, 'Polo norte',        'POINT en latitud 90',                   ST_GeomFromText('POINT(0 90)')),
  (9, 'Punto nulo',        'POINT en (0,0), donde acaban las conversiones mal hechas', ST_GeomFromText('POINT(0 0)')),
  (10,'Precisión larga',   'Quince decimales, para comprobar el ida y vuelta', ST_GeomFromText('POINT(-84.090725000000000 9.928069444444444)'));
SEED_73_EOF

  # ══════════════════════════════════════════════════════════════════════════
  #  GIS SEED — applied only to mysql8-gis:3321, plegado ahi el 2026-09-12
  # ══════════════════════════════════════════════════════════════════════════
  #
  #  AXIS ORDER — read this before touching a single coordinate.
  #
  #  MySQL 8 honours the axis order declared by the spatial reference system.
  #  EPSG:4326 declares LATITUDE FIRST, so in the `gis` database every WKT is
  #  written POINT(lat lon) — POINT(40.4168 -3.7038) is Madrid.
  #
  #  In `gis_cartesian` (SRID 0) there is no reference system and therefore no
  #  declared axis order: the de-facto convention is X=longitude, so the very
  #  same city is POINT(-3.7038 40.4168), with the numbers swapped.
  #
  #  Both databases hold the same cities on purpose. A client that gets the
  #  axis order wrong renders one of them in the sea, and the two side by side
  #  are what makes the mistake visible instead of subtle.
  #
  #  MySQL validates 4326 ranges (lat -90..90, lon -180..180), so a swapped
  #  pair usually fails to insert rather than landing somewhere plausible —
  #  which is why the cartesian copy carries no such safety net.

  # ── MongoDB seed ──────────────────────────────────────────────────────────
  # Un solo archivo: los `.js` de /docker-entrypoint-initdb.d corren con mongosh
  # ya autenticado como root, y sólo la primera vez que se crea el volumen.
  cat > "${MONGO_SEED_DIR}/10-seed.js" <<'MONGO_SEED_EOF'
// Datos de prueba de Caliope para MongoDB.
//
// El objetivo no es el volumen sino la VARIEDAD DE TIPOS: el visor de documentos
// tiene que saber dibujar ObjectId, Date, Decimal128, Binary, arrays anidados,
// subdocumentos, null, regex y GeoJSON, y ninguno de ellos se parece a una celda
// de una tabla SQL. Una colección con documentos planos no probaría nada.

const db = db.getSiblingDB('caliope_demo');

// ── clientes: subdocumentos, arrays y tipos variados ────────────────────────
db.clientes.insertMany([
  {
    _id: ObjectId('000000000000000000000001'),
    nombre: 'Ana Villalobos',
    correo: 'ana@ejemplo.cr',
    activo: true,
    saldo: NumberDecimal('1520.75'),
    visitas: NumberInt(42),
    ultimoAcceso: new Date('2026-08-01T10:15:00Z'),
    direccion: { pais: 'Costa Rica', provincia: 'Alajuela', codigo: NumberInt(20101) },
    etiquetas: ['preferente', 'boletin'],
    telefonos: [ { tipo: 'movil', numero: '+506 8888 0000' },
                 { tipo: 'casa',  numero: '+506 2222 0000' } ],
    notas: null
  },
  {
    _id: ObjectId('000000000000000000000002'),
    nombre: 'Bruno Solís',
    correo: 'bruno@ejemplo.cr',
    activo: false,
    saldo: NumberDecimal('-40.00'),
    visitas: NumberLong('9007199254740993'),   // fuera del rango exacto de Double
    ultimoAcceso: new Date('2025-12-24T23:59:59Z'),
    direccion: { pais: 'España', provincia: 'Madrid', codigo: NumberInt(28001) },
    etiquetas: [],
    telefonos: [],
    notas: 'Pendiente de verificación'
  }
]);

// ── pedidos: referencias y arrays de subdocumentos ──────────────────────────
db.pedidos.insertMany([
  { cliente: ObjectId('000000000000000000000001'),
    fecha: new Date('2026-07-30T14:00:00Z'),
    estado: 'entregado',
    total: NumberDecimal('89.90'),
    lineas: [ { sku: 'A-1', cantidad: NumberInt(2), precio: NumberDecimal('19.95') },
              { sku: 'B-7', cantidad: NumberInt(1), precio: NumberDecimal('50.00') } ] },
  { cliente: ObjectId('000000000000000000000002'),
    fecha: new Date('2026-08-05T09:30:00Z'),
    estado: 'pendiente',
    total: NumberDecimal('12.00'),
    lineas: [ { sku: 'C-3', cantidad: NumberInt(1), precio: NumberDecimal('12.00') } ] }
]);

// ── binarios: BinData y UUID, que no son texto y no deben intentar decodificarse ──
db.binarios.insertMany([
  { nombre: 'miniatura.png', datos: BinData(0, 'iVBORw0KGgoAAAANSUhEUg=='), bytes: NumberInt(16) },
  { nombre: 'sesion',        uuid:  UUID('3b241101-e2bb-4255-8caf-4136c566a962') }
]);

// ── lugares: GeoJSON, con su índice 2dsphere ────────────────────────────────
db.lugares.insertMany([
  { nombre: 'San José',  ubicacion: { type: 'Point', coordinates: [-84.0907, 9.9281] } },
  { nombre: 'Liberia',   ubicacion: { type: 'Point', coordinates: [-85.4370, 10.6346] } },
  { nombre: 'Ruta',      ubicacion: { type: 'LineString',
      coordinates: [[-84.0907, 9.9281], [-85.4370, 10.6346]] } }
]);
db.lugares.createIndex({ ubicacion: '2dsphere' });

// ── articulos: índice de texto, compuesto y único ───────────────────────────
db.articulos.insertMany([
  { sku: 'A-1', titulo: 'Teclado mecánico',  descripcion: 'Interruptores táctiles',  precio: NumberDecimal('19.95'), patron: /tecl.*/i },
  { sku: 'B-7', titulo: 'Monitor 27 pulgadas', descripcion: 'Panel IPS a 120 Hz',    precio: NumberDecimal('50.00'), patron: /moni.*/i },
  { sku: 'C-3', titulo: 'Cable USB-C',       descripcion: 'Dos metros, trenzado',    precio: NumberDecimal('12.00'), patron: /cab.*/i }
]);
db.articulos.createIndex({ sku: 1 }, { unique: true });
db.articulos.createIndex({ titulo: 'text', descripcion: 'text' });
db.articulos.createIndex({ precio: -1, titulo: 1 });

// ── sesiones: índice TTL ────────────────────────────────────────────────────
db.sesiones.insertOne({ token: 'abc123', creada: new Date() });
db.sesiones.createIndex({ creada: 1 }, { expireAfterSeconds: 3600 });

// ── eventos: volumen suficiente para paginar de verdad ──────────────────────
const lote = [];
for (let i = 0; i < 10000; i++) {
  lote.push({
    secuencia: NumberInt(i),
    tipo: ['clic', 'vista', 'compra', 'error'][i % 4],
    creado: new Date(Date.now() - i * 60000),
    carga: { pagina: '/p/' + (i % 50), duracionMs: NumberInt((i * 7) % 900) }
  });
}
db.eventos.insertMany(lote);
db.eventos.createIndex({ creado: -1 });
db.eventos.createIndex({ tipo: 1, secuencia: 1 });

// ── vista: existe en Mongo y el explorador debe distinguirla de una colección ──
db.createView('pedidos_pendientes', 'pedidos', [{ $match: { estado: 'pendiente' } }]);

// ── colección capada: otra clase de objeto que el explorador debe saber leer ──
db.createCollection('bitacora', { capped: true, size: 65536, max: 500 });
db.bitacora.insertOne({ mensaje: 'testbed iniciado', creado: new Date() });

// ── usuarios y roles: lo que alimenta la herramienta de administración ──────
db.createRole({
  role: 'lectorInformes',
  privileges: [ { resource: { db: 'caliope_demo', collection: 'pedidos' }, actions: ['find'] } ],
  roles: []
});
db.createUser({ user: 'lector',  pwd: 'lectorpass',  roles: [ { role: 'read', db: 'caliope_demo' } ] });
db.createUser({ user: 'informes', pwd: 'informespass', roles: [ { role: 'lectorInformes', db: 'caliope_demo' } ] });

// ── usuarios del transporte propio (plan 60): los dos en `admin`, que es el
// `authSource` por omisión de Calíope. `legado` sólo admite SCRAM-SHA-1, y la
// contraseña de `acentos` va con la «ñ» precompuesta (U+00F1), como la guarda
// el servidor tras su propio SASLprep ──
db.getSiblingDB('admin').createUser({ user: 'legado', pwd: 'legadopass', roles: [ { role: 'read', db: 'caliope_demo' } ], mechanisms: ['SCRAM-SHA-1'] });
db.getSiblingDB('admin').createUser({ user: 'acentos', pwd: 'contrase\u00f1a', roles: [ { role: 'read', db: 'caliope_demo' } ] });

print('[caliope] seed de MongoDB aplicado sobre caliope_demo');
MONGO_SEED_EOF

  cat > "${GIS_SEED_DIR}/10-schemas.sql" <<'GIS_10_EOF'
-- El entrypoint de MySQL aplica este archivo con el cliente `mysql`, que no negocia
-- el juego de caracteres: sin esta línea lee el fichero como latin1 y guarda cada
-- acento doblemente codificado (C383C2A9 en vez de C3A9 para «é»).
SET NAMES utf8mb4;
-- Dedicated spatial node. `gis` is created by MYSQL_DATABASE; the cartesian
-- twin and the privileges are set up here.
CREATE DATABASE IF NOT EXISTS gis
  CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE DATABASE IF NOT EXISTS gis_cartesian
  CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

-- Same credentials as the rest of the testbed so connection profiles can be
-- swapped without re-editing user and password.
CREATE USER IF NOT EXISTS 'caliope'@'%' IDENTIFIED BY 'caliopepass';
GRANT ALL PRIVILEGES ON *.* TO 'caliope'@'%' WITH GRANT OPTION;
FLUSH PRIVILEGES;
GIS_10_EOF

  cat > "${GIS_SEED_DIR}/20-tables.sql" <<'GIS_20_EOF'
-- El entrypoint de MySQL aplica este archivo con el cliente `mysql`, que no negocia
-- el juego de caracteres: sin esta línea lee el fichero como latin1 y guarda cada
-- acento doblemente codificado (C383C2A9 en vez de C3A9 para «é»).
SET NAMES utf8mb4;
USE gis;

-- ── Points ──────────────────────────────────────────────────────────────────
-- A spatial index requires NOT NULL, so the nullable-geometry cases live in
-- `zonas_complejas` instead (see 32-degeneradas.sql).
CREATE TABLE IF NOT EXISTS ciudades (
  id           INT AUTO_INCREMENT PRIMARY KEY,
  nombre       VARCHAR(80)  NOT NULL,
  pais         CHAR(2)      NOT NULL,
  continente   ENUM('Europa','América del Norte','América Central',
                    'América del Sur','África','Asia','Oceanía','Antártida') NOT NULL,
  poblacion    INT UNSIGNED NULL COMMENT 'Área metropolitana aproximada, redondeada',
  es_capital   TINYINT(1)   NOT NULL DEFAULT 0,
  ubicacion    POINT        NOT NULL SRID 4326,
  actualizada  TIMESTAMP    DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  SPATIAL INDEX idx_ubicacion (ubicacion),
  INDEX idx_pais (pais),
  INDEX idx_continente (continente)
) ENGINE=InnoDB COMMENT='Puntos SRID 4326 — orden de ejes latitud, longitud';

CREATE TABLE IF NOT EXISTS aeropuertos (
  id           INT AUTO_INCREMENT PRIMARY KEY,
  iata         CHAR(3)      NOT NULL,
  nombre       VARCHAR(120) NOT NULL,
  ciudad_id    INT          NULL,
  altitud_m    INT          NULL,
  ubicacion    POINT        NOT NULL SRID 4326,
  UNIQUE KEY uq_iata (iata),
  FOREIGN KEY (ciudad_id) REFERENCES ciudades(id) ON DELETE SET NULL,
  SPATIAL INDEX idx_ubicacion (ubicacion)
) ENGINE=InnoDB;

-- ── Lines ───────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS rutas (
  id           INT AUTO_INCREMENT PRIMARY KEY,
  nombre       VARCHAR(120) NOT NULL,
  tipo         ENUM('aérea','marítima','terrestre','ferroviaria') NOT NULL,
  distancia_km DECIMAL(10,2) NULL,
  trazado      LINESTRING   NOT NULL SRID 4326,
  SPATIAL INDEX idx_trazado (trazado),
  INDEX idx_tipo (tipo)
) ENGINE=InnoDB;

-- ── Polygons ────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS regiones (
  id           INT AUTO_INCREMENT PRIMARY KEY,
  nombre       VARCHAR(120) NOT NULL,
  clase        ENUM('país','provincia','parque','zona urbana','reserva') NOT NULL,
  area_km2     DECIMAL(14,2) NULL,
  contorno     POLYGON      NOT NULL SRID 4326,
  SPATIAL INDEX idx_contorno (contorno),
  INDEX idx_clase (clase)
) ENGINE=InnoDB COMMENT='Contornos aproximados: cajas envolventes, no fronteras reales';

-- ── The awkward cases ───────────────────────────────────────────────────────
-- Nullable GEOMETRY column, hence no spatial index. This is the table that
-- breaks naive parsers and naive map framing, and it is here on purpose.
CREATE TABLE IF NOT EXISTS zonas_complejas (
  id           INT AUTO_INCREMENT PRIMARY KEY,
  nombre       VARCHAR(120) NOT NULL,
  motivo       VARCHAR(200) NOT NULL COMMENT 'Qué caso límite ejercita esta fila',
  forma        GEOMETRY     NULL SRID 4326,
  INDEX idx_nombre (nombre)
) ENGINE=InnoDB;

-- ── Bulk ────────────────────────────────────────────────────────────────────
-- Populated by 50-bulk.sql. Sized to sit above the map's 10 000-geometry
-- ceiling so the truncation notice is reachable without writing a query by
-- hand.
CREATE TABLE IF NOT EXISTS sensores (
  id           INT AUTO_INCREMENT PRIMARY KEY,
  codigo       VARCHAR(24)  NOT NULL,
  lote         SMALLINT     NOT NULL,
  temperatura  DECIMAL(5,2) NULL,
  activo       TINYINT(1)   NOT NULL DEFAULT 1,
  ubicacion    POINT        NOT NULL SRID 4326,
  medido_en    DATETIME     NOT NULL,
  SPATIAL INDEX idx_ubicacion (ubicacion),
  INDEX idx_lote (lote)
) ENGINE=InnoDB;

CREATE TABLE IF NOT EXISTS parcelas (
  id           INT AUTO_INCREMENT PRIMARY KEY,
  referencia   VARCHAR(24)  NOT NULL,
  cultivo      VARCHAR(40)  NULL,
  contorno     POLYGON      NOT NULL SRID 4326,
  SPATIAL INDEX idx_contorno (contorno)
) ENGINE=InnoDB;

-- ── A view over geometries, to check that the column type survives it ───────
CREATE OR REPLACE VIEW v_capitales AS
  SELECT id, nombre, pais, poblacion, ubicacion
    FROM ciudades
   WHERE es_capital = 1;
GIS_20_EOF

  cat > "${GIS_SEED_DIR}/30-seed-ciudades.sql" <<'GIS_30_EOF'
-- El entrypoint de MySQL aplica este archivo con el cliente `mysql`, que no negocia
-- el juego de caracteres: sin esta línea lee el fichero como latin1 y guarda cada
-- acento doblemente codificado (C383C2A9 en vez de C3A9 para «é»).
SET NAMES utf8mb4;
USE gis;

-- POINT(latitud longitud) — EPSG:4326 declares latitude first and MySQL 8
-- honours it. Populations are rounded metropolitan-area figures: testbed
-- material, not an atlas.
INSERT INTO ciudades (nombre, pais, continente, poblacion, es_capital, ubicacion) VALUES
  ('Madrid','ES','Europa',6700000,1,ST_GeomFromText('POINT(40.4168 -3.7038)',4326)),
  ('Barcelona','ES','Europa',5600000,0,ST_GeomFromText('POINT(41.3874 2.1686)',4326)),
  ('Valencia','ES','Europa',1600000,0,ST_GeomFromText('POINT(39.4699 -0.3763)',4326)),
  ('Sevilla','ES','Europa',1500000,0,ST_GeomFromText('POINT(37.3891 -5.9845)',4326)),
  ('Bilbao','ES','Europa',1000000,0,ST_GeomFromText('POINT(43.2630 -2.9350)',4326)),
  ('Lisboa','PT','Europa',2900000,1,ST_GeomFromText('POINT(38.7223 -9.1393)',4326)),
  ('Oporto','PT','Europa',1700000,0,ST_GeomFromText('POINT(41.1579 -8.6291)',4326)),
  ('París','FR','Europa',11100000,1,ST_GeomFromText('POINT(48.8566 2.3522)',4326)),
  ('Marsella','FR','Europa',1600000,0,ST_GeomFromText('POINT(43.2965 5.3698)',4326)),
  ('Londres','GB','Europa',9600000,1,ST_GeomFromText('POINT(51.5074 -0.1278)',4326)),
  ('Mánchester','GB','Europa',2800000,0,ST_GeomFromText('POINT(53.4808 -2.2426)',4326)),
  ('Edimburgo','GB','Europa',900000,0,ST_GeomFromText('POINT(55.9533 -3.1883)',4326)),
  ('Dublín','IE','Europa',1400000,1,ST_GeomFromText('POINT(53.3498 -6.2603)',4326)),
  ('Berlín','DE','Europa',4500000,1,ST_GeomFromText('POINT(52.5200 13.4050)',4326)),
  ('Múnich','DE','Europa',2600000,0,ST_GeomFromText('POINT(48.1351 11.5820)',4326)),
  ('Hamburgo','DE','Europa',2500000,0,ST_GeomFromText('POINT(53.5511 9.9937)',4326)),
  ('Colonia','DE','Europa',2100000,0,ST_GeomFromText('POINT(50.9375 6.9603)',4326)),
  ('Ámsterdam','NL','Europa',2500000,1,ST_GeomFromText('POINT(52.3676 4.9041)',4326)),
  ('Róterdam','NL','Europa',1200000,0,ST_GeomFromText('POINT(51.9244 4.4777)',4326)),
  ('Bruselas','BE','Europa',2100000,1,ST_GeomFromText('POINT(50.8503 4.3517)',4326)),
  ('Zúrich','CH','Europa',1400000,0,ST_GeomFromText('POINT(47.3769 8.5417)',4326)),
  ('Ginebra','CH','Europa',600000,0,ST_GeomFromText('POINT(46.2044 6.1432)',4326)),
  ('Viena','AT','Europa',2000000,1,ST_GeomFromText('POINT(48.2082 16.3738)',4326)),
  ('Roma','IT','Europa',4300000,1,ST_GeomFromText('POINT(41.9028 12.4964)',4326)),
  ('Milán','IT','Europa',3200000,0,ST_GeomFromText('POINT(45.4642 9.1900)',4326)),
  ('Nápoles','IT','Europa',3100000,0,ST_GeomFromText('POINT(40.8518 14.2681)',4326)),
  ('Venecia','IT','Europa',600000,0,ST_GeomFromText('POINT(45.4408 12.3155)',4326)),
  ('Praga','CZ','Europa',1300000,1,ST_GeomFromText('POINT(50.0755 14.4378)',4326)),
  ('Varsovia','PL','Europa',3100000,1,ST_GeomFromText('POINT(52.2297 21.0122)',4326)),
  ('Cracovia','PL','Europa',1000000,0,ST_GeomFromText('POINT(50.0647 19.9450)',4326)),
  ('Budapest','HU','Europa',1800000,1,ST_GeomFromText('POINT(47.4979 19.0402)',4326)),
  ('Bucarest','RO','Europa',2200000,1,ST_GeomFromText('POINT(44.4268 26.1025)',4326)),
  ('Sofía','BG','Europa',1300000,1,ST_GeomFromText('POINT(42.6977 23.3219)',4326)),
  ('Belgrado','RS','Europa',1700000,1,ST_GeomFromText('POINT(44.7866 20.4489)',4326)),
  ('Zagreb','HR','Europa',800000,1,ST_GeomFromText('POINT(45.8150 15.9819)',4326)),
  ('Atenas','GR','Europa',3200000,1,ST_GeomFromText('POINT(37.9838 23.7275)',4326)),
  ('Estocolmo','SE','Europa',2400000,1,ST_GeomFromText('POINT(59.3293 18.0686)',4326)),
  ('Oslo','NO','Europa',1100000,1,ST_GeomFromText('POINT(59.9139 10.7522)',4326)),
  ('Copenhague','DK','Europa',2100000,1,ST_GeomFromText('POINT(55.6761 12.5683)',4326)),
  ('Helsinki','FI','Europa',1300000,1,ST_GeomFromText('POINT(60.1699 24.9384)',4326)),
  ('Reikiavik','IS','Europa',230000,1,ST_GeomFromText('POINT(64.1466 -21.9426)',4326)),
  ('Estambul','TR','Europa',15500000,0,ST_GeomFromText('POINT(41.0082 28.9784)',4326)),
  ('Moscú','RU','Europa',12600000,1,ST_GeomFromText('POINT(55.7558 37.6173)',4326)),
  ('San Petersburgo','RU','Europa',5400000,0,ST_GeomFromText('POINT(59.9311 30.3609)',4326)),
  ('Kiev','UA','Europa',3000000,1,ST_GeomFromText('POINT(50.4501 30.5234)',4326)),
  ('Longyearbyen','NO','Europa',2400,0,ST_GeomFromText('POINT(78.2232 15.6267)',4326)),
  ('Nueva York','US','América del Norte',18800000,0,ST_GeomFromText('POINT(40.7128 -74.0060)',4326)),
  ('Los Ángeles','US','América del Norte',12500000,0,ST_GeomFromText('POINT(34.0522 -118.2437)',4326)),
  ('Chicago','US','América del Norte',8900000,0,ST_GeomFromText('POINT(41.8781 -87.6298)',4326)),
  ('Houston','US','América del Norte',7100000,0,ST_GeomFromText('POINT(29.7604 -95.3698)',4326)),
  ('Phoenix','US','América del Norte',4900000,0,ST_GeomFromText('POINT(33.4484 -112.0740)',4326)),
  ('Filadelfia','US','América del Norte',6200000,0,ST_GeomFromText('POINT(39.9526 -75.1652)',4326)),
  ('San Antonio','US','América del Norte',2600000,0,ST_GeomFromText('POINT(29.4241 -98.4936)',4326)),
  ('San Diego','US','América del Norte',3300000,0,ST_GeomFromText('POINT(32.7157 -117.1611)',4326)),
  ('Dallas','US','América del Norte',7600000,0,ST_GeomFromText('POINT(32.7767 -96.7970)',4326)),
  ('San Francisco','US','América del Norte',4700000,0,ST_GeomFromText('POINT(37.7749 -122.4194)',4326)),
  ('Seattle','US','América del Norte',4000000,0,ST_GeomFromText('POINT(47.6062 -122.3321)',4326)),
  ('Denver','US','América del Norte',2900000,0,ST_GeomFromText('POINT(39.7392 -104.9903)',4326)),
  ('Boston','US','América del Norte',4900000,0,ST_GeomFromText('POINT(42.3601 -71.0589)',4326)),
  ('Miami','US','América del Norte',6100000,0,ST_GeomFromText('POINT(25.7617 -80.1918)',4326)),
  ('Atlanta','US','América del Norte',6100000,0,ST_GeomFromText('POINT(33.7490 -84.3880)',4326)),
  ('Washington','US','América del Norte',6400000,1,ST_GeomFromText('POINT(38.9072 -77.0369)',4326)),
  ('Nueva Orleans','US','América del Norte',1300000,0,ST_GeomFromText('POINT(29.9511 -90.0715)',4326)),
  ('Anchorage','US','América del Norte',290000,0,ST_GeomFromText('POINT(61.2181 -149.9003)',4326)),
  ('Honolulu','US','Oceanía',1000000,0,ST_GeomFromText('POINT(21.3069 -157.8583)',4326)),
  ('Utqiagvik','US','América del Norte',4400,0,ST_GeomFromText('POINT(71.2906 -156.7886)',4326)),
  ('Toronto','CA','América del Norte',6200000,0,ST_GeomFromText('POINT(43.6532 -79.3832)',4326)),
  ('Montreal','CA','América del Norte',4300000,0,ST_GeomFromText('POINT(45.5017 -73.5673)',4326)),
  ('Vancouver','CA','América del Norte',2600000,0,ST_GeomFromText('POINT(49.2827 -123.1207)',4326)),
  ('Ottawa','CA','América del Norte',1500000,1,ST_GeomFromText('POINT(45.4215 -75.6972)',4326)),
  ('Ciudad de México','MX','América del Norte',21800000,1,ST_GeomFromText('POINT(19.4326 -99.1332)',4326)),
  ('Guadalajara','MX','América del Norte',5300000,0,ST_GeomFromText('POINT(20.6597 -103.3496)',4326)),
  ('Monterrey','MX','América del Norte',5300000,0,ST_GeomFromText('POINT(25.6866 -100.3161)',4326)),
  ('Cancún','MX','América del Norte',900000,0,ST_GeomFromText('POINT(21.1619 -86.8515)',4326)),
  ('San José','CR','América Central',1400000,1,ST_GeomFromText('POINT(9.9281 -84.0907)',4326)),
  ('Limón','CR','América Central',60000,0,ST_GeomFromText('POINT(9.9907 -83.0359)',4326)),
  ('Liberia','CR','América Central',70000,0,ST_GeomFromText('POINT(10.6346 -85.4377)',4326)),
  ('Ciudad de Panamá','PA','América Central',1900000,1,ST_GeomFromText('POINT(8.9824 -79.5199)',4326)),
  ('Ciudad de Guatemala','GT','América Central',3000000,1,ST_GeomFromText('POINT(14.6349 -90.5069)',4326)),
  ('San Salvador','SV','América Central',1800000,1,ST_GeomFromText('POINT(13.6929 -89.2182)',4326)),
  ('Tegucigalpa','HN','América Central',1400000,1,ST_GeomFromText('POINT(14.0723 -87.1921)',4326)),
  ('Managua','NI','América Central',1400000,1,ST_GeomFromText('POINT(12.1150 -86.2362)',4326)),
  ('La Habana','CU','América Central',2100000,1,ST_GeomFromText('POINT(23.1136 -82.3666)',4326)),
  ('Santo Domingo','DO','América Central',3500000,1,ST_GeomFromText('POINT(18.4861 -69.9312)',4326)),
  ('San Juan','PR','América Central',2400000,0,ST_GeomFromText('POINT(18.4655 -66.1057)',4326)),
  ('Kingston','JM','América Central',1200000,1,ST_GeomFromText('POINT(17.9714 -76.7931)',4326)),
  ('Bogotá','CO','América del Sur',11000000,1,ST_GeomFromText('POINT(4.7110 -74.0721)',4326)),
  ('Medellín','CO','América del Sur',4000000,0,ST_GeomFromText('POINT(6.2442 -75.5812)',4326)),
  ('Cartagena','CO','América del Sur',1000000,0,ST_GeomFromText('POINT(10.3910 -75.4794)',4326)),
  ('Caracas','VE','América del Sur',2900000,1,ST_GeomFromText('POINT(10.4806 -66.9036)',4326)),
  ('Quito','EC','América del Sur',2000000,1,ST_GeomFromText('POINT(-0.1807 -78.4678)',4326)),
  ('Guayaquil','EC','América del Sur',2700000,0,ST_GeomFromText('POINT(-2.1894 -79.8891)',4326)),
  ('Lima','PE','América del Sur',10700000,1,ST_GeomFromText('POINT(-12.0464 -77.0428)',4326)),
  ('Cusco','PE','América del Sur',430000,0,ST_GeomFromText('POINT(-13.5320 -71.9675)',4326)),
  ('La Paz','BO','América del Sur',1900000,1,ST_GeomFromText('POINT(-16.4897 -68.1193)',4326)),
  ('Santiago','CL','América del Sur',7100000,1,ST_GeomFromText('POINT(-33.4489 -70.6693)',4326)),
  ('Valparaíso','CL','América del Sur',1000000,0,ST_GeomFromText('POINT(-33.0472 -71.6127)',4326)),
  ('Punta Arenas','CL','América del Sur',130000,0,ST_GeomFromText('POINT(-53.1638 -70.9171)',4326)),
  ('Buenos Aires','AR','América del Sur',15400000,1,ST_GeomFromText('POINT(-34.6037 -58.3816)',4326)),
  ('Córdoba','AR','América del Sur',1600000,0,ST_GeomFromText('POINT(-31.4201 -64.1888)',4326)),
  ('Mendoza','AR','América del Sur',1100000,0,ST_GeomFromText('POINT(-32.8895 -68.8458)',4326)),
  ('Ushuaia','AR','América del Sur',82000,0,ST_GeomFromText('POINT(-54.8019 -68.3030)',4326)),
  ('Montevideo','UY','América del Sur',1800000,1,ST_GeomFromText('POINT(-34.9011 -56.1645)',4326)),
  ('Asunción','PY','América del Sur',2300000,1,ST_GeomFromText('POINT(-25.2637 -57.5759)',4326)),
  ('São Paulo','BR','América del Sur',22400000,0,ST_GeomFromText('POINT(-23.5505 -46.6333)',4326)),
  ('Río de Janeiro','BR','América del Sur',13500000,0,ST_GeomFromText('POINT(-22.9068 -43.1729)',4326)),
  ('Brasilia','BR','América del Sur',4800000,1,ST_GeomFromText('POINT(-15.7975 -47.8919)',4326)),
  ('Salvador','BR','América del Sur',3900000,0,ST_GeomFromText('POINT(-12.9777 -38.5016)',4326)),
  ('Fortaleza','BR','América del Sur',4100000,0,ST_GeomFromText('POINT(-3.7319 -38.5267)',4326)),
  ('Manaos','BR','América del Sur',2200000,0,ST_GeomFromText('POINT(-3.1190 -60.0217)',4326)),
  ('El Cairo','EG','África',21300000,1,ST_GeomFromText('POINT(30.0444 31.2357)',4326)),
  ('Alejandría','EG','África',5400000,0,ST_GeomFromText('POINT(31.2001 29.9187)',4326)),
  ('Lagos','NG','África',15400000,0,ST_GeomFromText('POINT(6.5244 3.3792)',4326)),
  ('Abuya','NG','África',3600000,1,ST_GeomFromText('POINT(9.0765 7.3986)',4326)),
  ('Kinsasa','CD','África',15600000,1,ST_GeomFromText('POINT(-4.4419 15.2663)',4326)),
  ('Johannesburgo','ZA','África',6000000,0,ST_GeomFromText('POINT(-26.2041 28.0473)',4326)),
  ('Ciudad del Cabo','ZA','África',4700000,1,ST_GeomFromText('POINT(-33.9249 18.4241)',4326)),
  ('Nairobi','KE','África',5100000,1,ST_GeomFromText('POINT(-1.2921 36.8219)',4326)),
  ('Adís Abeba','ET','África',5200000,1,ST_GeomFromText('POINT(9.0320 38.7469)',4326)),
  ('Casablanca','MA','África',3800000,0,ST_GeomFromText('POINT(33.5731 -7.5898)',4326)),
  ('Rabat','MA','África',1900000,1,ST_GeomFromText('POINT(34.0209 -6.8416)',4326)),
  ('Argel','DZ','África',3400000,1,ST_GeomFromText('POINT(36.7538 3.0588)',4326)),
  ('Túnez','TN','África',2400000,1,ST_GeomFromText('POINT(36.8065 10.1815)',4326)),
  ('Accra','GH','África',2600000,1,ST_GeomFromText('POINT(5.6037 -0.1870)',4326)),
  ('Dakar','SN','África',3100000,1,ST_GeomFromText('POINT(14.7167 -17.4677)',4326)),
  ('Luanda','AO','África',8300000,1,ST_GeomFromText('POINT(-8.8390 13.2894)',4326)),
  ('Jartum','SD','África',5800000,1,ST_GeomFromText('POINT(15.5007 32.5599)',4326)),
  ('Harare','ZW','África',1600000,1,ST_GeomFromText('POINT(-17.8252 31.0335)',4326)),
  ('Antananarivo','MG','África',1400000,1,ST_GeomFromText('POINT(-18.8792 47.5079)',4326)),
  ('Tokio','JP','Asia',37400000,1,ST_GeomFromText('POINT(35.6762 139.6503)',4326)),
  ('Osaka','JP','Asia',19000000,0,ST_GeomFromText('POINT(34.6937 135.5023)',4326)),
  ('Sapporo','JP','Asia',2700000,0,ST_GeomFromText('POINT(43.0618 141.3545)',4326)),
  ('Seúl','KR','Asia',25600000,1,ST_GeomFromText('POINT(37.5665 126.9780)',4326)),
  ('Pekín','CN','Asia',21500000,1,ST_GeomFromText('POINT(39.9042 116.4074)',4326)),
  ('Shanghái','CN','Asia',28500000,0,ST_GeomFromText('POINT(31.2304 121.4737)',4326)),
  ('Cantón','CN','Asia',18700000,0,ST_GeomFromText('POINT(23.1291 113.2644)',4326)),
  ('Shenzhen','CN','Asia',17500000,0,ST_GeomFromText('POINT(22.5431 114.0579)',4326)),
  ('Hong Kong','HK','Asia',7500000,0,ST_GeomFromText('POINT(22.3193 114.1694)',4326)),
  ('Taipéi','TW','Asia',7000000,1,ST_GeomFromText('POINT(25.0330 121.5654)',4326)),
  ('Manila','PH','Asia',14200000,1,ST_GeomFromText('POINT(14.5995 120.9842)',4326)),
  ('Yakarta','ID','Asia',34500000,1,ST_GeomFromText('POINT(-6.2088 106.8456)',4326)),
  ('Bangkok','TH','Asia',10700000,1,ST_GeomFromText('POINT(13.7563 100.5018)',4326)),
  ('Singapur','SG','Asia',5900000,1,ST_GeomFromText('POINT(1.3521 103.8198)',4326)),
  ('Kuala Lumpur','MY','Asia',8200000,1,ST_GeomFromText('POINT(3.1390 101.6869)',4326)),
  ('Hanói','VN','Asia',8100000,1,ST_GeomFromText('POINT(21.0285 105.8542)',4326)),
  ('Ciudad Ho Chi Minh','VN','Asia',9300000,0,ST_GeomFromText('POINT(10.8231 106.6297)',4326)),
  ('Delhi','IN','Asia',32900000,1,ST_GeomFromText('POINT(28.7041 77.1025)',4326)),
  ('Bombay','IN','Asia',21300000,0,ST_GeomFromText('POINT(19.0760 72.8777)',4326)),
  ('Bangalore','IN','Asia',13600000,0,ST_GeomFromText('POINT(12.9716 77.5946)',4326)),
  ('Calcuta','IN','Asia',15100000,0,ST_GeomFromText('POINT(22.5726 88.3639)',4326)),
  ('Chennai','IN','Asia',11500000,0,ST_GeomFromText('POINT(13.0827 80.2707)',4326)),
  ('Karachi','PK','Asia',16800000,0,ST_GeomFromText('POINT(24.8607 67.0011)',4326)),
  ('Lahore','PK','Asia',13500000,0,ST_GeomFromText('POINT(31.5204 74.3587)',4326)),
  ('Daca','BD','Asia',22500000,1,ST_GeomFromText('POINT(23.8103 90.4125)',4326)),
  ('Colombo','LK','Asia',800000,1,ST_GeomFromText('POINT(6.9271 79.8612)',4326)),
  ('Katmandú','NP','Asia',1500000,1,ST_GeomFromText('POINT(27.7172 85.3240)',4326)),
  ('Teherán','IR','Asia',9500000,1,ST_GeomFromText('POINT(35.6892 51.3890)',4326)),
  ('Bagdad','IQ','Asia',7500000,1,ST_GeomFromText('POINT(33.3152 44.3661)',4326)),
  ('Riad','SA','Asia',7700000,1,ST_GeomFromText('POINT(24.7136 46.6753)',4326)),
  ('Dubái','AE','Asia',3500000,0,ST_GeomFromText('POINT(25.2048 55.2708)',4326)),
  ('Doha','QA','Asia',2400000,1,ST_GeomFromText('POINT(25.2854 51.5310)',4326)),
  ('Kuwait','KW','Asia',3100000,1,ST_GeomFromText('POINT(29.3759 47.9774)',4326)),
  ('Ammán','JO','Asia',4300000,1,ST_GeomFromText('POINT(31.9454 35.9284)',4326)),
  ('Beirut','LB','Asia',2400000,1,ST_GeomFromText('POINT(33.8938 35.5018)',4326)),
  ('Taskent','UZ','Asia',2900000,1,ST_GeomFromText('POINT(41.2995 69.2401)',4326)),
  ('Almaty','KZ','Asia',2000000,0,ST_GeomFromText('POINT(43.2220 76.8512)',4326)),
  ('Novosibirsk','RU','Asia',1600000,0,ST_GeomFromText('POINT(55.0084 82.9357)',4326)),
  ('Vladivostok','RU','Asia',600000,0,ST_GeomFromText('POINT(43.1332 131.9113)',4326)),
  ('Anadyr','RU','Asia',13000,0,ST_GeomFromText('POINT(64.7314 177.5083)',4326)),
  ('Sídney','AU','Oceanía',5300000,0,ST_GeomFromText('POINT(-33.8688 151.2093)',4326)),
  ('Melbourne','AU','Oceanía',5100000,0,ST_GeomFromText('POINT(-37.8136 144.9631)',4326)),
  ('Brisbane','AU','Oceanía',2600000,0,ST_GeomFromText('POINT(-27.4698 153.0251)',4326)),
  ('Perth','AU','Oceanía',2100000,0,ST_GeomFromText('POINT(-31.9505 115.8605)',4326)),
  ('Canberra','AU','Oceanía',460000,1,ST_GeomFromText('POINT(-35.2809 149.1300)',4326)),
  ('Auckland','NZ','Oceanía',1700000,0,ST_GeomFromText('POINT(-36.8485 174.7633)',4326)),
  ('Wellington','NZ','Oceanía',420000,1,ST_GeomFromText('POINT(-41.2865 174.7762)',4326)),
  ('Suva','FJ','Oceanía',95000,1,ST_GeomFromText('POINT(-18.1416 178.4419)',4326)),
  ('Nukualofa','TO','Oceanía',23000,1,ST_GeomFromText('POINT(-21.1393 -175.2018)',4326)),
  ('Apia','WS','Oceanía',37000,1,ST_GeomFromText('POINT(-13.8506 -171.7513)',4326)),
  ('Numea','NC','Oceanía',94000,0,ST_GeomFromText('POINT(-22.2758 166.4572)',4326)),
  ('Port Moresby','PG','Oceanía',380000,1,ST_GeomFromText('POINT(-9.4438 147.1803)',4326)),
  ('Base Esperanza','AQ','Antártida',100,0,ST_GeomFromText('POINT(-63.3975 -56.9967)',4326)),
  ('Base McMurdo','AQ','Antártida',1200,0,ST_GeomFromText('POINT(-77.8419 166.6863)',4326));

-- Airports, some deliberately without a linked city so the FK is nullable in
-- practice and not just in the schema.
INSERT INTO aeropuertos (iata, nombre, ciudad_id, altitud_m, ubicacion) VALUES
  ('MAD','Adolfo Suárez Madrid-Barajas',(SELECT id FROM ciudades WHERE nombre='Madrid'),610,ST_GeomFromText('POINT(40.4719 -3.5626)',4326)),
  ('BCN','Josep Tarradellas Barcelona-El Prat',(SELECT id FROM ciudades WHERE nombre='Barcelona'),4,ST_GeomFromText('POINT(41.2974 2.0833)',4326)),
  ('LIS','Humberto Delgado',(SELECT id FROM ciudades WHERE nombre='Lisboa'),114,ST_GeomFromText('POINT(38.7742 -9.1342)',4326)),
  ('CDG','Charles de Gaulle',(SELECT id FROM ciudades WHERE nombre='París'),119,ST_GeomFromText('POINT(49.0097 2.5479)',4326)),
  ('LHR','Heathrow',(SELECT id FROM ciudades WHERE nombre='Londres'),25,ST_GeomFromText('POINT(51.4700 -0.4543)',4326)),
  ('AMS','Schiphol',(SELECT id FROM ciudades WHERE nombre='Ámsterdam'),-3,ST_GeomFromText('POINT(52.3105 4.7683)',4326)),
  ('FRA','Fráncfort del Meno',NULL,111,ST_GeomFromText('POINT(50.0379 8.5622)',4326)),
  ('FCO','Leonardo da Vinci-Fiumicino',(SELECT id FROM ciudades WHERE nombre='Roma'),5,ST_GeomFromText('POINT(41.8003 12.2389)',4326)),
  ('IST','Estambul',(SELECT id FROM ciudades WHERE nombre='Estambul'),99,ST_GeomFromText('POINT(41.2753 28.7519)',4326)),
  ('JFK','John F. Kennedy',(SELECT id FROM ciudades WHERE nombre='Nueva York'),4,ST_GeomFromText('POINT(40.6413 -73.7781)',4326)),
  ('LAX','Los Ángeles',(SELECT id FROM ciudades WHERE nombre='Los Ángeles'),38,ST_GeomFromText('POINT(33.9416 -118.4085)',4326)),
  ('ORD','O''Hare',(SELECT id FROM ciudades WHERE nombre='Chicago'),204,ST_GeomFromText('POINT(41.9742 -87.9073)',4326)),
  ('SFO','San Francisco',(SELECT id FROM ciudades WHERE nombre='San Francisco'),4,ST_GeomFromText('POINT(37.6213 -122.3790)',4326)),
  ('MIA','Miami',(SELECT id FROM ciudades WHERE nombre='Miami'),3,ST_GeomFromText('POINT(25.7959 -80.2871)',4326)),
  ('YYZ','Toronto Pearson',(SELECT id FROM ciudades WHERE nombre='Toronto'),173,ST_GeomFromText('POINT(43.6777 -79.6248)',4326)),
  ('MEX','Benito Juárez',(SELECT id FROM ciudades WHERE nombre='Ciudad de México'),2230,ST_GeomFromText('POINT(19.4361 -99.0719)',4326)),
  ('SJO','Juan Santamaría',(SELECT id FROM ciudades WHERE nombre='San José'),921,ST_GeomFromText('POINT(9.9939 -84.2088)',4326)),
  ('LIR','Daniel Oduber Quirós',(SELECT id FROM ciudades WHERE nombre='Liberia'),44,ST_GeomFromText('POINT(10.5933 -85.5444)',4326)),
  ('PTY','Tocumen',(SELECT id FROM ciudades WHERE nombre='Ciudad de Panamá'),41,ST_GeomFromText('POINT(9.0714 -79.3835)',4326)),
  ('BOG','El Dorado',(SELECT id FROM ciudades WHERE nombre='Bogotá'),2548,ST_GeomFromText('POINT(4.7016 -74.1469)',4326)),
  ('LIM','Jorge Chávez',(SELECT id FROM ciudades WHERE nombre='Lima'),34,ST_GeomFromText('POINT(-12.0219 -77.1143)',4326)),
  ('SCL','Arturo Merino Benítez',(SELECT id FROM ciudades WHERE nombre='Santiago'),474,ST_GeomFromText('POINT(-33.3930 -70.7858)',4326)),
  ('EZE','Ministro Pistarini',(SELECT id FROM ciudades WHERE nombre='Buenos Aires'),20,ST_GeomFromText('POINT(-34.8222 -58.5358)',4326)),
  ('GRU','Guarulhos',(SELECT id FROM ciudades WHERE nombre='São Paulo'),750,ST_GeomFromText('POINT(-23.4356 -46.4731)',4326)),
  ('JNB','O. R. Tambo',(SELECT id FROM ciudades WHERE nombre='Johannesburgo'),1694,ST_GeomFromText('POINT(-26.1367 28.2411)',4326)),
  ('CPT','Ciudad del Cabo',(SELECT id FROM ciudades WHERE nombre='Ciudad del Cabo'),46,ST_GeomFromText('POINT(-33.9649 18.6017)',4326)),
  ('CAI','El Cairo',(SELECT id FROM ciudades WHERE nombre='El Cairo'),116,ST_GeomFromText('POINT(30.1219 31.4056)',4326)),
  ('NBO','Jomo Kenyatta',(SELECT id FROM ciudades WHERE nombre='Nairobi'),1624,ST_GeomFromText('POINT(-1.3192 36.9278)',4326)),
  ('DXB','Dubái',(SELECT id FROM ciudades WHERE nombre='Dubái'),19,ST_GeomFromText('POINT(25.2532 55.3657)',4326)),
  ('DOH','Hamad',(SELECT id FROM ciudades WHERE nombre='Doha'),4,ST_GeomFromText('POINT(25.2731 51.6081)',4326)),
  ('DEL','Indira Gandhi',(SELECT id FROM ciudades WHERE nombre='Delhi'),237,ST_GeomFromText('POINT(28.5562 77.1000)',4326)),
  ('BOM','Chhatrapati Shivaji',(SELECT id FROM ciudades WHERE nombre='Bombay'),11,ST_GeomFromText('POINT(19.0896 72.8656)',4326)),
  ('SIN','Changi',(SELECT id FROM ciudades WHERE nombre='Singapur'),7,ST_GeomFromText('POINT(1.3644 103.9915)',4326)),
  ('BKK','Suvarnabhumi',(SELECT id FROM ciudades WHERE nombre='Bangkok'),2,ST_GeomFromText('POINT(13.6900 100.7501)',4326)),
  ('HKG','Hong Kong',(SELECT id FROM ciudades WHERE nombre='Hong Kong'),9,ST_GeomFromText('POINT(22.3080 113.9185)',4326)),
  ('PVG','Shanghái Pudong',(SELECT id FROM ciudades WHERE nombre='Shanghái'),4,ST_GeomFromText('POINT(31.1443 121.8083)',4326)),
  ('NRT','Narita',(SELECT id FROM ciudades WHERE nombre='Tokio'),43,ST_GeomFromText('POINT(35.7720 140.3929)',4326)),
  ('ICN','Incheon',(SELECT id FROM ciudades WHERE nombre='Seúl'),7,ST_GeomFromText('POINT(37.4602 126.4407)',4326)),
  ('SYD','Kingsford Smith',(SELECT id FROM ciudades WHERE nombre='Sídney'),6,ST_GeomFromText('POINT(-33.9399 151.1753)',4326)),
  ('AKL','Auckland',(SELECT id FROM ciudades WHERE nombre='Auckland'),7,ST_GeomFromText('POINT(-37.0082 174.7850)',4326)),
  ('NAN','Nadi',NULL,18,ST_GeomFromText('POINT(-17.7554 177.4434)',4326)),
  ('HNL','Daniel K. Inouye',(SELECT id FROM ciudades WHERE nombre='Honolulu'),4,ST_GeomFromText('POINT(21.3187 -157.9224)',4326)),
  ('KEF','Keflavík',(SELECT id FROM ciudades WHERE nombre='Reikiavik'),52,ST_GeomFromText('POINT(63.9850 -22.6056)',4326)),
  ('ANC','Ted Stevens',(SELECT id FROM ciudades WHERE nombre='Anchorage'),46,ST_GeomFromText('POINT(61.1743 -149.9962)',4326)),
  ('USH','Malvinas Argentinas',(SELECT id FROM ciudades WHERE nombre='Ushuaia'),31,ST_GeomFromText('POINT(-54.8433 -68.2958)',4326));
GIS_30_EOF

  cat > "${GIS_SEED_DIR}/31-seed-lineas-poligonos.sql" <<'GIS_31_EOF'
-- El entrypoint de MySQL aplica este archivo con el cliente `mysql`, que no negocia
-- el juego de caracteres: sin esta línea lee el fichero como latin1 y guarda cada
-- acento doblemente codificado (C383C2A9 en vez de C3A9 para «é»).
SET NAMES utf8mb4;
USE gis;

-- LINESTRING(lat lon, lat lon, …) — same axis order as the points.
-- Great-circle routes are approximated with a handful of waypoints; the aim
-- is a line with real curvature on a map, not navigational accuracy.
INSERT INTO rutas (nombre, tipo, distancia_km, trazado) VALUES
  ('Madrid → Nueva York','aérea',5760.00,ST_GeomFromText('LINESTRING(40.4168 -3.7038, 43.5 -20.0, 45.0 -35.0, 44.0 -50.0, 41.5 -65.0, 40.7128 -74.0060)',4326)),
  ('Madrid → Buenos Aires','aérea',10050.00,ST_GeomFromText('LINESTRING(40.4168 -3.7038, 28.0 -18.0, 10.0 -28.0, -8.0 -35.0, -25.0 -48.0, -34.6037 -58.3816)',4326)),
  ('Madrid → San José','aérea',8720.00,ST_GeomFromText('LINESTRING(40.4168 -3.7038, 38.0 -20.0, 32.0 -40.0, 24.0 -60.0, 15.0 -75.0, 9.9281 -84.0907)',4326)),
  ('Londres → Tokio','aérea',9560.00,ST_GeomFromText('LINESTRING(51.5074 -0.1278, 60.0 20.0, 66.0 50.0, 64.0 90.0, 55.0 120.0, 35.6762 139.6503)',4326)),
  ('París → Nueva York','aérea',5840.00,ST_GeomFromText('LINESTRING(48.8566 2.3522, 51.0 -10.0, 52.0 -25.0, 50.0 -40.0, 46.0 -55.0, 40.7128 -74.0060)',4326)),
  ('Los Ángeles → Sídney','aérea',12060.00,ST_GeomFromText('LINESTRING(34.0522 -118.2437, 21.0 -140.0, 5.0 -160.0, -10.0 -178.0, -25.0 165.0, -33.8688 151.2093)',4326)),
  ('Santiago → Auckland','aérea',9670.00,ST_GeomFromText('LINESTRING(-33.4489 -70.6693, -40.0 -100.0, -45.0 -130.0, -44.0 -160.0, -40.0 175.0, -36.8485 174.7633)',4326)),
  ('Ciudad del Cabo → Perth','aérea',8690.00,ST_GeomFromText('LINESTRING(-33.9249 18.4241, -38.0 40.0, -40.0 60.0, -38.0 85.0, -34.0 105.0, -31.9505 115.8605)',4326)),
  ('São Paulo → Lisboa','aérea',7940.00,ST_GeomFromText('LINESTRING(-23.5505 -46.6333, -10.0 -35.0, 5.0 -28.0, 20.0 -22.0, 32.0 -14.0, 38.7223 -9.1393)',4326)),
  ('Dubái → Singapur','aérea',5840.00,ST_GeomFromText('LINESTRING(25.2048 55.2708, 22.0 65.0, 17.0 78.0, 11.0 90.0, 5.0 98.0, 1.3521 103.8198)',4326)),
  ('Canal de Panamá','marítima',82.00,ST_GeomFromText('LINESTRING(9.3547 -79.9020, 9.2800 -79.9200, 9.1800 -79.8100, 9.1000 -79.7000, 8.9350 -79.5600)',4326)),
  ('Canal de Suez','marítima',193.00,ST_GeomFromText('LINESTRING(31.2650 32.3020, 30.9000 32.3400, 30.5000 32.3300, 30.1000 32.5600, 29.9300 32.5600)',4326)),
  ('Estrecho de Gibraltar','marítima',60.00,ST_GeomFromText('LINESTRING(36.1400 -5.3500, 35.9500 -5.6000, 35.8900 -5.9400)',4326)),
  ('Ruta del Cabo de Hornos','marítima',680.00,ST_GeomFromText('LINESTRING(-52.5000 -68.0000, -54.0000 -66.5000, -55.9800 -67.2700, -56.5000 -68.5000, -55.0000 -70.5000)',4326)),
  ('Costa Rica: San José → Limón','terrestre',160.00,ST_GeomFromText('LINESTRING(9.9281 -84.0907, 9.9700 -83.9000, 10.0800 -83.6500, 10.0200 -83.3500, 9.9907 -83.0359)',4326)),
  ('Costa Rica: Interamericana Norte','terrestre',215.00,ST_GeomFromText('LINESTRING(9.9281 -84.0907, 10.0200 -84.2200, 10.1500 -84.5000, 10.3500 -84.9000, 10.6346 -85.4377)',4326)),
  ('Ruta 66 (tramo)','terrestre',1400.00,ST_GeomFromText('LINESTRING(41.8781 -87.6298, 39.8000 -89.6500, 38.6270 -90.1994, 37.0800 -94.5100, 35.4676 -97.5164)',4326)),
  ('Carretera Panamericana (tramo andino)','terrestre',2100.00,ST_GeomFromText('LINESTRING(4.7110 -74.0721, 0.8000 -77.6000, -0.1807 -78.4678, -2.1894 -79.8891, -6.7700 -79.8400, -12.0464 -77.0428)',4326)),
  ('Transiberiano (tramo)','ferroviaria',3300.00,ST_GeomFromText('LINESTRING(55.7558 37.6173, 56.3300 44.0000, 56.8400 53.2100, 55.1600 61.4000, 55.0084 82.9357)',4326)),
  ('AVE Madrid → Barcelona','ferroviaria',621.00,ST_GeomFromText('LINESTRING(40.4168 -3.7038, 40.6800 -2.5000, 41.1500 -1.1000, 41.6560 -0.8770, 41.6200 1.0000, 41.3874 2.1686)',4326)),
  ('Shinkansen Tokio → Osaka','ferroviaria',515.00,ST_GeomFromText('LINESTRING(35.6762 139.6503, 35.1800 138.9000, 34.9700 138.3800, 35.1700 136.8800, 34.9800 135.7600, 34.6937 135.5023)',4326)),
  ('Eurostar Londres → París','ferroviaria',492.00,ST_GeomFromText('LINESTRING(51.5074 -0.1278, 51.1300 1.3100, 50.9600 1.8600, 50.6300 3.0600, 49.8900 2.3000, 48.8566 2.3522)',4326));

-- POLYGON((lat lon, …)) — bounding boxes, deliberately coarse. The first and
-- last vertex must coincide or MySQL rejects the ring.
INSERT INTO regiones (nombre, clase, area_km2, contorno) VALUES
  ('Península ibérica','país',583254.00,ST_GeomFromText('POLYGON((43.8 -9.5, 43.8 3.4, 36.0 3.4, 36.0 -9.5, 43.8 -9.5))',4326)),
  ('Francia continental','país',551695.00,ST_GeomFromText('POLYGON((51.1 -4.8, 51.1 8.2, 42.3 8.2, 42.3 -4.8, 51.1 -4.8))',4326)),
  ('Italia','país',301340.00,ST_GeomFromText('POLYGON((47.1 6.6, 47.1 18.5, 36.6 18.5, 36.6 6.6, 47.1 6.6))',4326)),
  ('Alemania','país',357022.00,ST_GeomFromText('POLYGON((55.1 5.9, 55.1 15.0, 47.3 15.0, 47.3 5.9, 55.1 5.9))',4326)),
  ('Reino Unido','país',242495.00,ST_GeomFromText('POLYGON((58.7 -8.2, 58.7 1.8, 49.9 1.8, 49.9 -8.2, 58.7 -8.2))',4326)),
  ('Costa Rica','país',51100.00,ST_GeomFromText('POLYGON((11.22 -85.95, 11.22 -82.55, 8.03 -82.55, 8.03 -85.95, 11.22 -85.95))',4326)),
  ('Panamá','país',75417.00,ST_GeomFromText('POLYGON((9.65 -83.05, 9.65 -77.17, 7.20 -77.17, 7.20 -83.05, 9.65 -83.05))',4326)),
  ('México','país',1964375.00,ST_GeomFromText('POLYGON((32.72 -118.40, 32.72 -86.71, 14.53 -86.71, 14.53 -118.40, 32.72 -118.40))',4326)),
  ('Argentina','país',2780400.00,ST_GeomFromText('POLYGON((-21.78 -73.58, -21.78 -53.64, -55.06 -53.64, -55.06 -73.58, -21.78 -73.58))',4326)),
  ('Chile','país',756102.00,ST_GeomFromText('POLYGON((-17.50 -75.64, -17.50 -66.42, -55.98 -66.42, -55.98 -75.64, -17.50 -75.64))',4326)),
  ('Brasil','país',8515767.00,ST_GeomFromText('POLYGON((5.27 -73.99, 5.27 -34.79, -33.75 -34.79, -33.75 -73.99, 5.27 -73.99))',4326)),
  ('Japón','país',377975.00,ST_GeomFromText('POLYGON((45.52 128.99, 45.52 145.82, 24.25 145.82, 24.25 128.99, 45.52 128.99))',4326)),
  ('Australia','país',7692024.00,ST_GeomFromText('POLYGON((-10.06 112.92, -10.06 153.64, -43.64 153.64, -43.64 112.92, -10.06 112.92))',4326)),
  ('Sudáfrica','país',1221037.00,ST_GeomFromText('POLYGON((-22.13 16.45, -22.13 32.89, -34.84 32.89, -34.84 16.45, -22.13 16.45))',4326)),
  ('Egipto','país',1002450.00,ST_GeomFromText('POLYGON((31.67 24.70, 31.67 36.90, 21.99 36.90, 21.99 24.70, 31.67 24.70))',4326)),
  ('Cataluña','provincia',32108.00,ST_GeomFromText('POLYGON((42.86 0.16, 42.86 3.33, 40.52 3.33, 40.52 0.16, 42.86 0.16))',4326)),
  ('Andalucía','provincia',87268.00,ST_GeomFromText('POLYGON((38.73 -7.52, 38.73 -1.63, 36.00 -1.63, 36.00 -7.52, 38.73 -7.52))',4326)),
  ('Baviera','provincia',70550.00,ST_GeomFromText('POLYGON((50.56 8.98, 50.56 13.84, 47.27 13.84, 47.27 8.98, 50.56 8.98))',4326)),
  ('Provincia de Guanacaste','provincia',10141.00,ST_GeomFromText('POLYGON((11.22 -86.00, 11.22 -84.65, 9.85 -84.65, 9.85 -86.00, 11.22 -86.00))',4326)),
  ('Parque Nacional de Yellowstone','parque',8991.00,ST_GeomFromText('POLYGON((45.11 -111.16, 45.11 -109.82, 44.13 -109.82, 44.13 -111.16, 45.11 -111.16))',4326)),
  ('Parque Nacional Manuel Antonio','parque',19.83,ST_GeomFromText('POLYGON((9.42 -84.17, 9.42 -84.12, 9.36 -84.12, 9.36 -84.17, 9.42 -84.17))',4326)),
  ('Parque Nacional Corcovado','parque',424.00,ST_GeomFromText('POLYGON((8.63 -83.75, 8.63 -83.34, 8.38 -83.34, 8.38 -83.75, 8.63 -83.75))',4326)),
  ('Parque Nacional del Teide','parque',189.90,ST_GeomFromText('POLYGON((28.34 -16.75, 28.34 -16.48, 28.17 -16.48, 28.17 -16.75, 28.34 -16.75))',4326)),
  ('Reserva de Doñana','reserva',543.00,ST_GeomFromText('POLYGON((37.19 -6.60, 37.19 -6.19, 36.79 -6.19, 36.79 -6.60, 37.19 -6.60))',4326)),
  ('Reserva del Serengueti','reserva',14763.00,ST_GeomFromText('POLYGON((-1.52 33.90, -1.52 35.31, -3.36 35.31, -3.36 33.90, -1.52 33.90))',4326)),
  ('Centro de Madrid','zona urbana',42.00,ST_GeomFromText('POLYGON((40.44 -3.73, 40.44 -3.66, 40.39 -3.66, 40.39 -3.73, 40.44 -3.73))',4326)),
  ('Manhattan','zona urbana',59.10,ST_GeomFromText('POLYGON((40.88 -74.02, 40.88 -73.91, 40.70 -73.91, 40.70 -74.02, 40.88 -74.02))',4326)),
  ('Gran San José','zona urbana',2044.00,ST_GeomFromText('POLYGON((10.05 -84.30, 10.05 -83.90, 9.80 -83.90, 9.80 -84.30, 10.05 -84.30))',4326));
GIS_31_EOF

  cat > "${GIS_SEED_DIR}/32-seed-degeneradas.sql" <<'GIS_32_EOF'
-- El entrypoint de MySQL aplica este archivo con el cliente `mysql`, que no negocia
-- el juego de caracteres: sin esta línea lee el fichero como latin1 y guarda cada
-- acento doblemente codificado (C383C2A9 en vez de C3A9 para «é»).
SET NAMES utf8mb4;
USE gis;

-- Every row here exists to break something specific. `motivo` says what, so a
-- failure points at its own cause instead of looking like bad data.
INSERT INTO zonas_complejas (nombre, motivo, forma) VALUES

  ('Anillo con agujero',
   'POLYGON de dos anillos: exterior e interior. Un parser que asuma un solo anillo dibuja el agujero como relleno',
   ST_GeomFromText('POLYGON((40.60 -3.90, 40.60 -3.50, 40.25 -3.50, 40.25 -3.90, 40.60 -3.90),(40.50 -3.80, 40.50 -3.60, 40.35 -3.60, 40.35 -3.80, 40.50 -3.80))',4326)),

  ('Anillo con dos agujeros',
   'POLYGON de tres anillos: dos interiores disjuntos',
   ST_GeomFromText('POLYGON((10.20 -84.40, 10.20 -83.80, 9.70 -83.80, 9.70 -84.40, 10.20 -84.40),(10.10 -84.30, 10.10 -84.15, 10.00 -84.15, 10.00 -84.30, 10.10 -84.30),(9.90 -84.05, 9.90 -83.90, 9.80 -83.90, 9.80 -84.05, 9.90 -84.05))',4326)),

  ('Archipiélago',
   'MULTIPOLYGON de tres islas disjuntas',
   ST_GeomFromText('MULTIPOLYGON(((28.60 -17.90, 28.60 -17.70, 28.40 -17.70, 28.40 -17.90, 28.60 -17.90)),((28.60 -16.30, 28.60 -16.10, 28.00 -16.10, 28.00 -16.30, 28.60 -16.30)),((29.30 -13.60, 29.30 -13.40, 28.85 -13.40, 28.85 -13.60, 29.30 -13.60)))',4326)),

  ('Boyas de medición',
   'MULTIPOINT: varios puntos en una sola celda',
   ST_GeomFromText('MULTIPOINT((9.50 -84.80),(9.20 -84.60),(8.90 -84.20),(8.60 -83.80),(8.30 -83.40))',4326)),

  ('Red fluvial',
   'MULTILINESTRING: afluentes que no se tocan entre sí',
   ST_GeomFromText('MULTILINESTRING((10.40 -84.90, 10.20 -84.60, 10.05 -84.30),(10.55 -84.50, 10.30 -84.35, 10.05 -84.30),(10.05 -84.30, 9.85 -84.10, 9.70 -83.85))',4326)),

  ('Estación completa',
   'GEOMETRYCOLLECTION: punto, línea y polígono en un solo valor. Rompe cualquier lector que asuma un tipo homogéneo',
   ST_GeomFromText('GEOMETRYCOLLECTION(POINT(9.9281 -84.0907),LINESTRING(9.9281 -84.0907, 9.9500 -84.0500, 9.9700 -84.0100),POLYGON((9.95 -84.12, 9.95 -84.06, 9.90 -84.06, 9.90 -84.12, 9.95 -84.12)))',4326)),

  ('Colección anidada',
   'GEOMETRYCOLLECTION que contiene otra GEOMETRYCOLLECTION: prueba el techo de profundidad del parser',
   ST_GeomFromText('GEOMETRYCOLLECTION(POINT(40.4168 -3.7038),GEOMETRYCOLLECTION(POINT(41.3874 2.1686),MULTIPOINT((39.4699 -0.3763),(37.3891 -5.9845))))',4326)),

  ('Cruce del antimeridiano',
   'LINESTRING que salta de +179 a -179: en el mapa debe cruzar el Pacífico, no dar la vuelta al mundo',
   ST_GeomFromText('LINESTRING(-16.50 178.00, -17.20 179.00, -17.80 179.90, -18.30 -179.20, -18.90 -178.00, -19.50 -177.00)',4326)),

  ('Isla sobre el antimeridiano',
   'POLYGON con vértices a ambos lados de ±180',
   ST_GeomFromText('POLYGON((-16.00 179.00, -16.00 -179.50, -17.20 -179.50, -17.20 179.00, -16.00 179.00))',4326)),

  ('Polo norte',
   'POINT en latitud 90: los límites del sistema de referencia',
   ST_GeomFromText('POINT(90 0)',4326)),

  ('Polo sur',
   'POINT en latitud -90',
   ST_GeomFromText('POINT(-90 0)',4326)),

  ('Punto nulo de la isla',
   'POINT en (0,0), donde acaban las coordenadas mal convertidas',
   ST_GeomFromText('POINT(0 0)',4326)),

  ('Meridiano de Greenwich',
   'LINESTRING de longitud exactamente 0',
   ST_GeomFromText('LINESTRING(51.4779 0, 45.0 0, 30.0 0, 15.0 0, 0 0)',4326)),

  ('Ecuador',
   'LINESTRING de latitud exactamente 0, de lado a lado del mundo',
   ST_GeomFromText('LINESTRING(0 -80, 0 -40, 0 0, 0 40, 0 80, 0 120, 0 178)',4326)),

  ('Sin geometría',
   'NULL: la fila existe y la columna espacial está vacía. Debe contarse y decirse, no desaparecer',
   NULL),

  ('Sin geometría (segunda)',
   'Segundo NULL, para que el recuento de filas sin geometría sea plural',
   NULL),

  ('Línea de dos puntos idénticos',
   'LINESTRING degenerada: longitud cero',
   ST_GeomFromText('LINESTRING(9.9281 -84.0907, 9.9281 -84.0907)',4326)),

  ('Triángulo mínimo',
   'POLYGON de tres vértices, el anillo más pequeño que admite el formato',
   ST_GeomFromText('POLYGON((9.90 -84.10, 9.91 -84.09, 9.89 -84.09, 9.90 -84.10))',4326)),

  ('Precisión larga',
   'POINT con quince decimales: comprueba que no se redondea al ir y volver',
   ST_GeomFromText('POINT(9.928069444444444 -84.090725000000000)',4326)),

  ('Coordenadas diminutas',
   'POINT muy cerca del origen, con exponente negativo grande',
   ST_GeomFromText('POINT(0.000001 0.000001)',4326));

-- A 512-vertex circle, built rather than typed: the case where one row alone
-- carries more vertices than a naive renderer expects.
DROP PROCEDURE IF EXISTS sp_circulo_denso;
DELIMITER $$
CREATE PROCEDURE sp_circulo_denso(IN p_lat DOUBLE, IN p_lon DOUBLE,
                                  IN p_radio DOUBLE, IN p_vertices INT,
                                  IN p_nombre VARCHAR(120))
  COMMENT 'Inserta en zonas_complejas un POLYGON circular de p_vertices lados'
BEGIN
  DECLARE i INT DEFAULT 0;
  DECLARE anillo TEXT DEFAULT '';
  DECLARE lat0 DOUBLE; DECLARE lon0 DOUBLE;
  SET lat0 = p_lat + p_radio;
  SET lon0 = p_lon;
  SET anillo = CONCAT(lat0, ' ', lon0);
  -- Hasta p_vertices - 1: en la última vuelta el coseno vale exactamente 1 y el
  -- vértice coincidiría con el inicial, dejando el punto de cierre repetido tres
  -- veces. Es un anillo válido y MySQL lo acepta, pero es un dato mal formado.
  WHILE i < p_vertices - 1 DO
    SET i = i + 1;
    SET anillo = CONCAT(anillo, ', ',
      p_lat + p_radio * COS(2 * PI() * i / p_vertices), ' ',
      p_lon + p_radio * SIN(2 * PI() * i / p_vertices) / COS(RADIANS(p_lat)));
  END WHILE;
  SET anillo = CONCAT(anillo, ', ', lat0, ' ', lon0);
  INSERT INTO zonas_complejas (nombre, motivo, forma)
  VALUES (p_nombre,
          CONCAT('POLYGON de ', p_vertices, ' vértices generado — una sola fila con muchos puntos'),
          ST_GeomFromText(CONCAT('POLYGON((', anillo, '))'), 4326));
END$$
DELIMITER ;

CALL sp_circulo_denso(9.9281, -84.0907, 0.30, 512, 'Círculo denso sobre San José');
CALL sp_circulo_denso(40.4168, -3.7038, 0.50, 128, 'Círculo sobre Madrid');
GIS_32_EOF

  cat > "${GIS_SEED_DIR}/40-cartesian.sql" <<'GIS_40_EOF'
-- El entrypoint de MySQL aplica este archivo con el cliente `mysql`, que no negocia
-- el juego de caracteres: sin esta línea lee el fichero como latin1 y guarda cada
-- acento doblemente codificado (C383C2A9 en vez de C3A9 para «é»).
SET NAMES utf8mb4;
-- The same cities, SRID 0, X=longitude — the opposite axis convention.
-- Reading `gis.ciudades` and `gis_cartesian.ciudades` side by side is what
-- makes an axis-order bug obvious: get it wrong and one of the two lands in
-- the sea. ST_X/ST_Y read the stored geometry, so the swap is done by the
-- server and cannot drift from the source table.
USE gis_cartesian;

CREATE TABLE IF NOT EXISTS ciudades (
  id           INT AUTO_INCREMENT PRIMARY KEY,
  nombre       VARCHAR(80)  NOT NULL,
  pais         CHAR(2)      NOT NULL,
  poblacion    INT UNSIGNED NULL,
  ubicacion    POINT        NOT NULL SRID 0 COMMENT 'X es longitud, Y es latitud',
  SPATIAL INDEX idx_ubicacion (ubicacion)
) ENGINE=InnoDB;

INSERT INTO ciudades (nombre, pais, poblacion, ubicacion)
  SELECT nombre, pais, poblacion,
         ST_GeomFromText(CONCAT('POINT(', ST_Y(ubicacion), ' ', ST_X(ubicacion), ')'), 0)
    FROM gis.ciudades;

-- Routes too, so there is a line to compare and not only points.
CREATE TABLE IF NOT EXISTS rutas (
  id           INT AUTO_INCREMENT PRIMARY KEY,
  nombre       VARCHAR(120) NOT NULL,
  trazado      LINESTRING   NOT NULL SRID 0 COMMENT 'X es longitud',
  SPATIAL INDEX idx_trazado (trazado)
) ENGINE=InnoDB;

-- ST_SwapXY keeps the source SRID, so ST_SRID re-stamps it to 0 — otherwise
-- the insert fails against the SRID 0 column declared above.
INSERT INTO rutas (nombre, trazado)
  SELECT nombre, ST_SRID(ST_SwapXY(trazado), 0) FROM gis.rutas;
GIS_40_EOF

  cat > "${GIS_SEED_DIR}/50-bulk.sql" <<'GIS_50_EOF'
-- El entrypoint de MySQL aplica este archivo con el cliente `mysql`, que no negocia
-- el juego de caracteres: sin esta línea lee el fichero como latin1 y guarda cada
-- acento doblemente codificado (C383C2A9 en vez de C3A9 para «é»).
SET NAMES utf8mb4;
USE gis;

-- Volume, generated rather than typed. `sensores` is sized past the map's
-- 10 000-geometry ceiling on purpose: the truncation notice has to be
-- reachable with a plain `SELECT * FROM sensores`, without anyone having to
-- craft a query for it.
DROP PROCEDURE IF EXISTS sp_generar_sensores;
DELIMITER $$
CREATE PROCEDURE sp_generar_sensores(IN p_total INT)
  COMMENT 'Siembra p_total sensores repartidos en cinco cuencas del mundo'
BEGIN
  DECLARE i INT DEFAULT 0;
  DECLARE c_lat DOUBLE; DECLARE c_lon DOUBLE; DECLARE cuenca SMALLINT;
  SET autocommit = 0;
  WHILE i < p_total DO
    SET cuenca = i % 5;
    CASE cuenca
      WHEN 0 THEN SET c_lat = 9.9281,   c_lon = -84.0907;  -- Valle Central
      WHEN 1 THEN SET c_lat = 40.4168,  c_lon = -3.7038;   -- Meseta
      WHEN 2 THEN SET c_lat = -33.4489, c_lon = -70.6693;  -- Cono Sur
      WHEN 3 THEN SET c_lat = 35.6762,  c_lon = 139.6503;  -- Kanto
      ELSE        SET c_lat = -26.2041, c_lon = 28.0473;   -- Highveld
    END CASE;
    INSERT INTO sensores (codigo, lote, temperatura, activo, ubicacion, medido_en)
    VALUES (CONCAT('SN-', LPAD(i + 1, 6, '0')),
            cuenca,
            ROUND(5 + RAND() * 35, 2),
            IF(RAND() < 0.92, 1, 0),
            ST_GeomFromText(CONCAT('POINT(',
              c_lat + (RAND() - 0.5) * 3.0, ' ',
              c_lon + (RAND() - 0.5) * 3.0, ')'), 4326),
            NOW() - INTERVAL FLOOR(RAND() * 90) DAY);
    SET i = i + 1;
    IF i % 1000 = 0 THEN COMMIT; END IF;
  END WHILE;
  COMMIT;
  SET autocommit = 1;
END$$
DELIMITER ;

-- A grid of square parcels: many small polygons, which is a different load on
-- a renderer than many points.
DROP PROCEDURE IF EXISTS sp_generar_parcelas;
DELIMITER $$
CREATE PROCEDURE sp_generar_parcelas(IN p_filas INT, IN p_columnas INT)
  COMMENT 'Rejilla de parcelas cuadradas al norte de San José'
BEGIN
  DECLARE f INT DEFAULT 0; DECLARE c INT DEFAULT 0;
  DECLARE lat0 DOUBLE DEFAULT 10.10; DECLARE lon0 DOUBLE DEFAULT -84.35;
  DECLARE paso DOUBLE DEFAULT 0.004;
  DECLARE la DOUBLE; DECLARE lo DOUBLE;
  SET autocommit = 0;
  WHILE f < p_filas DO
    SET c = 0;
    WHILE c < p_columnas DO
      SET la = lat0 - f * paso;
      SET lo = lon0 + c * paso;
      INSERT INTO parcelas (referencia, cultivo, contorno)
      VALUES (CONCAT('P-', LPAD(f, 3, '0'), '-', LPAD(c, 3, '0')),
              ELT(1 + FLOOR(RAND() * 5), 'café', 'caña', 'piña', 'banano', 'pasto'),
              ST_GeomFromText(CONCAT('POLYGON((',
                la, ' ', lo, ', ',
                la, ' ', lo + paso * 0.9, ', ',
                la - paso * 0.9, ' ', lo + paso * 0.9, ', ',
                la - paso * 0.9, ' ', lo, ', ',
                la, ' ', lo, '))'), 4326));
      SET c = c + 1;
    END WHILE;
    SET f = f + 1;
    COMMIT;
  END WHILE;
  SET autocommit = 1;
END$$
DELIMITER ;

CALL sp_generar_sensores(25000);
CALL sp_generar_parcelas(45, 45);
GIS_50_EOF

  # ── scripts/mysql8-gis-seed/ ──────────────────────────────────────────────
  # El seed del nodo plegado: el compartido tal cual, y detrás el espacial con
  # prefijos 80-86 para que el glob del entrypoint lo aplique después. Se arma
  # copiando en vez de montando siete archivos porque
  # /docker-entrypoint-initdb.d ya es un montaje `:ro` y dentro de un montaje de
  # sólo lectura no se puede crear otro punto de montaje.
  #
  # Se rehace en cada corrida: así un archivo del seed que se edite en el
  # `.command` llega aquí igual que a los demás nodos, sin copias viejas.
  rm -f "${MYSQL8_GIS_SEED_DIR}"/*.sql 2>/dev/null || true
  cp "${SEED_DIR}"/*.sql "${MYSQL8_GIS_SEED_DIR}/"
  local gis_n=80 gis_f
  for gis_f in 10-schemas 20-tables 30-seed-ciudades 31-seed-lineas-poligonos \
               32-seed-degeneradas 40-cartesian 50-bulk; do
    cp "${GIS_SEED_DIR}/${gis_f}.sql" "${MYSQL8_GIS_SEED_DIR}/${gis_n}-gis-${gis_f#*-}.sql"
    gis_n=$(( gis_n + 1 ))
  done

  # ── Seed de Oracle (plan 63) ──────────────────────────────────────────────
  # `${ORACLE_SEED_DIR}` se copia en la imagen sobre `/opt/oracle/scripts/`
  # (el `DEST` del nodo `oracle`), así que lleva dos carpetas:
  #
  #   · `setup/` — el seed: los esquemas (tarea 2 del plan 63) y, el último,
  #     `99-marca.sh`. La imagen ejecuta `setup/` sólo al CREAR la base, y Free
  #     la trae creada: por sí sola no lo correría nunca (medido el 2026-10-15).
  #   · `startup/` — `00-caliope-seed.sh`, que la imagen carga en CADA arranque
  #     tras «DATABASE IS READY TO USE!» y que aplica `setup/` si falta la marca.
  #
  # Los `.sql` corren con `sqlplus -s "/ as sysdba"` y los `.sh` con `.` —DENTRO
  # del script de arranque de la imagen, así que un `exit` en uno lo mata—.
  #
  # Se rehace en cada corrida, como las demás semillas: un archivo editado aquí
  # llega a la imagen sin copias viejas al lado.
  rm -rf "${ORACLE_SEED_DIR}/setup" "${ORACLE_SEED_DIR}/startup"
  mkdir -p "${ORACLE_SEED_DIR}/setup" "${ORACLE_SEED_DIR}/startup"

  cat > "${ORACLE_SEED_DIR}/startup/00-caliope-seed.sh" <<'ORACLE_SEED_STARTUP_EOF'
# 00-caliope-seed.sh — aplica el seed del nodo `oracle` la primera vez.
#
# La imagen lo carga con `.` en cada arranque (nada de `exit`). La base de Free
# viene creada dentro de la imagen, así que el `setup/` que la imagen sólo corre
# al crear una base no corre nunca: este script lo aplica con el mismo
# `runUserScripts.sh` de la imagen, una vez, mientras falte la marca que deja
# `setup/99-marca.sh` en el volumen.
#
# `NLS_LANG` va en UTF-8 porque los `.sql` del seed lo están: sin él `sqlplus`
# lee el texto como US7ASCII y cada letra con tilde llega a la base como dos
# U+FFFD («Ana Garc��a»; medido en la tarea 14 del plan 63 con `DUMP`).
if [ ! -f /opt/oracle/oradata/caliope-seed-ok ]; then
  echo "[caliope] seed de Oracle: falta la marca, se aplica /opt/oracle/scripts/setup"
  NLS_LANG=AMERICAN_AMERICA.AL32UTF8 /opt/oracle/runUserScripts.sh /opt/oracle/scripts/setup
else
  echo "[caliope] seed de Oracle: ya aplicado ($(cat /opt/oracle/oradata/caliope-seed-ok))"
fi
ORACLE_SEED_STARTUP_EOF

  # ── Los archivos del seed (tarea 2 del plan 63) ───────────────────────────
  # Las mismas diez tablas, los mismos ids y las mismas filas que el seed de SQL
  # Server (`51-semilla-sqlserver.sh`), traducidas a Oracle, más lo que sólo
  # tiene Oracle en `60-solo-oracle.sql`. Cada `.sql` corre en su propio
  # `sqlplus -s "/ as sysdba"` en la raíz del CDB, así que TODOS empiezan con
  # `WHENEVER SQLERROR EXIT FAILURE` y `ALTER SESSION SET CONTAINER = FREEPDB1`:
  # sin lo primero un error deja seguir al resto del archivo; sin lo segundo los
  # usuarios se crearían en la raíz, donde exigen el prefijo `C##`.

  cat > "${ORACLE_SEED_DIR}/setup/10-usuarios.sql" <<'ORA_SEED_10_USUARIOS_EOF'
-- 10-usuarios.sql — Oracle
--
-- En Oracle un esquema ES un usuario: `DEMO` y `RRHH` son los dos esquemas que
-- en SQL Server son `dbo` y `rrhh` de la base `demo`. `CALIOPE` es una cuenta
-- que sólo puede leer (`SELECT ANY TABLE`), para las pruebas de «sin privilegio».
--
-- Empieza borrando los tres si existen: si una corrida anterior no llegó a
-- escribir la marca, el seed se repite en el siguiente arranque sobre lo que
-- quedó (`startup/00-caliope-seed.sh`), y tiene que poder empezar de cero.

WHENEVER SQLERROR EXIT FAILURE ROLLBACK
SET DEFINE OFF
ALTER SESSION SET CONTAINER = FREEPDB1;

BEGIN
  FOR u IN (SELECT username FROM dba_users WHERE username IN ('DEMO', 'RRHH', 'CALIOPE')) LOOP
    EXECUTE IMMEDIATE 'DROP USER ' || u.username || ' CASCADE';
  END LOOP;
  -- Los metadatos espaciales de `DEMO.DIRECCIONES` viven en MDSYS, no en DEMO.
  DELETE FROM mdsys.sdo_geom_metadata_table WHERE sdo_owner = 'DEMO';
  COMMIT;
END;
/

CREATE USER demo IDENTIFIED BY caliopepass
  DEFAULT TABLESPACE users QUOTA UNLIMITED ON users;
GRANT CREATE SESSION, CREATE TABLE, CREATE VIEW, CREATE SEQUENCE, CREATE PROCEDURE,
      CREATE TRIGGER, CREATE TYPE, CREATE MATERIALIZED VIEW TO demo;
-- `DBMS_CRYPTO.HASH` compara el BLOB de `documentos_grandes` con lo que lee el
-- driver (plan 63, tarea 18).
GRANT EXECUTE ON sys.dbms_crypto TO demo;

CREATE USER rrhh IDENTIFIED BY caliopepass
  DEFAULT TABLESPACE users QUOTA UNLIMITED ON users;
GRANT CREATE SESSION, CREATE TABLE, CREATE VIEW, CREATE SEQUENCE, CREATE PROCEDURE,
      CREATE TRIGGER TO rrhh;

CREATE USER caliope IDENTIFIED BY caliopepass;
GRANT CREATE SESSION, SELECT ANY TABLE TO caliope;
ORA_SEED_10_USUARIOS_EOF

  cat > "${ORACLE_SEED_DIR}/setup/20-tablas-demo.sql" <<'ORA_SEED_20_TABLAS_DEMO_EOF'
-- 20-tablas-demo.sql — Oracle
--
-- Las siete tablas de `dbo` de SQL Server, en el esquema `DEMO`, con los mismos
-- nombres, columnas y restricciones. Lo que tiene otra forma aquí:
--
--   INT IDENTITY        → `NUMBER(10) GENERATED BY DEFAULT AS IDENTITY`: admite
--                         el id explícito del seed, y 70-datos-demo.sql recoloca
--                         el contador después (`START WITH LIMIT VALUE`).
--   NVARCHAR(n)         → `VARCHAR2(n CHAR)`: la base es AL32UTF8 y la longitud
--                         se cuenta en caracteres, no en bytes.
--   NVARCHAR(MAX)       → `CLOB`; con `ISJSON` → `CLOB` con `CHECK (… IS JSON)`.
--                         El tipo `JSON` nativo va en 60-solo-oracle.sql.
--   DECIMAL(p,s)        → `NUMBER(p,s)`.  BIGINT → `NUMBER(19)`.
--   BIT                 → `BOOLEAN` (SQL, desde la 23ai).
--   DATETIMEOFFSET      → `TIMESTAMP`: la sesión y la base están en UTC, y el
--                         tipo con zona va en 60-solo-oracle.sql.
--   VARBINARY(n)        → `RAW(n)`.
--   geography           → `SDO_GEOMETRY`, SRID 4326, con índice espacial. Es un
--                         TIPO DE OBJETO de MDSYS: Calíope no lo decodifica y la
--                         celda dice «no decodificado» (decisión 6 del plan 63).
--   ON DELETE SET NULL sobre la misma tabla: Oracle SÍ lo admite (SQL Server no,
--                         error 1785), así que `categorias.padre_id` vuelve a la
--                         forma de Postgres.
--   MS_Description      → `COMMENT ON`.
--
-- Las restricciones llevan nombre, como en SQL Server: sin él Oracle inventa
-- `SYS_C00…`, distinto en cada siembra.

WHENEVER SQLERROR EXIT FAILURE ROLLBACK
SET DEFINE OFF
ALTER SESSION SET CONTAINER = FREEPDB1;
ALTER SESSION SET CURRENT_SCHEMA = DEMO;

CREATE TABLE productos (
  id         NUMBER(10) GENERATED BY DEFAULT AS IDENTITY CONSTRAINT pk_productos PRIMARY KEY,
  nombre     VARCHAR2(100 CHAR) NOT NULL,
  categoria  VARCHAR2(50 CHAR) DEFAULT 'general' NOT NULL,
  precio     NUMBER(10,2) NOT NULL,
  stock      NUMBER(10) DEFAULT 0 NOT NULL,
  activo     BOOLEAN DEFAULT TRUE NOT NULL,
  creado_en  TIMESTAMP(0) DEFAULT SYSTIMESTAMP
);
CREATE INDEX idx_categoria ON productos (categoria);

CREATE TABLE clientes (
  id         NUMBER(10) GENERATED BY DEFAULT AS IDENTITY CONSTRAINT pk_clientes PRIMARY KEY,
  nombre     VARCHAR2(100 CHAR) NOT NULL,
  email      VARCHAR2(150 CHAR) NOT NULL CONSTRAINT uk_clientes_email UNIQUE,
  ciudad     VARCHAR2(80 CHAR),
  creado_en  TIMESTAMP(0) DEFAULT SYSTIMESTAMP
);

CREATE TABLE pedidos (
  id           NUMBER(10) GENERATED BY DEFAULT AS IDENTITY CONSTRAINT pk_pedidos PRIMARY KEY,
  cliente_id   NUMBER(10) NOT NULL CONSTRAINT fk_pedidos_cliente  REFERENCES clientes (id),
  producto_id  NUMBER(10) NOT NULL CONSTRAINT fk_pedidos_producto REFERENCES productos (id),
  cantidad     NUMBER(10) NOT NULL,
  total        NUMBER(10,2) NOT NULL,
  estado       VARCHAR2(10 CHAR) DEFAULT 'pendiente'
               CONSTRAINT ck_pedidos_estado CHECK (estado IN ('pendiente', 'enviado', 'entregado', 'cancelado')),
  creado_en    TIMESTAMP(0) DEFAULT SYSTIMESTAMP
);
CREATE INDEX idx_estado  ON pedidos (estado);
CREATE INDEX idx_cliente ON pedidos (cliente_id);

CREATE TABLE categorias (
  id           NUMBER(10) GENERATED BY DEFAULT AS IDENTITY CONSTRAINT pk_categorias PRIMARY KEY,
  slug         VARCHAR2(50 CHAR) NOT NULL CONSTRAINT uk_categorias_slug UNIQUE,
  nombre       VARCHAR2(100 CHAR) NOT NULL,
  descripcion  CLOB,
  padre_id     NUMBER(10) CONSTRAINT fk_categorias_padre REFERENCES categorias (id) ON DELETE SET NULL,
  activa       BOOLEAN DEFAULT TRUE NOT NULL,
  creada_en    TIMESTAMP(0) DEFAULT SYSTIMESTAMP
);
CREATE INDEX idx_activa ON categorias (activa);

CREATE TABLE direcciones (
  id            NUMBER(10) GENERATED BY DEFAULT AS IDENTITY CONSTRAINT pk_direcciones PRIMARY KEY,
  cliente_id    NUMBER(10) NOT NULL CONSTRAINT fk_direcciones_cliente REFERENCES clientes (id) ON DELETE CASCADE,
  etiqueta      VARCHAR2(30 CHAR) DEFAULT 'principal' NOT NULL,
  linea1        VARCHAR2(200 CHAR) NOT NULL,
  linea2        VARCHAR2(200 CHAR),
  ciudad        VARCHAR2(80 CHAR) NOT NULL,
  pais          CHAR(2 CHAR) NOT NULL,
  metadatos     CLOB CONSTRAINT ck_direcciones_metadatos CHECK (metadatos IS JSON),
  ubicacion     SDO_GEOMETRY NOT NULL,
  actualizada   TIMESTAMP(0) DEFAULT SYSTIMESTAMP
);
-- Un índice espacial exige su fila en los metadatos de MDSYS (extensión y
-- tolerancia por eje); el seed corre como SYS y la escribe en la tabla de
-- debajo de `USER_SDO_GEOM_METADATA`, con el dueño a mano.
INSERT INTO mdsys.sdo_geom_metadata_table (sdo_owner, sdo_table_name, sdo_column_name, sdo_diminfo, sdo_srid)
VALUES ('DEMO', 'DIRECCIONES', 'UBICACION',
        MDSYS.SDO_DIM_ARRAY(MDSYS.SDO_DIM_ELEMENT('Longitude', -180, 180, 0.05),
                            MDSYS.SDO_DIM_ELEMENT('Latitude',   -90,  90, 0.05)),
        4326);
COMMIT;
CREATE INDEX idx_ubicacion ON direcciones (ubicacion) INDEXTYPE IS MDSYS.SPATIAL_INDEX_V2;
CREATE INDEX idx_pais ON direcciones (pais);

CREATE TABLE pedido_lineas (
  id            NUMBER(10) GENERATED BY DEFAULT AS IDENTITY CONSTRAINT pk_pedido_lineas PRIMARY KEY,
  pedido_id     NUMBER(10) NOT NULL CONSTRAINT fk_lineas_pedido   REFERENCES pedidos (id) ON DELETE CASCADE,
  producto_id   NUMBER(10) NOT NULL CONSTRAINT fk_lineas_producto REFERENCES productos (id),
  cantidad      NUMBER(10) DEFAULT 1 NOT NULL,
  precio_unit   NUMBER(10,2) NOT NULL,
  descuento_pct NUMBER(5,2) DEFAULT 0.00 NOT NULL
);
CREATE INDEX idx_pedido ON pedido_lineas (pedido_id);

CREATE TABLE log_auditoria (
  id            NUMBER(19) GENERATED BY DEFAULT AS IDENTITY CONSTRAINT pk_log_auditoria PRIMARY KEY,
  ocurrido_en   TIMESTAMP(3) DEFAULT SYSTIMESTAMP,
  actor         VARCHAR2(80 CHAR),
  entidad       VARCHAR2(80 CHAR) NOT NULL,
  entidad_id    NUMBER(19),
  accion        VARCHAR2(6 CHAR) NOT NULL
                CONSTRAINT ck_log_accion CHECK (accion IN ('INSERT', 'UPDATE', 'DELETE')),
  payload_json  CLOB CONSTRAINT ck_log_payload CHECK (payload_json IS JSON),
  payload_hash  RAW(32)
);
CREATE INDEX idx_entidad  ON log_auditoria (entidad, entidad_id);
CREATE INDEX idx_ocurrido ON log_auditoria (ocurrido_en);

COMMENT ON TABLE log_auditoria IS 'La llenan los disparadores de pedidos y clientes (50-disparadores.sql)';
COMMENT ON COLUMN direcciones.ubicacion IS 'SDO_GEOMETRY, SRID 4326, (longitud, latitud)';
ORA_SEED_20_TABLAS_DEMO_EOF

  cat > "${ORACLE_SEED_DIR}/setup/21-tablas-rrhh.sql" <<'ORA_SEED_21_TABLAS_RRHH_EOF'
-- 21-tablas-rrhh.sql — Oracle
--
-- Las tres tablas de `rrhh` de SQL Server, en el esquema (usuario) `RRHH`. Lo
-- que cambia:
--
--   columna calculada PERSISTED → columna VIRTUAL: Oracle no la guarda en disco,
--       la calcula al leer, y aun así se puede indexar.
--   MS_Description              → `COMMENT ON COLUMN`.
--   ON DELETE SET NULL en la autorreferencia de `jefe_id`: Oracle lo admite, y
--       vuelve a la forma de Postgres.
--
-- **La foránea que cruza de esquema.** `rrhh.empleados.cliente_asignado_id →
-- demo.clientes(id)`, como en SQL Server. Aquí cruzar de esquema es cruzar de
-- usuario, y el dueño de la tabla hija necesita `REFERENCES` sobre la madre:
-- sin el `GRANT`, ORA-01031 aunque quien crea la tabla sea SYS.

WHENEVER SQLERROR EXIT FAILURE ROLLBACK
SET DEFINE OFF
ALTER SESSION SET CONTAINER = FREEPDB1;
ALTER SESSION SET CURRENT_SCHEMA = RRHH;

GRANT REFERENCES ON demo.clientes TO rrhh;

CREATE TABLE departamentos (
  id           NUMBER(10) GENERATED BY DEFAULT AS IDENTITY CONSTRAINT pk_departamentos PRIMARY KEY,
  codigo       CHAR(4 CHAR) NOT NULL CONSTRAINT uk_departamentos_codigo UNIQUE,
  nombre       VARCHAR2(120 CHAR) NOT NULL,
  presupuesto  NUMBER(14,2) DEFAULT 0.00 NOT NULL,
  creado_en    TIMESTAMP(0) DEFAULT SYSTIMESTAMP
);

CREATE TABLE empleados (
  id                   NUMBER(10) GENERATED BY DEFAULT AS IDENTITY CONSTRAINT pk_empleados PRIMARY KEY,
  nombre               VARCHAR2(120 CHAR) NOT NULL,
  email                VARCHAR2(150 CHAR) NOT NULL CONSTRAINT uk_empleados_email UNIQUE,
  departamento_id      NUMBER(10) NOT NULL CONSTRAINT fk_empleados_departamento REFERENCES departamentos (id),
  jefe_id              NUMBER(10) CONSTRAINT fk_empleados_jefe    REFERENCES empleados (id) ON DELETE SET NULL,
  cliente_asignado_id  NUMBER(10) CONSTRAINT fk_empleados_cliente REFERENCES demo.clientes (id),
  fecha_ingreso        DATE NOT NULL,
  salario_base         NUMBER(12,2) NOT NULL,
  estado               VARCHAR2(12 CHAR) DEFAULT 'activo'
                       CONSTRAINT ck_empleados_estado CHECK (estado IN ('activo', 'baja_medica', 'vacaciones', 'despedido')),
  permisos             VARCHAR2(200 CHAR) DEFAULT 'lectura' NOT NULL,
  notas                CLOB
);
CREATE INDEX idx_departamento ON empleados (departamento_id);
CREATE INDEX idx_estado       ON empleados (estado);

CREATE TABLE nominas (
  id             NUMBER(19) GENERATED BY DEFAULT AS IDENTITY CONSTRAINT pk_nominas PRIMARY KEY,
  empleado_id    NUMBER(10) NOT NULL CONSTRAINT fk_nominas_empleado REFERENCES empleados (id) ON DELETE CASCADE,
  periodo        CHAR(7 CHAR) NOT NULL,
  bruto          NUMBER(12,2) NOT NULL,
  impuestos      NUMBER(12,2) NOT NULL,
  neto           NUMBER(12,2) GENERATED ALWAYS AS (bruto - impuestos) VIRTUAL,
  estado         VARCHAR2(10 CHAR) DEFAULT 'borrador'
                 CONSTRAINT ck_nominas_estado CHECK (estado IN ('borrador', 'emitida', 'pagada', 'anulada')),
  documento_pdf  BLOB,
  emitida_en     TIMESTAMP(0),
  CONSTRAINT uk_empleado_periodo UNIQUE (empleado_id, periodo)
);
CREATE INDEX idx_periodo ON nominas (periodo);

COMMENT ON COLUMN nominas.periodo IS 'YYYY-MM';
COMMENT ON COLUMN empleados.permisos IS 'Varios permisos por empleado, separados por comas: Oracle no tiene arrays en una columna escalar';
COMMENT ON COLUMN empleados.cliente_asignado_id IS 'Comercial que lleva el cliente; la foránea que cruza de RRHH a DEMO';
ORA_SEED_21_TABLAS_RRHH_EOF

  cat > "${ORACLE_SEED_DIR}/setup/30-vistas.sql" <<'ORA_SEED_30_VISTAS_EOF'
-- 30-vistas.sql — Oracle
--
-- Las mismas cuatro vistas. Lo que cambia:
--
--   WITH SCHEMABINDING     → `WITH READ ONLY`, que es lo que este motor tiene y
--       SQL Server no: la vista no admite DML aunque su forma lo permitiera.
--   WITH CHECK OPTION      → igual, y aquí es una restricción con nombre propio
--       (`ALL_CONSTRAINTS`, tipo `V`).
--   `DATEADD(DAY, -30, …)` → `LOCALTIMESTAMP - INTERVAL '30' DAY`.

WHENEVER SQLERROR EXIT FAILURE ROLLBACK
SET DEFINE OFF
ALTER SESSION SET CONTAINER = FREEPDB1;
ALTER SESSION SET CURRENT_SCHEMA = DEMO;

-- 1) Ranking de productos por pedidos. GROUP BY → no actualizable, y lo dice.
CREATE OR REPLACE VIEW v_top_productos AS
SELECT
  p.id,
  p.nombre,
  p.categoria,
  COUNT(pe.id)                AS total_pedidos,
  COALESCE(SUM(pe.total), 0)  AS ingresos
FROM productos p
LEFT JOIN pedidos pe ON pe.producto_id = p.id
GROUP BY p.id, p.nombre, p.categoria
WITH READ ONLY;

-- 2) Pedidos recientes (últimos 30 días) — actualizable, con CHECK OPTION.
CREATE OR REPLACE VIEW v_pedidos_recientes AS
SELECT
  id, cliente_id, producto_id, cantidad, total, estado, creado_en
FROM pedidos
WHERE creado_en >= LOCALTIMESTAMP - INTERVAL '30' DAY
WITH CHECK OPTION CONSTRAINT ck_v_pedidos_recientes;

-- 3) Clientes activos.
CREATE OR REPLACE VIEW v_clientes_activos AS
SELECT
  c.id,
  c.nombre,
  c.email,
  c.ciudad,
  COUNT(pe.id) AS pedidos_totales
FROM clientes c
LEFT JOIN pedidos pe ON pe.cliente_id = c.id
GROUP BY c.id, c.nombre, c.email, c.ciudad;

-- 4) Resumen de ventas vía UNION — no actualizable por definición.
CREATE OR REPLACE VIEW v_resumen_ventas AS
SELECT CAST('pendiente' AS VARCHAR2(10 CHAR)) AS estado, COUNT(*) AS pedidos, COALESCE(SUM(total), 0) AS monto
  FROM pedidos WHERE estado = 'pendiente'
UNION ALL
SELECT 'enviado',   COUNT(*), COALESCE(SUM(total), 0)
  FROM pedidos WHERE estado = 'enviado'
UNION ALL
SELECT 'entregado', COUNT(*), COALESCE(SUM(total), 0)
  FROM pedidos WHERE estado = 'entregado'
UNION ALL
SELECT 'cancelado', COUNT(*), COALESCE(SUM(total), 0)
  FROM pedidos WHERE estado = 'cancelado';
ORA_SEED_30_VISTAS_EOF

  cat > "${ORACLE_SEED_DIR}/setup/40-rutinas.sql" <<'ORA_SEED_40_RUTINAS_EOF'
-- 40-rutinas.sql — Oracle
--
-- Los mismos seis objetos con los mismos nombres, en PL/SQL, y uno que sólo
-- tiene Oracle: el PAQUETE `pkg_pedidos`, con especificación y cuerpo, que son
-- dos objetos (`PACKAGE` y `PACKAGE BODY`) y dos textos en `ALL_SOURCE`.
--
--   · Cada bloque PL/SQL termina con `/` en línea sola: es lo que separa los
--     bloques para el cliente (el `GO` de SQL Server), no SQL.
--   · Parámetros de salida: `OUT` e `IN OUT`.
--   · `WITH EXECUTE AS OWNER` → `AUTHID DEFINER`, que es el valor por omisión.
--   · `WITH SCHEMABINDING` en las funciones puras → `DETERMINISTIC`.
--   · `SCOPE_IDENTITY()`   → `RETURNING id INTO`, dentro del PL/SQL.
--   · `sp_reporte_ventas` devuelve filas como el `SELECT` suelto de un
--     procedimiento de SQL Server: con `DBMS_SQL.RETURN_RESULT`, el «resultado
--     implícito» de Oracle (desde la 12c). Que el editor lo enseñe depende de
--     que el driver lo pida (`TTC3`, bit de resultados implícitos).
--   · `MS_Description` no tiene equivalente para rutinas (`COMMENT ON` es de
--     tablas, columnas y vistas materializadas): la descripción va como
--     comentario dentro del código, que `ALL_SOURCE` conserva.

WHENEVER SQLERROR EXIT FAILURE ROLLBACK
SET DEFINE OFF
SET SQLBLANKLINES ON
ALTER SESSION SET CONTAINER = FREEPDB1;
ALTER SESSION SET CURRENT_SCHEMA = DEMO;

CREATE OR REPLACE PROCEDURE sp_registrar_pedido (
  in_cliente_id  IN  NUMBER,
  in_producto_id IN  NUMBER,
  in_cantidad    IN  NUMBER,
  out_pedido_id  OUT NUMBER
) AUTHID DEFINER AS
  -- Registra un pedido calculando el total desde productos.precio.
  v_precio productos.precio%TYPE;
BEGIN
  SELECT precio INTO v_precio FROM productos WHERE id = in_producto_id;
  INSERT INTO pedidos (cliente_id, producto_id, cantidad, total, estado)
  VALUES (in_cliente_id, in_producto_id, in_cantidad, v_precio * in_cantidad, 'pendiente')
  RETURNING id INTO out_pedido_id;
EXCEPTION
  WHEN NO_DATA_FOUND THEN
    out_pedido_id := NULL;
END sp_registrar_pedido;
/

CREATE OR REPLACE PROCEDURE sp_reporte_ventas (
  in_desde IN DATE,
  in_hasta IN DATE
) AUTHID DEFINER AS
  -- Resumen de ventas por estado en un rango; devuelve filas como en MySQL.
  c SYS_REFCURSOR;
BEGIN
  OPEN c FOR
    SELECT p.estado,
           COUNT(*)                  AS pedidos,
           COALESCE(SUM(p.total), 0) AS monto
      FROM pedidos p
     WHERE TRUNC(p.creado_en) BETWEEN in_desde AND in_hasta
     GROUP BY p.estado;
  DBMS_SQL.RETURN_RESULT(c);
END sp_reporte_ventas;
/

CREATE OR REPLACE PROCEDURE sp_ajustar_stock (
  in_producto_id IN     NUMBER,
  io_delta       IN OUT NUMBER
) AUTHID DEFINER AS
  -- Aplica un delta al stock; devuelve en io_delta el stock resultante.
BEGIN
  UPDATE productos
     SET stock = GREATEST(stock + io_delta, 0)
   WHERE id = in_producto_id
  RETURNING stock INTO io_delta;
END sp_ajustar_stock;
/

CREATE OR REPLACE FUNCTION fn_precio_con_iva (
  p_precio  IN NUMBER,
  p_iva_pct IN NUMBER
) RETURN NUMBER DETERMINISTIC AS
  -- Aplica un porcentaje de IVA sobre un precio base.
BEGIN
  RETURN ROUND(p_precio * (1 + p_iva_pct / 100), 2);
END fn_precio_con_iva;
/

CREATE OR REPLACE FUNCTION fn_stock_producto (
  p_producto_id IN NUMBER
) RETURN NUMBER AUTHID DEFINER AS
  -- Devuelve el stock actual de un producto por id.
  v_stock productos.stock%TYPE;
BEGIN
  SELECT stock INTO v_stock FROM productos WHERE id = p_producto_id;
  RETURN v_stock;
EXCEPTION
  WHEN NO_DATA_FOUND THEN
    RETURN 0;
END fn_stock_producto;
/

CREATE OR REPLACE FUNCTION fn_slugify (
  p_texto IN VARCHAR2
) RETURN VARCHAR2 DETERMINISTIC AS
  -- Normaliza un texto a slug en minúsculas.
BEGIN
  RETURN LOWER(REPLACE(REPLACE(TRIM(p_texto), ' ', '-'), '_', '-'));
END fn_slugify;
/

-- El paquete: lo que sólo tiene Oracle entre las rutinas. Especificación (lo
-- público) y cuerpo (la implementación) se crean, se compilan y se invalidan
-- por separado.
CREATE OR REPLACE PACKAGE pkg_pedidos AUTHID DEFINER AS
  -- Operaciones sobre pedidos agrupadas en un paquete.
  c_estado_inicial CONSTANT VARCHAR2(10) := 'pendiente';

  FUNCTION total_cliente (p_cliente_id IN NUMBER) RETURN NUMBER;

  PROCEDURE cambiar_estado (
    p_pedido_id IN NUMBER,
    p_estado    IN VARCHAR2
  );
END pkg_pedidos;
/

CREATE OR REPLACE PACKAGE BODY pkg_pedidos AS

  FUNCTION total_cliente (p_cliente_id IN NUMBER) RETURN NUMBER IS
    v_total NUMBER;
  BEGIN
    SELECT COALESCE(SUM(total), 0) INTO v_total
      FROM pedidos
     WHERE cliente_id = p_cliente_id;
    RETURN v_total;
  END total_cliente;

  PROCEDURE cambiar_estado (
    p_pedido_id IN NUMBER,
    p_estado    IN VARCHAR2
  ) IS
  BEGIN
    UPDATE pedidos SET estado = p_estado WHERE id = p_pedido_id;
  END cambiar_estado;

END pkg_pedidos;
/
ORA_SEED_40_RUTINAS_EOF

  cat > "${ORACLE_SEED_DIR}/setup/50-disparadores.sql" <<'ORA_SEED_50_DISPARADORES_EOF'
-- 50-disparadores.sql — Oracle
--
-- Los mismos siete disparadores con los mismos nombres. Aquí el modelo vuelve a
-- ser el de Postgres, no el de SQL Server:
--
--   · `FOR EACH ROW`, con `:NEW` y `:OLD`: uno por fila, no por sentencia.
--   · **Hay `BEFORE`.** Lo que SQL Server corrige con un `UPDATE` después de
--     escribir (el slug, la marca de actualización) aquí se corrige en `:NEW`
--     antes de escribir, sin segunda escritura.
--   · **La cadena vacía ES `NULL` en Oracle.** El `slug = N''` de SQL Server se
--     escribe `slug IS NULL`, y el disparador corre antes que el `NOT NULL`.
--   · `JSON_OBJECT('k': v)` → `JSON_OBJECT('k' VALUE v)`.
--   · `HASHBYTES('SHA2_256', …)` → `STANDARD_HASH(…, 'SHA256')`, que es SQL y
--     no PL/SQL: va dentro del `INSERT`.
--   · `SUSER_SNAME()`      → `SYS_CONTEXT('USERENV', 'SESSION_USER')`.
--   · `IS DISTINCT FROM` con NULL → `DECODE(a, b, 0, 1) = 1`: `DECODE` trata dos
--     NULL como iguales.

WHENEVER SQLERROR EXIT FAILURE ROLLBACK
SET DEFINE OFF
SET SQLBLANKLINES ON
ALTER SESSION SET CONTAINER = FREEPDB1;
ALTER SESSION SET CURRENT_SCHEMA = DEMO;

-- ── 1 y 2) Normalización del slug ───────────────────────────────────────────

CREATE OR REPLACE TRIGGER bi_categorias_slug
BEFORE INSERT ON categorias
FOR EACH ROW
WHEN (NEW.slug IS NULL)
BEGIN
  :NEW.slug := fn_slugify(:NEW.nombre);
END;
/

CREATE OR REPLACE TRIGGER bu_categorias_slug
BEFORE UPDATE OF nombre ON categorias
FOR EACH ROW
WHEN (NEW.nombre <> OLD.nombre AND NEW.slug = OLD.slug)
BEGIN
  :NEW.slug := fn_slugify(:NEW.nombre);
END;
/

-- ── 3, 4 y 5) Auditoría de pedidos: sólo escribe en log_auditoria ───────────

CREATE OR REPLACE TRIGGER ai_pedidos_audit
AFTER INSERT ON pedidos
FOR EACH ROW
BEGIN
  INSERT INTO log_auditoria (actor, entidad, entidad_id, accion, payload_json, payload_hash)
  VALUES (SYS_CONTEXT('USERENV', 'SESSION_USER'), 'pedidos', :NEW.id, 'INSERT',
          JSON_OBJECT('cliente_id' VALUE :NEW.cliente_id, 'producto_id' VALUE :NEW.producto_id,
                      'cantidad' VALUE :NEW.cantidad, 'total' VALUE :NEW.total, 'estado' VALUE :NEW.estado),
          STANDARD_HASH(:NEW.cliente_id || '|' || :NEW.producto_id || '|' || :NEW.total, 'SHA256'));
END;
/

CREATE OR REPLACE TRIGGER au_pedidos_audit
AFTER UPDATE OF estado ON pedidos
FOR EACH ROW
WHEN (DECODE(NEW.estado, OLD.estado, 0, 1) = 1)
BEGIN
  INSERT INTO log_auditoria (actor, entidad, entidad_id, accion, payload_json, payload_hash)
  VALUES (SYS_CONTEXT('USERENV', 'SESSION_USER'), 'pedidos', :NEW.id, 'UPDATE',
          JSON_OBJECT('estado_anterior' VALUE :OLD.estado, 'estado_nuevo' VALUE :NEW.estado),
          STANDARD_HASH(:NEW.id || '|' || :OLD.estado || '|' || :NEW.estado, 'SHA256'));
END;
/

CREATE OR REPLACE TRIGGER ad_pedidos_audit
AFTER DELETE ON pedidos
FOR EACH ROW
BEGIN
  INSERT INTO log_auditoria (actor, entidad, entidad_id, accion, payload_json)
  VALUES (SYS_CONTEXT('USERENV', 'SESSION_USER'), 'pedidos', :OLD.id, 'DELETE',
          JSON_OBJECT('cliente_id' VALUE :OLD.cliente_id, 'producto_id' VALUE :OLD.producto_id,
                      'total' VALUE :OLD.total, 'estado' VALUE :OLD.estado));
END;
/

-- ── 6) Auditoría de altas de cliente ────────────────────────────────────────

CREATE OR REPLACE TRIGGER ai_clientes_audit
AFTER INSERT ON clientes
FOR EACH ROW
BEGIN
  INSERT INTO log_auditoria (actor, entidad, entidad_id, accion, payload_json)
  VALUES (SYS_CONTEXT('USERENV', 'SESSION_USER'), 'clientes', :NEW.id, 'INSERT',
          JSON_OBJECT('email' VALUE :NEW.email, 'ciudad' VALUE :NEW.ciudad));
END;
/

-- ── 7) El equivalente de ON UPDATE CURRENT_TIMESTAMP ────────────────────────
-- Con `BEFORE` no hay segundo `UPDATE` ni riesgo de bucle: se corrige `:NEW`.

CREATE OR REPLACE TRIGGER bu_direcciones_touch
BEFORE UPDATE ON direcciones
FOR EACH ROW
BEGIN
  :NEW.actualizada := SYSTIMESTAMP;
END;
/
ORA_SEED_50_DISPARADORES_EOF

  cat > "${ORACLE_SEED_DIR}/setup/60-solo-oracle.sql" <<'ORA_SEED_60_SOLO_ORACLE_EOF'
-- 60-solo-oracle.sql — lo que este motor tiene y los otros no
--
-- Cada cosa está porque una herramienta de Calíope o el driver del plan 63
-- tiene que saber enseñarla, decodificarla o decir que no la decodifica:
--
--   · `seq_referencia_pedido`: una SEQUENCE suelta, como la de SQL Server.
--   · `idx_pedidos_estado_mayus`: un índice por función (`UPPER(estado)`), que
--     el catálogo enseña con su expresión y no con la columna oculta
--     `SYS_NC…$` que Oracle crea para sostenerlo (tarea 29).
--   · `tipos_oracle`: un tipo por columna, para el decodificador (tarea 16):
--     `NUMBER` sin tipar de 38 dígitos, negativos y `1E-10`, `BINARY_DOUBLE`,
--     `BINARY_FLOAT`, `DATE` con hora, `TIMESTAMP(9)`, `WITH TIME ZONE` con
--     desfase, `WITH LOCAL TIME ZONE`, los dos `INTERVAL`, `NVARCHAR2`, `CHAR(5)`
--     con relleno, `RAW(16)` y `BOOLEAN`.
--   · `con_objeto`: una columna de un TIPO DE OBJETO (`punto`): Calíope enseña
--     «no decodificado» (decisión 6).
--   · `documentos_grandes`: LOB de 1, 5 y 20 MiB en `cuerpo` (CLOB) y uno de
--     1 MiB en `binario` (BLOB) en la fila 1. El de 20 MiB pasa el techo de
--     16 MiB del driver (tarea 18). Se construyen con `DBMS_LOB.WRITEAPPEND`
--     porque `RPAD` en SQL se acota a `max_string_size` (4 000 en Free).
--   · `documentos_json`: una columna `JSON` nativa (binaria, OSON): «no
--     decodificado» salvo que el provider pida `JSON_SERIALIZE`.
--   · `mv_ventas_por_estado`: una vista materializada (se refresca al final de
--     70-datos-demo.sql, cuando ya hay pedidos).
--   · `ventas_mensuales`: una tabla particionada por rango.

WHENEVER SQLERROR EXIT FAILURE ROLLBACK
SET DEFINE OFF
SET SQLBLANKLINES ON
ALTER SESSION SET CONTAINER = FREEPDB1;
ALTER SESSION SET CURRENT_SCHEMA = DEMO;

CREATE SEQUENCE seq_referencia_pedido
  START WITH 1000
  INCREMENT BY 1
  CACHE 10;

CREATE INDEX idx_pedidos_estado_mayus ON pedidos (UPPER(estado));

CREATE TABLE tipos_oracle (
  id           NUMBER(10) CONSTRAINT pk_tipos_oracle PRIMARY KEY,
  n_sin_tipar  NUMBER,
  n_negativo   NUMBER,
  n_pequeno    NUMBER,
  bd           BINARY_DOUBLE,
  bf           BINARY_FLOAT,
  d            DATE,
  ts           TIMESTAMP(9),
  tstz         TIMESTAMP(6) WITH TIME ZONE,
  tsltz        TIMESTAMP(6) WITH LOCAL TIME ZONE,
  ids          INTERVAL DAY(2) TO SECOND(6),
  iym          INTERVAL YEAR(2) TO MONTH,
  nv           NVARCHAR2(40),
  c5           CHAR(5),
  r16          RAW(16),
  b            BOOLEAN
);
INSERT INTO tipos_oracle VALUES (
  1, 12345678901234567890123456789012345678, -12.5, 1E-10,
  3.141592653589793D, 2.5F,
  TO_DATE('2026-10-15 14:30:05', 'YYYY-MM-DD HH24:MI:SS'),
  TIMESTAMP '2026-10-15 14:30:05.123456789',
  TIMESTAMP '2026-10-15 14:30:05.5 -06:00',
  TIMESTAMP '2026-10-15 14:30:05 +02:00',
  INTERVAL '1 02:03:04.5' DAY TO SECOND,
  INTERVAL '2-3' YEAR TO MONTH,
  N'Calíope ñ € 日本', 'ab', HEXTORAW('00112233445566778899AABBCCDDEEFF'), TRUE);
INSERT INTO tipos_oracle VALUES (
  2, -98765432109876543210987654321098765432, -0.001, 0,
  -1.0E100D, -0.25F,
  TO_DATE('1999-12-31 23:59:59', 'YYYY-MM-DD HH24:MI:SS'),
  TIMESTAMP '1999-12-31 23:59:59',
  TIMESTAMP '1999-12-31 23:59:59 +05:30',
  TIMESTAMP '1999-12-31 23:59:59 +00:00',
  INTERVAL '-0 00:00:01' DAY TO SECOND,
  INTERVAL '-1-0' YEAR TO MONTH,
  N'Ελληνικά', 'abcde', HEXTORAW('FFEEDDCCBBAA99887766554433221100'), FALSE);
COMMIT;
COMMENT ON TABLE tipos_oracle IS 'Un tipo de Oracle por columna, para el decodificador del driver';

CREATE TYPE punto AS OBJECT (x NUMBER, y NUMBER);
/
CREATE TABLE con_objeto (
  id  NUMBER(10) CONSTRAINT pk_con_objeto PRIMARY KEY,
  p   punto
);
INSERT INTO con_objeto VALUES (1, punto(1, 2));
INSERT INTO con_objeto VALUES (2, punto(-3.5, 40.25));
COMMIT;
COMMENT ON TABLE con_objeto IS 'Columna de un tipo de objeto: Calíope la enseña como no decodificada';

CREATE TABLE documentos_grandes (
  id       NUMBER(10) CONSTRAINT pk_documentos_grandes PRIMARY KEY,
  nombre   VARCHAR2(40 CHAR) NOT NULL,
  cuerpo   CLOB,
  binario  BLOB
);
DECLARE
  v_clob   CLOB;
  v_blob   BLOB;
  v_texto  VARCHAR2(16384);
  v_hex    VARCHAR2(16384);
  v_bytes  RAW(8192);
BEGIN
  -- 16 KiB de texto ASCII (un carácter = un byte) y 8 KiB con los 256 valores
  -- de byte repetidos.
  v_texto := RPAD('Caliope LOB ', 16384, 'abcdefghijklmnopqrstuvwxyz0123456789 ');
  FOR i IN 0 .. 255 LOOP
    v_hex := v_hex || TO_CHAR(i, 'FM0X');
  END LOOP;
  v_hex   := RPAD(v_hex, 16384, v_hex);
  v_bytes := HEXTORAW(v_hex);

  FOR fila IN (SELECT 1 AS id, 'clob 1 MiB + blob 1 MiB' AS nombre, 64   AS trozos FROM dual UNION ALL
               SELECT 2,       'clob 5 MiB',                         320            FROM dual UNION ALL
               SELECT 3,       'clob 20 MiB (pasa el techo)',        1280           FROM dual) LOOP
    INSERT INTO documentos_grandes (id, nombre, cuerpo)
    VALUES (fila.id, fila.nombre, EMPTY_CLOB())
    RETURNING cuerpo INTO v_clob;
    FOR k IN 1 .. fila.trozos LOOP
      DBMS_LOB.WRITEAPPEND(v_clob, 16384, v_texto);
    END LOOP;
  END LOOP;

  UPDATE documentos_grandes SET binario = EMPTY_BLOB() WHERE id = 1
  RETURNING binario INTO v_blob;
  FOR k IN 1 .. 128 LOOP
    DBMS_LOB.WRITEAPPEND(v_blob, 8192, v_bytes);
  END LOOP;
  COMMIT;
END;
/
COMMENT ON TABLE documentos_grandes IS 'LOB de 1, 5 y 20 MiB (CLOB) y 1 MiB (BLOB): el techo de 16 MiB del driver';

CREATE TABLE documentos_json (
  id   NUMBER(10) CONSTRAINT pk_documentos_json PRIMARY KEY,
  doc  JSON
);
INSERT INTO documentos_json VALUES (1, JSON('{"cliente":1,"etiquetas":["vip","norte"],"activo":true}'));
INSERT INTO documentos_json VALUES (2, JSON('{"cliente":2,"notas":null}'));
COMMIT;
COMMENT ON TABLE documentos_json IS 'Columna JSON nativa (binaria): no decodificada salvo con JSON_SERIALIZE';

CREATE MATERIALIZED VIEW mv_ventas_por_estado
  BUILD IMMEDIATE
  REFRESH COMPLETE ON DEMAND
AS
SELECT estado, COUNT(*) AS pedidos, COALESCE(SUM(total), 0) AS monto
  FROM pedidos
 GROUP BY estado;
COMMENT ON MATERIALIZED VIEW mv_ventas_por_estado IS 'Pedidos por estado, refrescada a mano (ON DEMAND)';

CREATE TABLE ventas_mensuales (
  id       NUMBER(10) CONSTRAINT pk_ventas_mensuales PRIMARY KEY,
  mes      DATE NOT NULL,
  importe  NUMBER(12,2) NOT NULL
)
PARTITION BY RANGE (mes) (
  PARTITION p_2025    VALUES LESS THAN (DATE '2026-01-01'),
  PARTITION p_2026_s1 VALUES LESS THAN (DATE '2026-07-01'),
  PARTITION p_resto   VALUES LESS THAN (MAXVALUE)
);
INSERT INTO ventas_mensuales VALUES (1, DATE '2025-11-01', 1520.40);
INSERT INTO ventas_mensuales VALUES (2, DATE '2025-12-01', 1980.00);
INSERT INTO ventas_mensuales VALUES (3, DATE '2026-03-01', 2210.75);
INSERT INTO ventas_mensuales VALUES (4, DATE '2026-05-01', 1875.10);
INSERT INTO ventas_mensuales VALUES (5, DATE '2026-09-01', 2430.95);
COMMIT;
COMMENT ON TABLE ventas_mensuales IS 'Particionada por rango de mes: tres particiones';
ORA_SEED_60_SOLO_ORACLE_EOF

  cat > "${ORACLE_SEED_DIR}/setup/70-datos-demo.sql" <<'ORA_SEED_70_DATOS_DEMO_EOF'
-- 70-datos-demo.sql — Oracle
--
-- Las mismas filas que el seed de SQL Server, con los mismos ids, para que el
-- recuento por tabla coincida y la comparación signifique algo.
--
--   · Ids explícitos: la identidad es `BY DEFAULT`, así que los admite sin
--     `IDENTITY_INSERT`; pero, a diferencia de SQL Server, el contador NO se
--     recoloca solo, y al final cada tabla lo lleva al máximo con
--     `START WITH LIMIT VALUE`. Sin eso el primer alta desde la app chocaría
--     con el id 1.
--   · `geography::STGeomFromText` → `SDO_GEOMETRY(2001, 4326, SDO_POINT_TYPE(
--     longitud, latitud, NULL), NULL, NULL)`, en el mismo orden.
--   · `log_auditoria` no se siembra: la llenan los disparadores de 50 (4 altas
--     de cliente + 6 de pedido = 10 filas).
--   · Sin `N'…'`: la base es AL32UTF8 y `VARCHAR2` guarda cualquier carácter.

WHENEVER SQLERROR EXIT FAILURE ROLLBACK
SET DEFINE OFF
ALTER SESSION SET CONTAINER = FREEPDB1;
ALTER SESSION SET CURRENT_SCHEMA = DEMO;

INSERT INTO productos (id, nombre, categoria, precio, stock) VALUES
  (1, 'Widget A',           'accesorios',    9.99, 100),
  (2, 'Widget B',           'accesorios',   19.99,  50),
  (3, 'Gadget Pro',         'electronica',  49.99,  25),
  (4, 'Cable USB-C',        'cables',        4.99, 200),
  (5, 'Adaptador HDMI',     'cables',       14.99,  75),
  (6, 'Teclado mecánico',   'periféricos',  89.99,  30),
  (7, 'Ratón inalámbrico',  'periféricos',  34.99,  60),
  (8, 'Monitor 24"',        'pantallas',   249.99,  10);

INSERT INTO clientes (id, nombre, email, ciudad) VALUES
  (1, 'Ana García',    'ana@ejemplo.com',    'Madrid'),
  (2, 'Luis Mora',     'luis@ejemplo.com',   'San José'),
  (3, 'María Castro',  'maria@ejemplo.com',  'Ciudad de México'),
  (4, 'Carlos Ruiz',   'carlos@ejemplo.com', 'Buenos Aires');

INSERT INTO pedidos (id, cliente_id, producto_id, cantidad, total, estado) VALUES
  (1, 1, 1, 5,  49.95, 'entregado'),
  (2, 2, 3, 2,  99.98, 'enviado'),
  (3, 3, 2, 1,  19.99, 'pendiente'),
  (4, 4, 5, 3,  44.97, 'entregado'),
  (5, 1, 6, 1,  89.99, 'enviado'),
  (6, 2, 7, 2,  69.98, 'pendiente');

INSERT INTO categorias (id, slug, nombre, descripcion, padre_id, activa) VALUES
  (1, 'electronica',   'Electrónica',   'Dispositivos electrónicos varios', NULL, TRUE),
  (2, 'cables',        'Cables',        'Cables y conectores',              1,    TRUE),
  (3, 'perifericos',   'Periféricos',   'Teclados, ratones, etc.',          1,    TRUE),
  (4, 'pantallas',     'Pantallas',     'Monitores y proyectores',          1,    TRUE),
  (5, 'accesorios',    'Accesorios',    'Complementos generales',           NULL, TRUE),
  (6, 'descatalogado', 'Descatalogado', 'Categoría obsoleta',               NULL, FALSE);

-- Una sentencia por fila: en esta tabla, un `VALUES` de varias filas con
-- `JSON_OBJECT` viola el `CHECK (metadatos IS JSON)` que cada fila sola cumple
-- (ORA-02290 en la 23.26.3, medido el 2026-10-15, también sin el índice
-- espacial). En una tabla de dos columnas no pasa: la causa no se aisló.
INSERT INTO direcciones (id, cliente_id, etiqueta, linea1, ciudad, pais, metadatos, ubicacion) VALUES
  (1, 1, 'principal', 'Calle Mayor 12',      'Madrid',           'ES',
     JSON_OBJECT('piso' VALUE 3, 'puerta' VALUE 'B'),
     SDO_GEOMETRY(2001, 4326, SDO_POINT_TYPE(-3.7038, 40.4168, NULL), NULL, NULL));
INSERT INTO direcciones (id, cliente_id, etiqueta, linea1, ciudad, pais, metadatos, ubicacion) VALUES
  (2, 2, 'principal', 'Av. Central 88',      'San José',         'CR',
     JSON_OBJECT('cerca' VALUE 'ICE'),
     SDO_GEOMETRY(2001, 4326, SDO_POINT_TYPE(-84.0907, 9.9281, NULL), NULL, NULL));
INSERT INTO direcciones (id, cliente_id, etiqueta, linea1, ciudad, pais, metadatos, ubicacion) VALUES
  (3, 3, 'trabajo',   'Reforma 500 piso 12', 'Ciudad de México', 'MX',
     JSON_OBJECT('oficina' VALUE 1204),
     SDO_GEOMETRY(2001, 4326, SDO_POINT_TYPE(-99.1332, 19.4326, NULL), NULL, NULL));
INSERT INTO direcciones (id, cliente_id, etiqueta, linea1, ciudad, pais, metadatos, ubicacion) VALUES
  (4, 4, 'principal', 'Av. Corrientes 1200', 'Buenos Aires',     'AR',
     JSON_OBJECT('notas' VALUE 'timbre 4B'),
     SDO_GEOMETRY(2001, 4326, SDO_POINT_TYPE(-58.3816, -34.6037, NULL), NULL, NULL));

INSERT INTO pedido_lineas (id, pedido_id, producto_id, cantidad, precio_unit, descuento_pct) VALUES
  (1, 1, 1, 5,   9.99,  0.00),
  (2, 2, 3, 2,  49.99,  5.00),
  (3, 3, 2, 1,  19.99,  0.00),
  (4, 4, 5, 3,  14.99,  0.00),
  (5, 5, 6, 1,  89.99, 10.00),
  (6, 6, 7, 2,  34.99,  0.00);

COMMIT;

-- Los contadores de identidad, al máximo de lo sembrado (DDL: confirma solo).
ALTER TABLE productos     MODIFY id GENERATED BY DEFAULT AS IDENTITY (START WITH LIMIT VALUE);
ALTER TABLE clientes      MODIFY id GENERATED BY DEFAULT AS IDENTITY (START WITH LIMIT VALUE);
ALTER TABLE pedidos       MODIFY id GENERATED BY DEFAULT AS IDENTITY (START WITH LIMIT VALUE);
ALTER TABLE categorias    MODIFY id GENERATED BY DEFAULT AS IDENTITY (START WITH LIMIT VALUE);
ALTER TABLE direcciones   MODIFY id GENERATED BY DEFAULT AS IDENTITY (START WITH LIMIT VALUE);
ALTER TABLE pedido_lineas MODIFY id GENERATED BY DEFAULT AS IDENTITY (START WITH LIMIT VALUE);

-- La vista materializada de 60-solo-oracle.sql se creó con `pedidos` vacía.
EXEC DBMS_MVIEW.REFRESH('DEMO.MV_VENTAS_POR_ESTADO', 'C')

-- Los `ALTER TABLE` de arriba son DDL sobre tablas de las que dependen vistas,
-- rutinas y disparadores, y los dejan INVALID (11 objetos, medido el
-- 2026-10-15). Se recompilarían solos al usarse, pero el árbol de objetos los
-- enseñaría rotos y `99-marca.sh` no escribe la marca con ninguno inválido.
EXEC DBMS_UTILITY.COMPILE_SCHEMA('DEMO', FALSE)
ORA_SEED_70_DATOS_DEMO_EOF

  cat > "${ORACLE_SEED_DIR}/setup/71-datos-rrhh.sql" <<'ORA_SEED_71_DATOS_RRHH_EOF'
-- 71-datos-rrhh.sql — Oracle
--
-- Las mismas filas que `rrhh` de SQL Server. `permisos` va como lista separada
-- por comas, y `cliente_asignado_id` —la foránea que cruza a `DEMO`, ver
-- 21-tablas-rrhh.sql— se rellena en los dos comerciales y queda NULL en el resto.
-- Fechas con literales ANSI (`DATE '…'`, `TIMESTAMP '…'`), que no dependen de
-- `NLS_DATE_FORMAT`.

WHENEVER SQLERROR EXIT FAILURE ROLLBACK
SET DEFINE OFF
ALTER SESSION SET CONTAINER = FREEPDB1;
ALTER SESSION SET CURRENT_SCHEMA = RRHH;

INSERT INTO departamentos (id, codigo, nombre, presupuesto) VALUES
  (1, 'DIR', 'Dirección',        250000.00),
  (2, 'ING', 'Ingeniería',       450000.00),
  (3, 'COM', 'Comercial',        180000.00),
  (4, 'RRH', 'Recursos Humanos',  90000.00);

INSERT INTO empleados (id, nombre, email, departamento_id, jefe_id, cliente_asignado_id, fecha_ingreso, salario_base, estado, permisos, notas) VALUES
  (1, 'Ana García',      'ana.g@empresa.test',    1, NULL, NULL, DATE '2018-01-15', 8500.00, 'activo',      'lectura,escritura,aprobacion,admin', 'CEO'),
  (2, 'Luis Mora',       'luis.m@empresa.test',   2, 1,    NULL, DATE '2019-03-22', 6200.00, 'activo',      'lectura,escritura,aprobacion',       'Director de Ingeniería'),
  (3, 'María Castro',    'maria.c@empresa.test',  3, 1,    3,    DATE '2019-07-10', 5800.00, 'activo',      'lectura,escritura,aprobacion',       'Directora Comercial'),
  (4, 'Carlos Ruiz',     'carlos.r@empresa.test', 2, 2,    NULL, DATE '2020-05-04', 4200.00, 'activo',      'lectura,escritura',                  'Backend'),
  (5, 'Sofía López',     'sofia.l@empresa.test',  2, 2,    NULL, DATE '2021-09-18', 3800.00, 'vacaciones',  'lectura,escritura',                  'Frontend'),
  (6, 'Diego Fernández', 'diego.f@empresa.test',  3, 3,    1,    DATE '2022-02-14', 3200.00, 'baja_medica', 'lectura',                            'Ventas región norte'),
  (7, 'Elena Vega',      'elena.v@empresa.test',  4, 1,    NULL, DATE '2020-11-30', 4500.00, 'activo',      'lectura,escritura,aprobacion',       'Responsable de RRHH'),
  (8, 'Marco Antonelli', 'marco.a@empresa.test',  2, 2,    NULL, DATE '2023-06-01', 3600.00, 'despedido',   'lectura',                            'Baja disciplinaria');

INSERT INTO nominas (id, empleado_id, periodo, bruto, impuestos, estado, emitida_en) VALUES
  (1, 1, '2026-05', 8500.00, 2380.00, 'pagada',   TIMESTAMP '2026-05-31 18:00:00'),
  (2, 2, '2026-05', 6200.00, 1550.00, 'pagada',   TIMESTAMP '2026-05-31 18:05:00'),
  (3, 3, '2026-05', 5800.00, 1450.00, 'pagada',   TIMESTAMP '2026-05-31 18:10:00'),
  (4, 4, '2026-05', 4200.00, 1050.00, 'emitida',  TIMESTAMP '2026-05-31 18:15:00'),
  (5, 5, '2026-05', 3800.00,  950.00, 'emitida',  TIMESTAMP '2026-05-31 18:20:00'),
  (6, 7, '2026-05', 4500.00, 1125.00, 'borrador', NULL);

COMMIT;

ALTER TABLE departamentos MODIFY id GENERATED BY DEFAULT AS IDENTITY (START WITH LIMIT VALUE);
ALTER TABLE empleados     MODIFY id GENERATED BY DEFAULT AS IDENTITY (START WITH LIMIT VALUE);
ALTER TABLE nominas       MODIFY id GENERATED BY DEFAULT AS IDENTITY (START WITH LIMIT VALUE);

-- Lo mismo que al final de 70-datos-demo.sql: el DDL invalida lo que depende.
EXEC DBMS_UTILITY.COMPILE_SCHEMA('RRHH', FALSE)
ORA_SEED_71_DATOS_RRHH_EOF

  cat > "${ORACLE_SEED_DIR}/setup/99-marca.sh" <<'ORACLE_SEED_99_MARCA_EOF'
# 99-marca.sh — la última pieza del seed del nodo `oracle`.
#
# La imagen lo carga con `.` (no lo ejecuta): nada de `exit`, y nada de una
# variable `f`, que es la del bucle de `runUserScripts.sh`. Escribe la marca que
# exige el healthcheck y que mira `startup/00-caliope-seed.sh`, en el VOLUMEN de
# datos: en `/tmp` desaparecería en cada reinicio y el seed se repetiría sobre
# una base ya sembrada.
#
# Pero SÓLO si el seed quedó entero: la imagen corre cada `.sql` aunque el
# anterior haya fallado, y un `WHENEVER SQLERROR` sólo corta el suyo. Así que
# antes de la marca se comprueba lo que dejaría un seed a medias: filas en
# `DEMO.CLIENTES` (70) y `RRHH.EMPLEADOS` (71), los tres LOB de
# `DEMO.DOCUMENTOS_GRANDES` (60) y ningún objeto inválido en los dos esquemas
# (un PL/SQL que no compiló queda INVALID sin que nadie lo diga), y ninguna
# cadena de `DEMO.CLIENTES` con U+FFFD (el seed corrido sin `NLS_LANG` en UTF-8
# guarda así cada tilde). Sin marca, el siguiente arranque repite el seed desde
# `10-usuarios.sql`.
caliope_seed_check="$("$ORACLE_HOME"/bin/sqlplus -s "/ as sysdba" <<'ORACLE_SEED_CHECK_EOF'
WHENEVER SQLERROR EXIT FAILURE ROLLBACK
SET HEADING OFF FEEDBACK OFF PAGESIZE 0 LINESIZE 200
ALTER SESSION SET CONTAINER = FREEPDB1;
SELECT 'caliope-check'
    || ' ' || (SELECT COUNT(*) FROM demo.clientes)
    || ' ' || (SELECT COUNT(*) FROM rrhh.empleados)
    || ' ' || (SELECT COUNT(*) FROM demo.documentos_grandes)
    || ' ' || (SELECT COUNT(*) FROM dba_objects WHERE owner IN ('DEMO', 'RRHH') AND status <> 'VALID')
    || ' ' || (SELECT COUNT(*) FROM demo.clientes WHERE INSTR(nombre, UNISTR('\FFFD')) > 0)
  FROM dual;
EXIT
ORACLE_SEED_CHECK_EOF
)"
caliope_seed_line="$(printf '%s\n' "$caliope_seed_check" | grep '^caliope-check ')"
read -r _ caliope_clientes caliope_empleados caliope_lobs caliope_invalidos caliope_rotos <<< "${caliope_seed_line:-x 0 0 0 1 1}"
if [ "${caliope_clientes:-0}" -ge 1 ] && [ "${caliope_empleados:-0}" -ge 1 ] \
   && [ "${caliope_lobs:-0}" -eq 3 ] && [ "${caliope_invalidos:-1}" -eq 0 ] \
   && [ "${caliope_rotos:-1}" -eq 0 ]; then
  date -Iseconds > /opt/oracle/oradata/caliope-seed-ok
  echo "[caliope] seed de Oracle: terminado (clientes ${caliope_clientes}, empleados ${caliope_empleados}, lobs ${caliope_lobs}, invalidos ${caliope_invalidos}, tildes rotas ${caliope_rotos})"
else
  echo "[caliope] seed de Oracle: INCOMPLETO, sin marca (${caliope_seed_line:-sin respuesta de sqlplus}); se repite en el siguiente arranque"
  printf '%s\n' "$caliope_seed_check"
fi
ORACLE_SEED_99_MARCA_EOF

  # Los certificados y el `startup/10-tcps.sh` de `oracle-tls` (tarea 3 del plan 63),
  # como `generate_clickhouse_tls` desde la semilla de ClickHouse: `oracle-tls` usa
  # esta misma semilla y suma lo suyo (`Dockerfile.oracle`).
  generate_oracle_tls

  # El recuento excluye `logs/`: son los registros de las corridas anteriores, no
  # archivos extraídos, y sin la exclusión la cifra crecía una unidad por corrida
  # (49 la primera vez, 74 diez corridas después).
  step "Extracted $(find "${WORKDIR}" -type f -maxdepth 3 -not -path "${LOG_DIR}/*" | wc -l | tr -d ' ') files."
}

# ==============================================================================
#  El destino: el Docker de este Mac o el de otra máquina (David, 2026-10-01)
# ==============================================================================
#
# El mismo testbed se puede levantar en el Docker de este Mac (`--local`) o en
# el de otra máquina de la red (`--remote`), y en los dos a la vez: son dos
# Docker distintos, así que ni los nombres de contenedor ni los puertos chocan.
# Todo sigue corriendo DESDE este Mac: el lanzador extrae aquí sus archivos, y
# lo que tiene que llegar a la otra máquina viaja como contexto de construcción
# (por eso las semillas van dentro de imágenes; ver la cabecera del compose).
#
# Cómo se elige, en este orden:
#
#   · `--local` o `--remote` en la línea de órdenes, y se guarda en `target`.
#   · Sin ninguno, el último guardado; la primera vez, el local. Así, quien sólo
#     tiene Docker Desktop no ve ningún cambio.
#   · Salvo que el testbed esté corriendo en LOS DOS: entonces nada adivina, y
#     el lanzador pregunta (o, sin terminal, se niega). Parar el equivocado
#     creyendo parar el otro es justo el error que esto existe para evitar.
#
# El remoto es un contexto de Docker por SSH (`caliope-testbed-remote`). La
# primera vez el lanzador lo prepara entero: pide la IP y el usuario, crea una
# llave propia (`~/.ssh/caliope-testbed`) y la instala pidiendo la contraseña
# una vez, instala Docker si falta —con el script oficial, get.docker.com, que
# pide la contraseña de administrador de esa máquina— y registra el contexto.
#
# El contexto apunta a un ALIAS de SSH, no a la IP, y el alias vive en un
# archivo propio (`~/.ssh/caliope-testbed.conf`, incluido desde `~/.ssh/config`).
# La IP de la otra máquina la reparte su red y cambia; cuando cambia, el
# lanzador lo dice, pide la nueva y reescribe sólo ese archivo. Nunca cae al
# Docker local en silencio: creer que se prueba contra la otra máquina sin
# estar haciéndolo es peor que no poder probar.
TARGET=""
TARGET_FILE="${WORKDIR}/target"
REMOTE_CONF="${WORKDIR}/remote.conf"
REMOTE_CONTEXT="caliope-testbed-remote"
SSH_ALIAS="caliope-testbed-remote"
SSH_KEY="${HOME}/.ssh/caliope-testbed"
SSH_INCLUDE="${HOME}/.ssh/caliope-testbed.conf"
REMOTE_HOST=""
REMOTE_USER=""
DOCKER_CTX=""

# El nombre de los archivos que cambian con el destino. El local conserva el
# nombre de siempre —lo citan la Ayuda de la app en los cinco idiomas, el sitio
# y los runbooks, y quien sólo usa Docker Desktop no tiene por qué notar nada—,
# y sólo el remoto lleva sufijo: `testbed-servers-remote.json` y
# `caliope-testbed-connections-remote.json` (David, 2026-10-01).
target_suffix() {
  [[ "$TARGET" == "remote" ]] && printf -- '-remote'
  return 0
}

load_remote_conf() {
  [[ -f "$REMOTE_CONF" ]] || return 1
  REMOTE_HOST="$(sed -n 's/^host=//p' "$REMOTE_CONF")"
  REMOTE_USER="$(sed -n 's/^user=//p' "$REMOTE_CONF")"
  [[ -n "$REMOTE_HOST" && -n "$REMOTE_USER" ]]
}

save_remote_conf() {
  printf 'host=%s\nuser=%s\n' "$REMOTE_HOST" "$REMOTE_USER" > "$REMOTE_CONF"
}

# El contexto local es el que el usuario tenga en uso —Docker Desktop, Colima,
# OrbStack—, salvo que el que tenga en uso sea el remoto de este testbed.
local_context() {
  local cur
  cur="$(docker context show 2>/dev/null || echo default)"
  if [[ "$cur" == "$REMOTE_CONTEXT" ]]; then
    if docker context inspect desktop-linux >/dev/null 2>&1; then cur="desktop-linux"; else cur="default"; fi
  fi
  printf '%s' "$cur"
}

remote_docker_ok() {
  DOCKER_CONTEXT="$REMOTE_CONTEXT" docker info >/dev/null 2>&1
}

# El alias de SSH del remoto. `ConnectTimeout` hace que una máquina apagada se
# note en 5 s y no en los 75 de TCP; `ControlMaster` reutiliza una conexión
# para las decenas que abre `docker compose`.
write_ssh_include() {
  mkdir -p "${HOME}/.ssh"
  chmod 700 "${HOME}/.ssh"
  cat > "$SSH_INCLUDE" <<SSH_EOF
# Written by caliope-testbed.command: the remote machine of the Caliope testbed.
# Rewritten when its IP changes. Do not edit by hand.
Host ${SSH_ALIAS}
  HostName ${REMOTE_HOST}
  User ${REMOTE_USER}
  IdentityFile ${SSH_KEY}
  IdentitiesOnly yes
  ConnectTimeout 5
  ServerAliveInterval 15
  ServerAliveCountMax 3
  StrictHostKeyChecking accept-new
  ControlMaster auto
  ControlPath ~/.ssh/caliope-testbed-%C
  ControlPersist 120
SSH_EOF
  chmod 600 "$SSH_INCLUDE"

  # `Include` tiene que ir ANTES del primer `Host` de `~/.ssh/config`, o sólo
  # rige dentro de ese bloque. Se reescribe con `cat >` y no con `mv` para no
  # romper un `config` que sea un enlace simbólico.
  local cfg="${HOME}/.ssh/config" tmp
  [[ -f "$cfg" ]] || { : > "$cfg"; chmod 600 "$cfg"; }
  if ! grep -qxF "Include caliope-testbed.conf" "$cfg"; then
    tmp="$(mktemp -t caliope-sshcfg.XXXXXX)"
    { printf 'Include caliope-testbed.conf\n\n'; cat "$cfg"; } > "$tmp"
    cat "$tmp" > "$cfg"
    rm -f "$tmp"
  fi
}

prompt_remote() {
  local ip user
  read -r -p " IP address of the remote machine${REMOTE_HOST:+ [${REMOTE_HOST}]}: " ip
  ip="${ip:-$REMOTE_HOST}"
  if [[ -z "$ip" ]]; then
    echo " No address given: nothing was started." >&2
    exit 1
  fi
  read -r -p " User on that machine [${REMOTE_USER:-$USER}]: " user
  REMOTE_USER="${user:-${REMOTE_USER:-$USER}}"
  REMOTE_HOST="$ip"
}

# Los límites del SSH de la máquina remota, a la medida de Docker por SSH.
#
# Con un contexto `ssh://`, cada operación que `docker compose` hace en paralelo
# —bajar una imagen, inspeccionar un contenedor, construir— es una sesión más
# sobre la conexión maestra, y el `sshd` de fábrica admite 10 (`MaxSessions`):
# la undécima sale «Session open refused by peer». Y desde OpenSSH 9.8 cada
# rechazo penaliza a la IP (`PerSourcePenalties`), así que las conexiones
# siguientes se cortan con «kex_exchange_identification: Connection reset by
# peer» durante un rato. Con los 34 contenedores de la 1.6, `--fresh` fallaba
# siempre en `docker compose pull` (2026-10-13), y antes `--status` y `--only`
# se habían rodeado comando a comando (plan 33, tareas 2 y 38). Esto lo arregla
# en el servidor: un archivo propio en `sshd_config.d`, que `sshd -t` valida
# antes de recargar. `PerSourcePenalties` se queda como está: con el techo
# alto ya no hay rechazos que penalizar.
SSHD_DROPIN="/etc/ssh/sshd_config.d/10-caliope-testbed.conf"
SSHD_DROPIN_BODY='# Written by caliope-testbed.command: room for Docker over SSH (one session
# per parallel docker compose operation). Delete it to go back to the defaults.
MaxSessions 128
MaxStartups 128:30:256'

ensure_sshd_limits() {
  # Una sola sesión para preguntar: si el archivo ya dice lo que tiene que decir, nada.
  if ssh "$SSH_ALIAS" "grep -qx 'MaxSessions 128' '${SSHD_DROPIN}' 2>/dev/null"; then
    return 0
  fi
  local apply="printf '%s\\n' '${SSHD_DROPIN_BODY}' | sudo SUDO tee '${SSHD_DROPIN}' >/dev/null \
    && sudo SUDO sshd -t \
    && { sudo SUDO systemctl reload ssh 2>/dev/null || sudo SUDO systemctl reload sshd; }"
  step "Raising the SSH session limits on ${REMOTE_HOST} (Docker opens one per parallel operation)…"
  # Primero sin preguntar nada: con `sudo` sin contraseña el testbed no interrumpe.
  # `ControlMaster=no`: la maestra sigue con los límites viejos hasta que se cierra.
  if ssh -o ControlMaster=no -o ControlPath=none "$SSH_ALIAS" "${apply//SUDO/-n}" >/dev/null 2>&1; then
    :
  elif [[ -t 0 ]]; then
    echo " It needs the sudo password of ${REMOTE_USER} on ${REMOTE_HOST} (only this once)."
    if ! ssh -t -o ControlMaster=no -o ControlPath=none "$SSH_ALIAS" "${apply//SUDO/}"; then
      echo " ERROR: could not raise the SSH limits on ${REMOTE_HOST}; removing the file." >&2
      ssh -t -o ControlMaster=no -o ControlPath=none "$SSH_ALIAS" "sudo rm -f '${SSHD_DROPIN}'" || true
      exit 1
    fi
  else
    echo " NOTE: ${REMOTE_HOST} keeps the default SSH limits (10 sessions) and docker compose" >&2
    echo "       can hit them; sudo there wants a password. Run --remote once in a terminal." >&2
    return 0
  fi
  ssh -O exit "$SSH_ALIAS" >/dev/null 2>&1 || true
  step "SSH limits raised on ${REMOTE_HOST}."
}

# Prepara la máquina remota. Cada paso comprueba antes de hacer, así que
# repetirlo entero —al cambiar la IP— sólo hace lo que falta.
setup_remote() {
  write_ssh_include
  # Una conexión maestra abierta contra la IP vieja se llevaría las nuevas.
  ssh -O exit "$SSH_ALIAS" >/dev/null 2>&1 || true

  if ! nc -z -G 5 "$REMOTE_HOST" 22 >/dev/null 2>&1; then
    echo "" >&2
    echo " ERROR: nothing answers on ${REMOTE_HOST}, port 22 (SSH)." >&2
    echo "        Check that the machine is on, on this network, and has openssh-server." >&2
    exit 1
  fi

  # 1. La llave. Propia del testbed, sin frase de paso: la usan los procesos
  #    de `docker` sin terminal, que no podrían teclearla.
  if [[ ! -f "$SSH_KEY" ]]; then
    step "Creating the SSH key ${SSH_KEY}…"
    ssh-keygen -q -t ed25519 -N "" -C "caliope-testbed@$(hostname -s)" -f "$SSH_KEY"
  fi
  if ! ssh -o BatchMode=yes "$SSH_ALIAS" true >/dev/null 2>&1; then
    echo ""
    echo " Installing the testbed key on ${REMOTE_USER}@${REMOTE_HOST}."
    echo " Type the password of ${REMOTE_USER} on that machine (only this once):"
    if ! ssh-copy-id -i "${SSH_KEY}.pub" -o ControlMaster=no -o ControlPath=none "$SSH_ALIAS" >/dev/null; then
      echo " ERROR: the key could not be installed on ${REMOTE_USER}@${REMOTE_HOST}." >&2
      exit 1
    fi
    if ! ssh -o BatchMode=yes "$SSH_ALIAS" true >/dev/null 2>&1; then
      echo " ERROR: the key was copied but ${REMOTE_HOST} still refuses it." >&2
      exit 1
    fi
    step "SSH key installed."
  fi

  # 2. Docker. Sólo el motor: `docker compose` corre en este Mac y le habla al
  #    motor de allá, así que el plugin de compose de la otra máquina no se usa.
  if ! ssh "$SSH_ALIAS" 'command -v docker' >/dev/null 2>&1; then
    echo ""
    echo " Docker is not installed on ${REMOTE_HOST}."
    read -r -p " Install it now from Docker's official repository (get.docker.com)? [Y/n] " reply
    case "$reply" in
      [nN]|[nN][oO]) echo " Not installed: nothing was started." >&2; exit 1 ;;
    esac
    echo " It asks for the administrator (sudo) password of ${REMOTE_USER} on that machine."
    if ! ssh -t -o ControlMaster=no -o ControlPath=none "$SSH_ALIAS" \
         'set -e; curl -fsSL https://get.docker.com -o /tmp/get-docker.sh; sudo sh /tmp/get-docker.sh; rm -f /tmp/get-docker.sh'; then
      echo " ERROR: the Docker installation on ${REMOTE_HOST} failed (its output is above)." >&2
      exit 1
    fi
    step "Docker installed on ${REMOTE_HOST}."
  fi

  # 3. Que el usuario pueda hablarle sin sudo, y que el servicio esté en pie.
  #    Pertenecer al grupo `docker` sólo cuenta en sesiones NUEVAS, y la conexión
  #    maestra es una sesión vieja: por eso se cierra antes de volver a probar.
  if ! ssh "$SSH_ALIAS" 'docker info' >/dev/null 2>&1; then
    echo ""
    echo " ${REMOTE_USER} cannot use Docker on ${REMOTE_HOST} yet: adding it to the"
    echo " docker group and starting the service. It asks for the sudo password."
    if ! ssh -t -o ControlMaster=no -o ControlPath=none "$SSH_ALIAS" \
         'sudo usermod -aG docker "$USER" && sudo systemctl enable --now docker'; then
      echo " ERROR: could not give ${REMOTE_USER} access to Docker on ${REMOTE_HOST}." >&2
      exit 1
    fi
    ssh -O exit "$SSH_ALIAS" >/dev/null 2>&1 || true
    if ! ssh "$SSH_ALIAS" 'docker info' >/dev/null 2>&1; then
      echo " ERROR: Docker still does not answer to ${REMOTE_USER} on ${REMOTE_HOST}." >&2
      exit 1
    fi
  fi

  # 4. Los límites del SSH de allá, para Docker por SSH.
  ensure_sshd_limits

  # 5. El contexto. Apunta al alias, así que no cambia con la IP.
  if ! docker context inspect "$REMOTE_CONTEXT" >/dev/null 2>&1; then
    docker context create "$REMOTE_CONTEXT" \
      --description "Caliope testbed on another machine (see ${SSH_INCLUDE})" \
      --docker "host=ssh://${SSH_ALIAS}" >/dev/null 2>&1
    step "Docker context ${REMOTE_CONTEXT} created."
  fi

  save_remote_conf
}

ensure_remote() {
  load_remote_conf || true
  if [[ -n "$REMOTE_HOST" && -f "$SSH_INCLUDE" ]] \
     && docker context inspect "$REMOTE_CONTEXT" >/dev/null 2>&1; then
    # Una máquina configurada antes de que existiera el paso 4 lo recibe aquí.
    if remote_docker_ok; then ensure_sshd_limits; return 0; fi
    echo ""
    echo " The remote testbed machine (${REMOTE_USER}@${REMOTE_HOST}) is not answering."
    echo " Nothing is started on this Mac instead."
    if [[ ! -t 0 ]]; then
      echo " Check that it is on and on this network, or run --remote in a terminal" >&2
      echo " to enter its new IP if it changed." >&2
      exit 1
    fi
    echo " If its IP changed, type the new one; Enter retries ${REMOTE_HOST}."
  else
    if [[ ! -t 0 ]]; then
      echo "ERROR: --remote is not set up yet, and setting it up needs a terminal." >&2
      exit 1
    fi
    echo ""
    line
    echo " Remote testbed — first-time setup"
    line
    echo ""
    echo " The testbed will run on another machine of this network (Linux with"
    echo " SSH). This sets up, once: an SSH key, Docker on that machine if it"
    echo " is missing, and a Docker context named ${REMOTE_CONTEXT}."
    echo ""
  fi
  prompt_remote
  setup_remote
  if ! remote_docker_ok; then
    echo " ERROR: Docker on ${REMOTE_HOST} does not answer through the context ${REMOTE_CONTEXT}." >&2
    exit 1
  fi
}

# ¿Hay contenedores del testbed corriendo en ese destino?
target_is_running() {
  local ctx
  [[ -f "$COMPOSE_FILE" ]] || return 1
  if [[ "$1" == "remote" ]]; then
    docker context inspect "$REMOTE_CONTEXT" >/dev/null 2>&1 || return 1
    ctx="$REMOTE_CONTEXT"
  else
    ctx="$(local_context)"
  fi
  DOCKER_CONTEXT="$ctx" docker compose -f "$COMPOSE_FILE" ps -q --status running 2>/dev/null | grep -q .
}

resolve_target() {
  if ! command -v docker >/dev/null 2>&1; then
    require_docker
  fi

  TARGET="$CLI_TARGET"
  if [[ -z "$TARGET" ]]; then
    local last="local"
    [[ -f "$TARGET_FILE" ]] && last="$(cat "$TARGET_FILE")"
    [[ "$last" == "remote" ]] || last="local"
    if [[ -f "$REMOTE_CONF" ]] && target_is_running local && target_is_running remote; then
      load_remote_conf || true
      if [[ -t 0 ]]; then
        echo ""
        echo " The testbed is running on this Mac AND on the remote machine (${REMOTE_HOST})."
        read -r -p " Which one? [l]ocal / [r]emote: " reply
        case "$reply" in
          [lL]|local)  TARGET="local" ;;
          [rR]|remote) TARGET="remote" ;;
          *) echo " No target chosen: nothing was done." >&2; exit 2 ;;
        esac
      else
        echo "ERROR: the testbed is running both here and on the remote machine; pass --local or --remote." >&2
        exit 2
      fi
    else
      TARGET="$last"
    fi
  fi

  if [[ "$TARGET" == "remote" ]]; then
    unset DOCKER_HOST
    ensure_remote
    DOCKER_CTX="$REMOTE_CONTEXT"
    export DOCKER_CONTEXT="$DOCKER_CTX"
    export MONGO_RS_HOST="$REMOTE_HOST"
    # El nombre que lleva el certificado de `redis-tls` (y el que anuncia el
    # clúster de Redis): la IP de la PC, por la que la app llega a ella.
    export REDIS_ANNOUNCE_HOST="$REMOTE_HOST"
  else
    # Con `DOCKER_HOST` puesto (la sesión en la nube) manda ésa y no se toca.
    if [[ -z "${DOCKER_HOST:-}" ]]; then
      DOCKER_CTX="$(local_context)"
      export DOCKER_CONTEXT="$DOCKER_CTX"
    fi
    unset MONGO_RS_HOST
    export REDIS_ANNOUNCE_HOST="localhost"
  fi
  # `--write-files` no cambia el destino recordado: se corre para regenerar el
  # archivo del OTRO destino —el local desde la VM, que no tiene Docker propio—
  # y pisar `target` dejaría la corrida apuntando a donde no está el testbed.
  [[ "$MODE" == "files" ]] || printf '%s\n' "$TARGET" > "$TARGET_FILE"
}

# Cómo se nombra el destino en lo que se imprime.
target_label() {
  if [[ "$TARGET" == "remote" ]]; then
    printf 'the remote machine %s' "$REMOTE_HOST"
  else
    printf 'this Mac'
  fi
}

# ── testbed-servers.json / testbed-servers-remote.json ──────────────────────
# El registro de nodos que acaba de levantar este launcher, en el mismo
# formato que `~/.config/caliope/diag.json`, para que `--config` acepte
# cualquiera de los dos. Lo escribe el launcher y no una copia a mano porque
# aquí están el puerto, la contraseña y el motor de cada nodo: una lista
# paralela mantenida a mano se desincroniza en cuanto se añade un nodo, y el
# síntoma sería un arnés que prueba contra el servidor equivocado.
#
# Uno por destino, y ninguno pisa al otro: con el testbed en los dos a la vez,
# cada arnés elige el suyo (`--testbed local|remote`, o `CALIOPE_TESTBED`; lo
# resuelve `scripts/testbed-registry.py`). `host` es `127.0.0.1` en local y la
# IP de la otra máquina en remoto, y `dockerContext` dice en qué Docker hay que
# hacer `docker exec`: los arneses que restauran entran al contenedor.
#
# No se fusiona con `diag.json` a propósito. Ese archivo es la lista
# **curada** que consume el generador de capturas de App Store, que siembra
# un perfil de conexión por cada entrada: meter aquí los nodos Postgres haría
# aparecer en la captura del gestor de conexiones tres perfiles a los que
# Calíope todavía no sabe conectarse. Entran ahí cuando haya provider.
#
# `engine` usa los mismos valores que `DatabaseEngine` de la app.
# Los dos de SQL Server entran aunque la app aún no los abra (plan 12): este
# archivo lo lee el arnés, que habla con ellos por `sqlcmd` dentro del
# contenedor, y su `client` va con la ruta entera porque no está en el PATH
# de la imagen. El arnés que no sabe un motor lo dice al elegirlo, no al leer.
# `container` es lo que distingue a este archivo de `diag.json`: quien
# restaura es el cliente que vive DENTRO del contenedor, no uno del Mac.
# Los cinco de ClickHouse (plan 34, tarea 5) van con la cuenta de arranque
# `caliope`, que es con la que `clickhouse-client` entra dentro del contenedor.
# `clickhouse-tunnel` no publica puerto —sólo se llega por el bastión—, así que
# va como lo ve el bastión (`clickhouse-tunnel:8123`) con el túnel en `sshHost`,
# `sshPort`, `sshUser` y `sshPassword`, la forma que `QARunner` ya entiende
# (plan 34, tarea 11). `testbed_registry.is_alive` no lo resuelve y sigue con
# los demás; un arnés que quiera hablarle sin túnel entra por `container`.
# `db` va igual que `clickhouse-tunnel`, por el bastión: entró el 2026-10-13, con
# los nodos nuevos, para que `diag.json` lo pueda copiar —el grupo «Testbed» del
# vídeo de la Topología lo lleva como sexto miembro—.
# `clickhouse-tls` lleva `tls` y su autoridad entera en `ca`, en PEM: el
# registro viaja al contenedor de la app, y el sandbox no lee
# `scripts/clickhouse-tls/ca.crt` de fuera de él. El Keeper y
# `clickhouse-replica-setup` no entran: no se habla con ellos por HTTP.
write_servers_registry() {
  local h="$1"
  local out="${WORKDIR}/testbed-servers$(target_suffix).json"
  # El PEM con sus saltos escritos como `\n`: no lleva comillas ni barras.
  local ch_ca=""
  # La del contenedor primero (`ensure_protocol_fixtures`): es la que firma lo que se sirve.
  local ch_ca_file="${WORKDIR}/clickhouse-tls-ca.pem"
  [[ -f "$ch_ca_file" ]] || ch_ca_file="${CLICKHOUSE_TLS_DIR}/ca.crt"
  [[ -f "$ch_ca_file" ]] && ch_ca="$(awk '{printf "%s\\n", $0}' "$ch_ca_file")"
  # La de `redis-tls` la genera su contenedor y la copia `copy_redis_tls_ca`,
  # que corre antes que esto (plan 33, tareas 2 y 4).
  local redis_ca=""
  [[ -f "${WORKDIR}/redis-tls/ca.crt" ]] && redis_ca="$(awk '{printf "%s\\n", $0}' "${WORKDIR}/redis-tls/ca.crt")"
  local mongo_ca=""
  [[ -f "${WORKDIR}/mongo-tls/ca.crt" ]] && mongo_ca="$(awk '{printf "%s\\n", $0}' "${WORKDIR}/mongo-tls/ca.crt")"
  # Las de los tres nodos de MySQL/MariaDB (plan 61, tarea 4), que copia
  # `ensure_protocol_fixtures`. Van en `ca` sin `tls`: el nodo habla en claro y
  # en TLS por el mismo puerto, y `tls` haría que toda tanda entrara cifrada.
  local standalone_ca="" mysql_latest_ca="" mysql8_ca=""
  [[ -f "${WORKDIR}/mariadb-standalone-ca.pem" ]] && standalone_ca="$(awk '{printf "%s\\n", $0}' "${WORKDIR}/mariadb-standalone-ca.pem")"
  [[ -f "${WORKDIR}/mysql-latest-ca.pem" ]] && mysql_latest_ca="$(awk '{printf "%s\\n", $0}' "${WORKDIR}/mysql-latest-ca.pem")"
  [[ -f "${WORKDIR}/mysql8-gis-ca.pem" ]] && mysql8_ca="$(awk '{printf "%s\\n", $0}' "${WORKDIR}/mysql8-gis-ca.pem")"
  # La de `postgres-gis` (plan 61, tarea 18), que copia también
  # `ensure_protocol_fixtures`: en `ca` y sin `tls`, por lo mismo.
  local postgres_gis_ca=""
  [[ -f "${WORKDIR}/postgres-gis-ca.pem" ]] && postgres_gis_ca="$(awk '{printf "%s\\n", $0}' "${WORKDIR}/postgres-gis-ca.pem")"
  # La de `oracle-tls` (plan 63, tarea 3), la del contenedor (`copy_oracle_tls_ca`) antes
  # que la local, por lo mismo que la de `clickhouse-tls`. Con `tls`: el 2484 sólo habla TCPS.
  local oracle_ca="" oracle_ca_file="${WORKDIR}/oracle-tls-ca.pem"
  [[ -f "$oracle_ca_file" ]] || oracle_ca_file="${ORACLE_TLS_DIR}/ca.crt"
  [[ -f "$oracle_ca_file" ]] && oracle_ca="$(awk '{printf "%s\\n", $0}' "$oracle_ca_file")"
  # `oracle-r1` no es otro contenedor: es el esquema `DEMO_R1` del nodo `oracle`, el destino
  # de la capa 1 de `run-qa.sh --engine oracle` (plan 63, tarea 4). Oracle no tiene «otra base»
  # barata, y el arnés de respaldos lee el esquema de cada lado del campo `schema`.
  # `oracle-tunnel` es el mismo nodo `oracle`, visto desde el bastión (`oracle:1521`) con el túnel
  # en `sshHost`, como `mongo-tunnel`: lo usa el paso `oracleWireProbe connectSSH` (plan 63, tarea 21).
  cat > "$out" <<SERVERS_EOF
{
  "_comment": [
    "Generado por caliope-testbed.command en cada arranque. No editar: se sobrescribe.",
    "Mismo formato que ~/.config/caliope/diag.json, mas 'container' y 'client'.",
    "Uso: scripts/qa-harness/verify-backup-roundtrip.py --testbed ${TARGET}"
  ],
  "target": "${TARGET}",
  "dockerContext": "${DOCKER_CTX}",
  "servers": {
    "primary":           { "host": "${h}", "port": 3311,  "user": "caliope", "password": "caliopepass", "engine": "mariadb",  "container": "mariadb-primary",     "client": "mariadb", "containerPort": 3306 },
    "replica1":          { "host": "${h}", "port": 3312,  "user": "caliope", "password": "caliopepass", "engine": "mariadb",  "container": "mariadb-replica1",    "client": "mariadb", "containerPort": 3306 },
    "replica2":          { "host": "${h}", "port": 3313,  "user": "caliope", "password": "caliopepass", "engine": "mariadb",  "container": "mariadb-replica2",    "client": "mariadb", "containerPort": 3306 },
    "replica3":          { "host": "${h}", "port": 3314,  "user": "caliope", "password": "caliopepass", "engine": "mariadb",  "container": "mariadb-replica3",    "client": "mariadb", "containerPort": 3306 },
    "replica4":          { "host": "${h}", "port": 3315,  "user": "caliope", "password": "caliopepass", "engine": "mariadb",  "container": "mariadb-replica4",    "client": "mariadb", "containerPort": 3306 },
    "db":                { "host": "db", "port": 3306, "user": "caliope", "password": "caliopepass", "engine": "mariadb", "container": "mariadb-db", "client": "mariadb", "containerPort": 3306, "sshHost": "${h}", "sshPort": 2222, "sshUser": "tunneluser", "sshPassword": "tunnel123" },
    "standalone":        { "host": "${h}", "port": 3316,  "user": "caliope", "password": "caliopepass", "engine": "mariadb",  "container": "mariadb-standalone",  "client": "mariadb", "containerPort": 3306, "ca": "${standalone_ca}" },
    "mysql-latest":      { "host": "${h}", "port": 3320,  "user": "caliope", "password": "caliopepass", "engine": "mysql",    "container": "mysql-latest",        "client": "mysql",   "containerPort": 3306, "ca": "${mysql_latest_ca}" },
    "mysql8-gis":        { "host": "${h}", "port": 3321,  "user": "caliope", "password": "caliopepass", "engine": "mysql",    "container": "mysql8-gis",          "client": "mysql",   "containerPort": 3306, "ca": "${mysql8_ca}" },
    "mysql-primary":     { "host": "${h}", "port": 3322,  "user": "caliope", "password": "caliopepass", "engine": "mysql",    "container": "mysql-primary",       "client": "mysql",   "containerPort": 3306 },
    "mysql-replica1":    { "host": "${h}", "port": 3323,  "user": "caliope", "password": "caliopepass", "engine": "mysql",    "container": "mysql-replica1",      "client": "mysql",   "containerPort": 3306 },
    "mysql-replica2":    { "host": "${h}", "port": 3324,  "user": "caliope", "password": "caliopepass", "engine": "mysql",    "container": "mysql-replica2",      "client": "mysql",   "containerPort": 3306 },
    "postgres-gis":      { "host": "${h}", "port": 3330,  "user": "caliope", "password": "caliopepass", "engine": "postgres", "container": "postgres-gis",        "client": "psql",    "containerPort": 5432, "ca": "${postgres_gis_ca}" },
    "postgres-primary":  { "host": "${h}", "port": 3331,  "user": "caliope", "password": "caliopepass", "engine": "postgres", "container": "postgres-primary",    "client": "psql",    "containerPort": 5432 },
    "postgres-replica":  { "host": "${h}", "port": 3332,  "user": "caliope", "password": "caliopepass", "engine": "postgres", "container": "postgres-replica",    "client": "psql",    "containerPort": 5432 },
    "sqlserver":         { "host": "${h}", "port": 3340,  "user": "sa",      "password": "Caliope!pass1", "engine": "sqlserver", "container": "sqlserver",         "client": "/opt/mssql-tools18/bin/sqlcmd", "containerPort": 1433 },
    "sqlserver-restore": { "host": "${h}", "port": 3341,  "user": "sa",      "password": "Caliope!pass1", "engine": "sqlserver", "container": "sqlserver-restore", "client": "/opt/mssql-tools18/bin/sqlcmd", "containerPort": 1433 },
    "oracle":            { "host": "${h}", "port": 1521,  "user": "system",  "password": "Caliope_pass1", "engine": "oracle", "container": "oracle", "client": "sqlplus", "containerPort": 1521, "service": "FREEPDB1" },
    "oracle-r1":         { "host": "${h}", "port": 1521,  "user": "system",  "password": "Caliope_pass1", "engine": "oracle", "container": "oracle", "client": "sqlplus", "containerPort": 1521, "service": "FREEPDB1", "schema": "DEMO_R1" },
    "oracle-tls":        { "host": "${h}", "port": 2484,  "user": "system",  "password": "Caliope_pass1", "engine": "oracle", "container": "oracle-tls", "client": "sqlplus", "containerPort": 2484, "service": "FREEPDB1", "tls": true, "ca": "${oracle_ca}" },
    "oracle-tunnel":     { "host": "oracle", "port": 1521, "user": "system", "password": "Caliope_pass1", "engine": "oracle", "container": "oracle", "client": "sqlplus", "containerPort": 1521, "service": "FREEPDB1", "sshHost": "${h}", "sshPort": 2222, "sshUser": "tunneluser", "sshPassword": "tunnel123" },
    "clickhouse":        { "host": "${h}", "port": 8123,  "user": "caliope", "password": "caliopepass", "engine": "clickhouse", "container": "caliope-clickhouse",        "client": "clickhouse-client", "containerPort": 8123 },
    "clickhouse-tls":    { "host": "${h}", "port": 8443,  "user": "caliope", "password": "caliopepass", "engine": "clickhouse", "container": "caliope-clickhouse-tls",    "client": "clickhouse-client", "containerPort": 8443, "tls": true, "ca": "${ch_ca}" },
    "clickhouse-tunnel": { "host": "clickhouse-tunnel", "port": 8123, "user": "caliope", "password": "caliopepass", "engine": "clickhouse", "container": "caliope-clickhouse-tunnel", "client": "clickhouse-client", "containerPort": 8123, "sshHost": "${h}", "sshPort": 2222, "sshUser": "tunneluser", "sshPassword": "tunnel123" },
    "clickhouse-r1":     { "host": "${h}", "port": 8124,  "user": "caliope", "password": "caliopepass", "engine": "clickhouse", "container": "caliope-clickhouse-r1",     "client": "clickhouse-client", "containerPort": 8123 },
    "clickhouse-r2":     { "host": "${h}", "port": 8125,  "user": "caliope", "password": "caliopepass", "engine": "clickhouse", "container": "caliope-clickhouse-r2",     "client": "clickhouse-client", "containerPort": 8123 },
    "clickhouse-r3":     { "host": "${h}", "port": 8126,  "user": "caliope", "password": "caliopepass", "engine": "clickhouse", "container": "caliope-clickhouse-r3",     "client": "clickhouse-client", "containerPort": 8123 },
    "clickhouse-r4":     { "host": "${h}", "port": 8127,  "user": "caliope", "password": "caliopepass", "engine": "clickhouse", "container": "caliope-clickhouse-r4",     "client": "clickhouse-client", "containerPort": 8123 },
    "mongo":             { "host": "${h}", "port": 27017, "user": "caliope", "password": "caliopepass", "engine": "mongodb",  "container": "caliope-mongo",       "client": "mongosh", "containerPort": 27017 },
    "mongo-rs":          { "host": "${h}", "port": 27018, "user": "caliope", "password": "caliopepass", "engine": "mongodb",  "container": "caliope-mongo-rs",    "client": "mongosh", "containerPort": 27018 },
    "mongo-rs2":         { "host": "${h}", "port": 27020, "user": "caliope", "password": "caliopepass", "engine": "mongodb",  "container": "caliope-mongo-rs2",   "client": "mongosh", "containerPort": 27020 },
    "mongo-rs3":         { "host": "${h}", "port": 27021, "user": "caliope", "password": "caliopepass", "engine": "mongodb",  "container": "caliope-mongo-rs3",   "client": "mongosh", "containerPort": 27021 },
    "mongo-tls":         { "host": "${h}", "port": 27019, "user": "caliope", "password": "caliopepass", "engine": "mongodb",  "container": "caliope-mongo-tls",   "client": "mongosh", "containerPort": 27019, "tls": true, "ca": "${mongo_ca}" },
    "mongo-tunnel":      { "host": "mongo", "port": 27017, "user": "caliope", "password": "caliopepass", "engine": "mongodb", "container": "caliope-mongo", "client": "mongosh", "containerPort": 27017, "sshHost": "${h}", "sshPort": 2222, "sshUser": "tunneluser", "sshPassword": "tunnel123" },
    "redis":             { "host": "${h}", "port": 6379,  "user": "",        "password": "caliopepass", "engine": "redis",    "container": "caliope-redis",           "client": "redis-cli", "containerPort": 6379 },
    "redis-tls":         { "host": "${h}", "port": 6380,  "user": "",        "password": "caliopepass", "engine": "redis",    "container": "caliope-redis-tls",       "client": "redis-cli", "containerPort": 6380, "tls": true, "ca": "${redis_ca}" },
    "redis-cluster-1":   { "host": "${h}", "port": 7001,  "user": "",        "password": "caliopepass", "engine": "redis",    "container": "caliope-redis-cluster-1", "client": "redis-cli", "containerPort": 7001 },
    "redis-tunnel":      { "host": "redis-tunnel", "port": 6379, "user": "", "password": "caliopepass", "engine": "redis",    "container": "caliope-redis-tunnel",    "client": "redis-cli", "containerPort": 6379, "sshHost": "${h}", "sshPort": 2222, "sshUser": "tunneluser", "sshPassword": "tunnel123" }
  }
}
SERVERS_EOF
}

# ==============================================================================
#  Command handlers
# ==============================================================================

do_stop() {
  require_docker
  if [[ ! -f "${COMPOSE_FILE}" ]]; then
    echo "Nothing to stop: ${COMPOSE_FILE} does not exist yet."
    exit 0
  fi
  cd "${WORKDIR}"
  if [[ -n "$STOP_SERVICES" ]]; then
    local -a list=()
    IFS=',' read -r -a list <<< "$STOP_SERVICES"
    echo "Stopping ${list[*]} on $(target_label) (volumes preserved)..."
    docker compose -f "${COMPOSE_FILE}" stop "${list[@]}"
    echo "Done. The rest of the testbed on $(target_label) keeps running."
    return 0
  fi
  echo "Stopping Caliope testbed containers on $(target_label) (volumes preserved)..."
  docker compose -f "${COMPOSE_FILE}" stop
  echo "Done. Use --fresh for a full system-wide Docker cleanup (asks confirmation)."
}

# El nombre del proyecto de compose, sin hablar con Docker: sale de `config`, y si
# eso falla, del directorio, como lo calcula compose.
compose_project() {
  local project
  project="$(docker compose -f "${COMPOSE_FILE}" config --format json 2>/dev/null \
             | python3 -c 'import json,sys; print(json.load(sys.stdin)["name"])' 2>/dev/null)"
  [[ -n "$project" ]] || project="$(basename "${WORKDIR}" | tr '[:upper:]' '[:lower:]' | tr -cd 'a-z0-9_-')"
  printf '%s\n' "$project"
}

do_status() {
  require_docker
  if [[ ! -f "${COMPOSE_FILE}" ]]; then
    echo "The testbed has not been extracted yet. Run without --status first."
    exit 0
  fi
  cd "${WORKDIR}"
  echo "Caliope testbed on $(target_label):"
  # UNA llamada a la API, no `docker compose ps`: ése inspecciona cada contenedor en
  # paralelo, y con `--remote` cada inspección es una sesión más sobre la conexión SSH
  # multiplexada. Pasadas las diez de `MaxSessions` la PC la rechaza («Session open
  # refused by peer») y sus `PerSourcePenalties` cortan las siguientes con
  # `kex_exchange_identification`. Con los 34 contenedores de la PC fallaba cuatro de
  # cuatro veces seguidas (plan 33, tarea 38, 2026-10-08); es lo mismo que la tarea 2
  # arregló en `--only`. Con `-a`, para que un nodo parado se vea parado y no ausente.
  local project rc=0 rows
  project="$(compose_project)"
  rows="$(docker ps -a --filter "label=com.docker.compose.project=${project}" \
            --format '{{.Names}}\t{{.Label "com.docker.compose.service"}}\t{{.Status}}\t{{.Ports}}' 2>&1)" || rc=$?
  if (( rc != 0 )); then
    echo "Docker on $(target_label) did not answer (docker ps exited ${rc}); try again:"
    printf '%s\n' "$rows" | tail -n 3 | sed 's/^/  /'
    exit 1
  fi
  if ! grep -q . <<< "$rows"; then
    echo "  (no containers)"
    return 0
  fi
  { printf 'NAME\tSERVICE\tSTATUS\tPORTS\n'; sort <<< "$rows"; } | column -t -s $'\t'
  echo ""
  replication_health || true
}

# Sólo la salud de la replicación, con su código: 0 si todo lo que corre replica,
# 1 si algún grupo arriba está roto. Lo llama `testbed-check.sh` antes de cada toma
# de vídeo o tanda de capturas (2026-10-13): una toma se grabó entera con
# `replica4` sin replicar y `db` sin responder, y nada lo dijo hasta mirar los
# fotogramas.
do_health() {
  require_docker
  if [[ ! -f "${COMPOSE_FILE}" ]]; then
    echo "The testbed has not been extracted yet. Run without --health first."
    exit 0
  fi
  cd "${WORKDIR}"
  replication_health
}

# Una línea por miembro de cada grupo de replicación que esté corriendo, leída del
# propio servidor —no del informe del último arranque, que envejece—: MariaDB y
# MySQL por `SHOW … STATUS`, el conjunto de Mongo por `rs.status()`, ClickHouse por
# `system.replicas` de cada nodo, el clúster de Redis por `CLUSTER INFO` y la réplica
# de PostgreSQL por `pg_stat_wal_receiver`. Un grupo parado no se mira. Devuelve 1
# si algo de lo que corre no replica.
replication_health() {
  local project running bad=0 r st io sq src lag err out
  project="$(compose_project)"
  running="$(docker ps --filter "label=com.docker.compose.project=${project}" \
               --format '{{.Label "com.docker.compose.service"}}' 2>/dev/null || true)"
  is_up() { grep -qx "$1" <<< "$running"; }
  field() { awk -v k="$1" '$1 == k":" { $1 = ""; sub(/^ /, ""); print; exit }' <<< "$2"; }

  printf "  %sReplication%s\n" "$C_BOLD" "$C_RESET"
  local any=0
  for r in replica1 replica2 replica3 replica4 db; do
    is_up "$r" || continue
    any=1
    st="$(docker exec "mariadb-${r}" mariadb -uroot -prootpass -e 'SHOW SLAVE STATUS\G' 2>/dev/null || true)"
    io="$(field Slave_IO_Running "$st")"; sq="$(field Slave_SQL_Running "$st")"
    src="$(field Master_Host "$st")"; lag="$(field Seconds_Behind_Master "$st")"
    err="$(field Last_IO_Error "$st")"; [[ -z "$err" ]] && err="$(field Last_SQL_Error "$st")"
    if [[ "$io" == "Yes" && "$sq" == "Yes" ]]; then
      ok "MariaDB ${r} ← ${src} · lag ${lag:-?} s"
    else
      fail "MariaDB ${r} ← ${src:-?} · IO ${io:-?}, SQL ${sq:-?}${err:+ · ${err:0:110}}"; bad=1
    fi
  done
  for r in mysql-replica1 mysql-replica2; do
    is_up "$r" || continue
    any=1
    st="$(docker exec "$r" mysql -uroot -prootpass -e 'SHOW REPLICA STATUS\G' 2>/dev/null || true)"
    io="$(field Replica_IO_Running "$st")"; sq="$(field Replica_SQL_Running "$st")"
    src="$(field Source_Host "$st")"; lag="$(field Seconds_Behind_Source "$st")"
    err="$(field Last_IO_Error "$st")"; [[ -z "$err" ]] && err="$(field Last_SQL_Error "$st")"
    if [[ "$io" == "Yes" && "$sq" == "Yes" ]]; then
      ok "MySQL ${r} ← ${src} · lag ${lag:-?} s"
    else
      fail "MySQL ${r} ← ${src:-?} · IO ${io:-?}, SQL ${sq:-?}${err:+ · ${err:0:110}}"; bad=1
    fi
  done
  if is_up postgres-replica; then
    any=1
    out="$(docker exec postgres-replica psql -U caliope -d postgres -tAc "SELECT status FROM pg_stat_wal_receiver" 2>/dev/null | tr -d '\r' || true)"
    if [[ "$out" == "streaming" ]]; then ok "PostgreSQL postgres-replica ← postgres-primary · streaming"
    else fail "PostgreSQL postgres-replica · WAL receiver: ${out:-not running}"; bad=1; fi
  fi
  if is_up mongo-rs; then
    any=1
    out="$(docker exec caliope-mongo-rs mongosh --quiet --port 27018 --eval \
           'rs.status().members.map(m => m.name.split(":").pop() + " " + m.stateStr).join(", ")' 2>/dev/null | tail -1 || true)"
    if [[ "$out" == *PRIMARY* && "$out" != *"(not reachable"* && "$out" != *DOWN* && "$out" != *RECOVERING* ]]; then
      ok "MongoDB rs0 · ${out}"
    else
      fail "MongoDB rs0 · ${out:-no answer}"; bad=1
    fi
  fi
  for r in clickhouse-r1 clickhouse-r2 clickhouse-r3 clickhouse-r4; do
    is_up "$r" || continue
    any=1
    out="$(docker exec "caliope-${r}" clickhouse-client --user caliope --password caliopepass -q \
           "SELECT count(), countIf(is_readonly OR is_session_expired), max(absolute_delay) FROM system.replicas FORMAT TSV" 2>/dev/null || true)"
    local tables broken delay
    IFS=$'\t' read -r tables broken delay <<< "$out"
    if [[ -n "$tables" && "${broken:-1}" == "0" ]]; then
      ok "ClickHouse ${r} · ${tables} replicated table(s), delay ${delay:-0} s"
    else
      fail "ClickHouse ${r} · ${broken:-?} of ${tables:-?} table(s) read-only or without Keeper"; bad=1
    fi
  done
  if is_up redis-cluster-1; then
    any=1
    out="$(docker exec caliope-redis-cluster-1 redis-cli -p 7001 -a caliopepass --no-auth-warning cluster info 2>/dev/null \
           | tr -d '\r' | awk -F: '/^cluster_state|^cluster_known_nodes/ {printf "%s=%s ", $1, $2}' || true)"
    if [[ "$out" == *"cluster_state=ok"* ]]; then ok "Redis cluster · ${out% }"
    else fail "Redis cluster · ${out:-no answer}"; bad=1; fi
  fi
  [[ "$any" == "1" ]] || step "No replication group is running."
  return "$bad"
}

do_logs() {
  require_docker
  if [[ ! -f "${COMPOSE_FILE}" ]]; then
    echo "The testbed has not been extracted yet. Run without --logs first."
    exit 0
  fi
  cd "${WORKDIR}"
  docker compose -f "${COMPOSE_FILE}" logs -f
}

# ── MongoDB sample data — el «Sakila» de Mongo ───────────────────────────────
# Mismo trato que Sakila: lo ofrece el asistente, se descarga en el momento (sin
# copia vendida ni caché) y se carga por `docker exec`, porque la imagen mongo:8.2
# ya trae mongorestore y mongoimport y no hace falta instalar nada en el Mac.
#
# Dos fuentes, y el orden importa:
#
#   1. El archivo de Atlas (380 MB) es un `mongodump --archive` de verdad con las
#      nueve bases `sample_*` — es el equivalente exacto de Sakila y lo que de
#      verdad ejercita el explorador de documentos: Decimal128 en
#      sample_analytics, documentos hondamente anidados en sample_airbnb, arrays
#      de 1536 floats en sample_mflix.embedded_movies, GeoJSON con 2dsphere en
#      sample_geospatial. Pero su URL es la que Atlas usa por dentro, **no un
#      endpoint documentado** como el downloads.mysql.com de Sakila: funciona
#      hoy y puede desaparecer sin aviso.
#   2. Por eso hay reserva, y en hosts oficiales y documentados de MongoDB:
#      zips.json (3 MB) y primer-dataset.json (11 MB). Se cargan con los mismos
#      nombres que usa Atlas para esos datos, así que un perfil guardado sigue
#      encontrando algo donde miraba.
#
# Se carga en `mongo` y en el primario de `rs0`, si se levantaron; los otros dos
# miembros del conjunto lo reciben por replicación. Lo hace `job_atlas`, abajo.
# Medido el 2026-08-09 (8.0.26 → 8.2.12): mongorestore avisa de que el archivo
# salió de otra versión, y restaura los 425 367 documentos sin un fallo.
# ── Pagila — el «Sakila» de PostgreSQL ───────────────────────────────────────
# Mismo trato que Sakila y que los datos de ejemplo de Mongo: lo ofrece el
# asistente, se descarga en el momento y se carga por `docker exec`, porque la
# imagen ya trae `psql` y no hace falta instalar nada en el Mac.
#
# Pagila es el port oficial de Sakila a PostgreSQL: el mismo videoclub, las
# mismas tablas y los mismos recuentos —1000 películas, 200 actores, 16 044
# alquileres—, así que es lo único que da la propiedad que hace útil a Sakila
# aquí: **los mismos datos en los dos motores**, para comparar lado a lado lo
# que hace Calíope. Y encima trae lo que MySQL no puede enseñar igual: búsqueda
# de texto con `tsvector` y su disparador, dos dominios, tipo `ENUM` nativo,
# `payment` particionada por rango y dos vistas materializadas.
#
# ── POR QUÉ LA VERSIÓN VA FIJADA ─────────────────────────────────────────────
# `master` (pagila-v4.1.0) **exige PostgreSQL 18** y este testbed corre 17. No
# es una incompatibilidad de matiz: son tres, y ninguna avisa antes de romper.
#   · `uuidv7()` como DEFAULT en 58 columnas — función de PG 18.
#   · `GENERATED ALWAYS AS (…) VIRTUAL` — sintaxis de PG 18.
#   · `CREATE EXTENSION vector` (pgvector), que no traen ni `postgres:17` ni
#     `imresamu/postgis:17-3.5`.
# Se probó: `pagila-v4.0.0` tampoco sirve —lleva los `uuidv7()`—, y la carga
# muere en la primera tabla dejando la base creada y vacía, que se ve igual que
# un fallo del cliente.
#
# Por eso se fija `pagila-v3.1.0`, la última etiqueta que carga limpia en
# Postgres 17, comprobada de extremo a extremo el 2026-08-18 contra un
# `postgres:17` recién creado. Lo que se pierde respecto de v4 son las columnas
# `uuid`, la tabla de embeddings y la columna generada virtual — nada que el
# resto del testbed no cubra ya por otro lado.
#
# **Cuando el testbed suba a Postgres 18, esta versión se sube con él**: la
# etiqueta se cambia aquí y las tres incompatibilidades desaparecen solas. Fijar
# una versión sin decir cuándo deja de valer es como no fijarla.
#
# ── DÓNDE SE CARGA ───────────────────────────────────────────────────────────
# En `postgres-gis` y en `postgres-primary`. En la réplica **no**: es un standby
# físico, no acepta escrituras, y la base entera le llega sola por streaming —
# incluido el `CREATE DATABASE`. Escribirle ahí sería el error que el nodo
# existe para detectar.
#
# ── EL ROL `postgres` ────────────────────────────────────────────────────────
# El volcado asigna la propiedad de 57 objetos a un rol `postgres` que aquí no
# existe: en Postgres el superusuario se llama como diga `POSTGRES_USER`, y en
# este testbed es `caliope`. Se crea el rol antes de cargar en vez de reescribir
# 57 líneas del volcado — transformar un dump ajeno es la clase de arreglo que
# se pudre en la siguiente versión de Pagila.
PAGILA_VERSION="pagila-v3.1.0"

# ══════════════════════════════════════════════════════════════════════════════
#  El asistente: qué se levanta, motor por motor (David, 2026-10-13)
# ══════════════════════════════════════════════════════════════════════════════
#
# Con terminal y sin argumentos, el lanzador ya no levanta los cuarenta y tantos
# contenedores de golpe: pregunta. Primero qué motores; después, de cada uno,
# el mínimo, todo o a medida; y al final, los datos de ejemplo de los motores
# elegidos. Cada respuesta trae por omisión la de la vez anterior
# (`selection.conf`), y la primera pantalla ofrece repetirla con Enter.
#
# Sin terminal no pregunta nada y levanta todo, como siempre: la sesión en la
# nube, `corrida.sh` y las tandas de QA lo lanzan así, y tienen que seguir
# recibiendo el testbed entero. `--all` hace lo mismo con terminal. `--only`,
# `--stop` y los demás no pasan por aquí.
#
# Las piezas se eligen por OPCIÓN, no por contenedor: una opción es lo que tiene
# sentido levantar junto —el conjunto de réplicas son sus tres miembros y su
# preparación—. Los contenedores de preparación no se preguntan nunca, y las
# dependencias se resuelven solas: un nodo de túnel trae el bastión, y el shard 2
# de ClickHouse trae el par. La memoria de cada opción es la medida en la PC del
# testbed el 2026-10-13 (`docker stats`), redondeada hacia arriba.
#
# Bash 3.2: es el de macOS, y con él corre este lanzador al abrirlo desde el
# Finder. Así que nada de arrays asociativos, y ningún array vacío se expande
# con `set -u` sin su guarda.

# opción|motor|qué es|servicios|MB de memoria|requiere|mínimo
CATALOG=(
  "mariadb-primary|mariadb|Primary, read / write, seeded (3311)|primary setup|350||1"
  "mariadb-db|mariadb|db: a replica reachable only through the SSH bastion (2222)|db bastion|350||1"
  "mariadb-replicas|mariadb|Two replicas reading from the primary (3312, 3313)|replica1 replica2|700||0"
  "mariadb-cascade|mariadb|A cascade: replica3 (3314), and replica4 (3315) reading from it|replica3 replica4|700||0"
  "mariadb-standalone|mariadb|A standalone node, outside replication (3316)|standalone|100||0"
  "mariadb-load|mariadb|Load generator: traffic on the group, slow queries on every node|loadgen|220||0"
  "mysql-latest|mysql|MySQL latest, seeded (3320)|mysql-latest|450||1"
  "mysql8-gis|mysql|MySQL 8.0 with the spatial databases gis and gis_cartesian (3321)|mysql8-gis|400||0"
  "mysql-group|mysql|MySQL 8.4 LTS replication group: primary (3322), replicas (3323, 3324), light load|mysql-primary mysql-replica1 mysql-replica2 mysql-setup mysql-loadgen|1050||0"
  "postgres-gis|postgres|PostGIS 17 working node: databases demo and rrhh (3330)|postgres-gis|60||1"
  "postgres-pair|postgres|Streaming replication: primary (3331) and physical standby (3332)|postgres-primary postgres-replica|100||0"
  "postgres-load|postgres|Slow-query generator on postgres-gis|pg-loadgen|10||0"
  "sqlserver|sqlserver|SQL Server 2025 with the demo database (3340)|sqlserver|900||1"
  "sqlserver-restore|sqlserver|An empty SQL Server: the restore target (3341)|sqlserver-restore|800||0"
  "oracle|oracle|Oracle AI Database 26ai Free with the demo schemas (1521)|oracle|2200||1"
  "oracle-tls|oracle|TCPS only, signed by the testbed's CA (2484)|oracle-tls|2200||0"
  "oracle-tunnel|oracle|The oracle node, also through the SSH bastion (2222)|bastion|10|oracle|0"
  "clickhouse|clickhouse|ClickHouse 26.8 LTS with the demo database (8123)|clickhouse|370||1"
  "clickhouse-tls|clickhouse|HTTPS only, with its own CA (8443)|clickhouse-tls|340||0"
  "clickhouse-tunnel|clickhouse|Reachable only through the SSH bastion|clickhouse-tunnel bastion|330||0"
  "clickhouse-pair|clickhouse|Replicated pair caliope_par: Keeper, r1 (8124) and r2 (8125)|clickhouse-keeper clickhouse-r1 clickhouse-r2 clickhouse-replica-setup|800||0"
  "clickhouse-shard2|clickhouse|Second shard of caliope_2x2: r3 (8126) and r4 (8127)|clickhouse-r3 clickhouse-r4 clickhouse-shard-setup|700|clickhouse-pair|0"
  "mongo|mongodb|MongoDB 8.2, authenticated, seeded (27017)|mongo|120||1"
  "mongo-tls|mongodb|TLS only + SCRAM, with its own CA (27019)|mongo-tls|140||0"
  "mongo-rs|mongodb|Replica set rs0 with three members (27018, 27020, 27021)|mongo-rs mongo-rs2 mongo-rs3 mongo-rs-setup|400||0"
  "mongo-load|mongodb|Slow-query generator on the authenticated node|mongo-loadgen|150||0"
  "redis|redis|Redis 7.4, password only, database 0 seeded (6379)|redis redis-seed|10||1"
  "redis-tls|redis|TLS only, with its own CA (6380)|redis-tls|10||0"
  "redis-tunnel|redis|Reachable only through the SSH bastion|redis-tunnel bastion|10||0"
  "redis-cluster|redis|A cluster of six nodes: three primaries, three replicas (7001-7006)|redis-cluster-1 redis-cluster-2 redis-cluster-3 redis-cluster-4 redis-cluster-5 redis-cluster-6 redis-cluster-setup|40||0"
  "redis-load|redis|Load generator: hits, misses and a slow EVAL every 4 s|redis-loadgen|10||0"
)

# motor|nombre|lo que se descarga
ENGINES=(
  "mariadb|MariaDB|image mariadb:latest, 0.5 GB"
  "mysql|MySQL|images mysql 8.0, 8.4 and latest, about 1.2 GB each"
  "postgres|PostgreSQL|image imresamu/postgis 17, 0.9 GB"
  "sqlserver|SQL Server|image mssql/server 2025, 2.4 GB · Intel only: emulated on Apple silicon"
  "oracle|Oracle Database|image database/free 26ai, 13.8 GB · Intel and Apple silicon"
  "clickhouse|ClickHouse|image clickhouse-server 26.8, 1.2 GB"
  "mongodb|MongoDB|image mongo 8.2, 1.3 GB"
  "redis|Redis|image redis 7.4, 0.2 GB"
)

# conjunto|motores que lo reciben|nombre|qué es
DATASETS=(
  "sakila|mariadb mysql|Sakila|MySQL's sample film-rental store: 16 tables, views, triggers and routines (~1 MB)"
  "pagila|postgres|Pagila|Sakila ported to PostgreSQL: same data, plus tsvector, domains and partitions (~3 MB)"
  "atlas|mongodb|Atlas sample data|MongoDB's nine sample_* databases: Decimal128, GeoJSON, nested documents (380 MB)"
)

SELECTION_FILE="${WORKDIR}/selection.conf"
WIZARD_ENGINES=""
WIZARD_DATASETS=""

# Las variables por motor van por `eval` sobre su nombre: `SEL_MODE_mariadb`,
# `SEL_ITEMS_mariadb`. Los identificadores de motor son letras sueltas, así que
# el `eval` no recibe nada que haya escrito el usuario.
sel_get() { eval "printf '%s' \"\${SEL_${1}_${2}:-}\""; }
sel_set() { eval "SEL_${1}_${2}=\"\$3\""; }

load_selection() {
  [[ -f "$SELECTION_FILE" ]] || return 1
  local key value e
  while IFS='=' read -r key value; do
    case "$key" in
      engines)  WIZARD_ENGINES="$value" ;;
      datasets) WIZARD_DATASETS="$value" ;;
      mode_*)   e="${key#mode_}";  [[ "$e" =~ ^[a-z]+$ ]] && sel_set MODE "$e" "$value" ;;
      items_*)  e="${key#items_}"; [[ "$e" =~ ^[a-z]+$ ]] && sel_set ITEMS "$e" "$value" ;;
    esac
  done < "$SELECTION_FILE"
  [[ -n "$WIZARD_ENGINES" ]]
}

save_selection() {
  local e
  {
    echo "# Written by caliope-testbed.command: the answers of the last wizard run."
    echo "# Delete this file to start from the defaults again."
    echo "engines=${WIZARD_ENGINES}"
    echo "datasets=${WIZARD_DATASETS}"
    for e in $WIZARD_ENGINES; do
      echo "mode_${e}=$(sel_get MODE "$e")"
      echo "items_${e}=$(sel_get ITEMS "$e")"
    done
  } > "$SELECTION_FILE"
}

# Los campos de una opción del catálogo, por su identificador.
catalog_row() {
  local row
  for row in "${CATALOG[@]}"; do
    [[ "${row%%|*}" == "$1" ]] && { printf '%s' "$row"; return 0; }
  done
  return 1
}
catalog_field() { catalog_row "$1" | cut -d'|' -f"$2"; }

engine_items() {   # motor, 1 = mínimas / 0 = opcionales
  local row
  for row in "${CATALOG[@]}"; do
    [[ "$(printf '%s' "$row" | cut -d'|' -f2)" == "$1" && "${row##*|}" == "$2" ]] && printf '%s\n' "${row%%|*}"
  done
}

engine_field() {
  local row
  for row in "${ENGINES[@]}"; do
    [[ "${row%%|*}" == "$1" ]] && { printf '%s' "$row" | cut -d'|' -f"$2"; return 0; }
  done
}

# Las opciones que quedan elegidas para un motor según su modo.
engine_chosen_items() {
  local e="$1" mode items
  mode="$(sel_get MODE "$e")"
  items="$(sel_get ITEMS "$e")"
  engine_items "$e" 1
  case "$mode" in
    full)   engine_items "$e" 0 ;;
    custom) local it; for it in $items; do printf '%s\n' "$it"; done ;;
  esac
}

# Todas las opciones elegidas, con lo que requieren, sin repetir.
chosen_items() {
  local e it req out=""
  for e in $WIZARD_ENGINES; do
    for it in $(engine_chosen_items "$e"); do
      req="$(catalog_field "$it" 6)"
      [[ -n "$req" && " $out " != *" $req "* ]] && out="$out $req"
      [[ " $out " != *" $it "* ]] && out="$out $it"
    done
  done
  printf '%s' "${out# }"
}

# De las opciones a los servicios de compose, en el orden del catálogo.
services_of_items() {
  local it svc out=""
  for it in $1; do
    for svc in $(catalog_field "$it" 4); do
      [[ " $out " != *" $svc "* ]] && out="$out $svc"
    done
  done
  printf '%s' "${out# }"
}

ram_of_items() {
  local it total=0
  for it in $1; do total=$(( total + $(catalog_field "$it" 5) )); done
  printf '%s' "$total"
}

fmt_gb() { awk -v m="$1" 'BEGIN { printf "%.1f GB", m / 1024 }'; }

# Lo que tiene el Docker de destino: la memoria y los núcleos que ve el motor, que
# en el Mac son los de la máquina virtual de Docker Desktop, no los del Mac.
DOCKER_MEM_MB=0
DOCKER_CPUS="?"
read_docker_resources() {
  local info
  info="$(docker info --format '{{.MemTotal}} {{.NCPU}}' 2>/dev/null || true)"
  if [[ -n "$info" ]]; then
    DOCKER_MEM_MB=$(( ${info%% *} / 1048576 ))
    DOCKER_CPUS="${info##* }"
  fi
}

ask() {   # pregunta, valor por omisión → REPLY_VALUE
  local reply=""
  read -r -p "$1" reply || reply=""
  REPLY_VALUE="${reply:-$2}"
}

wizard_title() {
  echo ""
  printf '  %s%s%s%s\n' "$C_BOLD" "$C_AMBER" "$1" "$C_RESET"
  [[ -n "${2:-}" ]] && printf '  %s%s%s\n' "$C_DIM" "$2" "$C_RESET"
  return 0
}

print_selection_summary() {
  local e it mode items
  for e in $WIZARD_ENGINES; do
    mode="$(sel_get MODE "$e")"
    # Lo que se eligió, dicho por su nombre corto (lo de antes de los dos puntos o
    # del paréntesis): «custom» a secas no dice qué se va a levantar.
    items=""
    for it in $(engine_chosen_items "$e"); do
      items="${items:+$items, }$(catalog_field "$it" 3 | sed 's/[:(].*//; s/ *$//')"
    done
    case "${mode:-min}" in
      full)   mode="everything" ;;
      custom) mode="custom" ;;
      *)      mode="minimum" ;;
    esac
    printf '    %s%-11s%s %s%s:%s %s\n' "$C_BOLD" "$(engine_field "$e" 2)" "$C_RESET" "$C_DIM" "$mode" "$C_RESET" "$items"
  done
  if [[ -n "$WIZARD_DATASETS" ]]; then
    printf '    %s%-11s%s %s\n' "$C_BOLD" "Datasets" "$C_RESET" "$WIZARD_DATASETS"
  fi
}

run_wizard() {
  read_docker_resources
  local have_prev=0
  load_selection && have_prev=1

  wizard_title "What should the testbed start?" \
    "Docker on $(target_label): $( [[ "$DOCKER_MEM_MB" -gt 0 ]] && fmt_gb "$DOCKER_MEM_MB" || echo '? GB') of memory, ${DOCKER_CPUS} CPUs"

  if [[ "$have_prev" == "1" ]]; then
    echo ""
    echo "  Last time:"
    print_selection_summary
    echo ""
    ask "  Press Enter to start the same, or type c to change it: " ""
    case "$REPLY_VALUE" in
      [cC]*) ;;
      *) wizard_confirm; return ;;
    esac
  fi

  # 1. Los motores.
  wizard_title "1. Engines" "Type the numbers of the ones you want, separated by spaces or commas, or a for all."
  local i=1 row e def_nums="" nums
  for row in "${ENGINES[@]}"; do
    e="${row%%|*}"
    printf '    %s%d%s  %-11s %s%s%s\n' "$C_AMBER" "$i" "$C_RESET" "$(engine_field "$e" 2)" "$C_DIM" "$(engine_field "$e" 3)" "$C_RESET"
    if [[ -z "$WIZARD_ENGINES" || " $WIZARD_ENGINES " == *" $e "* ]]; then def_nums="$def_nums $i"; fi
    i=$(( i + 1 ))
  done
  printf '    %s%s%s  %s\n' "$C_AMBER" "a" "$C_RESET" "All engines"
  def_nums="${def_nums# }"
  ask "  Engines [${def_nums}]: " "$def_nums"
  # Comas o espacios, da igual: `1,3 5` son tres motores. Y `a` (o `all`) son
  # todos, que escritos uno a uno eran siete números.
  nums="${REPLY_VALUE//,/ }"
  case "$(printf '%s' "$nums" | tr -d ' ' | tr '[:upper:]' '[:lower:]')" in
    a|all) nums="$(seq 1 ${#ENGINES[@]} | tr '\n' ' ')" ;;
  esac
  WIZARD_ENGINES=""
  local n
  for n in $nums; do
    [[ "$n" =~ ^[0-9]+$ ]] || continue
    (( n >= 1 && n <= ${#ENGINES[@]} )) || continue
    e="${ENGINES[$(( n - 1 ))]%%|*}"
    [[ " $WIZARD_ENGINES " == *" $e "* ]] || WIZARD_ENGINES="$WIZARD_ENGINES $e"
  done
  WIZARD_ENGINES="${WIZARD_ENGINES# }"
  if [[ -z "$WIZARD_ENGINES" ]]; then
    echo "  No engine chosen: nothing was started."
    exit 0
  fi

  # 2. De cada motor, cuánto.
  local step_n=2 it mode def_mode label ram picked def_yes
  for e in $WIZARD_ENGINES; do
    wizard_title "${step_n}. $(engine_field "$e" 2)" "$(engine_field "$e" 3)"
    echo "    Always:"
    for it in $(engine_items "$e" 1); do
      printf '      %s•%s %s  %s~%s MB%s\n' "$C_GREEN" "$C_RESET" "$(catalog_field "$it" 3)" "$C_DIM" "$(catalog_field "$it" 5)" "$C_RESET"
    done
    echo "    Optional:"
    for it in $(engine_items "$e" 0); do
      printf '      %s+%s %s  %s~%s MB%s\n' "$C_AMBER" "$C_RESET" "$(catalog_field "$it" 3)" "$C_DIM" "$(catalog_field "$it" 5)" "$C_RESET"
    done
    def_mode="$(sel_get MODE "$e")"
    case "$def_mode" in full) def_mode=f ;; custom) def_mode=c ;; *) def_mode=m ;; esac
    ask "  ${C_BOLD}[m]${C_RESET}inimum (recommended), ${C_BOLD}[f]${C_RESET}ull or ${C_BOLD}[c]${C_RESET}ustom? [${def_mode}]: " "$def_mode"
    case "$REPLY_VALUE" in
      [fF]*) sel_set MODE "$e" full ;;
      [cC]*)
        sel_set MODE "$e" custom
        picked=""
        for it in $(engine_items "$e" 0); do
          def_yes=n
          [[ " $(sel_get ITEMS "$e") " == *" $it "* ]] && def_yes=y
          ask "      $(catalog_field "$it" 3)? [y/n, ${def_yes}]: " "$def_yes"
          [[ "$REPLY_VALUE" == [yY]* ]] && picked="$picked $it"
        done
        sel_set ITEMS "$e" "${picked# }"
        ;;
      *) sel_set MODE "$e" min ;;
    esac
    step_n=$(( step_n + 1 ))
  done

  # 3. Los datos de ejemplo de los motores elegidos.
  local ds ds_id ds_engines ds_name ds_what any=0 def_ds new_ds=""
  for ds in "${DATASETS[@]}"; do
    IFS='|' read -r ds_id ds_engines ds_name ds_what <<< "$ds"
    local fits=0 de
    for de in $ds_engines; do [[ " $WIZARD_ENGINES " == *" $de "* ]] && fits=1; done
    [[ "$fits" == "1" ]] || continue
    if [[ "$any" == "0" ]]; then
      wizard_title "${step_n}. Sample datasets" "Downloaded in the background while the containers start, loaded once their nodes are up."
      any=1
    fi
    def_ds=n
    [[ " $WIZARD_DATASETS " == *" $ds_id "* ]] && def_ds=y
    printf '    %s%s%s  %s%s%s\n' "$C_BOLD" "$ds_name" "$C_RESET" "$C_DIM" "$ds_what" "$C_RESET"
    ask "    Download and load ${ds_name}? [y/n, ${def_ds}]: " "$def_ds"
    [[ "$REPLY_VALUE" == [yY]* ]] && new_ds="$new_ds $ds_id"
  done
  WIZARD_DATASETS="${new_ds# }"

  wizard_confirm
}

# El resumen y la confirmación, que son lo mismo para «como la última vez».
wizard_confirm() {
  local items services ram count
  items="$(chosen_items)"
  services="$(services_of_items "$items")"
  ram="$(ram_of_items "$items")"
  count=$(printf '%s\n' $services | grep -Evc -- '(-setup|^setup)$' || true)

  wizard_title "Ready to start"
  print_selection_summary
  printf '    %s%-11s%s %s containers, about %s of memory' "$C_BOLD" "Total" "$C_RESET" "$count" "$(fmt_gb "$ram")"
  if [[ "$DOCKER_MEM_MB" -gt 0 ]]; then
    printf ' of the %s Docker has\n' "$(fmt_gb "$DOCKER_MEM_MB")"
    if (( ram * 100 > DOCKER_MEM_MB * 85 )); then
      warn "That is more than 85 % of what Docker has: some nodes may not start, or swap."
    fi
  else
    printf '\n'
  fi
  echo ""
  ask "  Start it? [Y/n]: " "y"
  if [[ "$REPLY_VALUE" == [nN]* ]]; then
    echo "  Nothing was started. Your answers were not saved."
    exit 0
  fi
  save_selection
  SELECTED_SERVICES="$services"
}

# ══════════════════════════════════════════════════════════════════════════════
#  Los datos de ejemplo, en segundo plano
# ══════════════════════════════════════════════════════════════════════════════
#
# Antes cada uno preguntaba, descargaba y cargaba con la terminal parada, y sólo
# con `--fresh`. Ahora los pregunta el asistente en cada arranque, y cada uno es
# un trabajo de segundo plano que hace tres cosas en orden:
#
#   1. Mira si ya está cargado en todos sus nodos —si están arriba— y, si lo
#      está, no descarga nada: «ya estaba». Atlas son 380 MB.
#   2. Descarga mientras Docker baja imágenes y levanta contenedores: es lo que
#      más tiempo ahorra.
#   3. Espera a que el arranque termine (`up.done`) y carga en cada nodo que no
#      lo tenga, esperando a que el nodo conteste.
#
# Ninguno escribe en la terminal: dejan su línea en `EVENTS_FILE` y su resultado
# en `<conjunto>.result`, y el informe final los recoge. Su registro va a
# `<conjunto>.log`, junto al de la corrida.
DATASET_DIR=""
DATASET_PIDS=""

dataset_log() { printf '%s\n' "$*" >> "${DATASET_DIR}/$1.log"; }

# ¿Contesta el nodo? Con techo de 90 s: la carga espera a que esté sano.
dataset_wait_node() {
  local svc="$1" probe="$2" i
  for (( i = 1; i <= 45; i++ )); do
    if docker compose -f "${COMPOSE_FILE}" exec -T "$svc" sh -c "$probe" >/dev/null 2>&1; then
      return 0
    fi
    sleep 2
  done
  return 1
}

sakila_targets() {
  local t
  for t in primary:mariadb standalone:mariadb mysql-latest:mysql mysql8-gis:mysql mysql-primary:mysql; do
    selected_service "${t%%:*}" && printf '%s\n' "$t"
  done
}
sakila_loaded_on() {   # servicio cliente
  local n
  n="$(docker compose -f "${COMPOSE_FILE}" exec -T "$1" "$2" -uroot -prootpass -N -B \
        -e "SELECT COUNT(*) FROM sakila.film" 2>/dev/null | tr -d '\r' || true)"
  [[ "$n" =~ ^[0-9]+$ ]] && (( n >= 1000 ))
}

pagila_targets() {
  local t
  for t in postgres-gis postgres-primary; do selected_service "$t" && printf '%s\n' "$t"; done
}
pagila_loaded_on() {
  local n
  n="$(docker compose -f "${COMPOSE_FILE}" exec -T "$1" psql -U caliope -d pagila -tAc \
        "SELECT COUNT(*) FROM public.film" 2>/dev/null | tr -d '\r' || true)"
  [[ "$n" =~ ^[0-9]+$ ]] && (( n >= 1000 ))
}

# mongo:<servicio>:<argumentos del cliente dentro de ese contenedor>
atlas_targets() {
  selected_service mongo    && printf '%s\n' "mongo:--username caliope --password caliopepass --authenticationDatabase admin"
  selected_service mongo-rs && printf '%s\n' "mongo-rs:--port 27018"
  return 0
}
atlas_loaded_on() {   # servicio argumentos
  local n
  n="$(docker compose -f "${COMPOSE_FILE}" exec -T "$1" mongosh --quiet $2 --eval \
        "db.getSiblingDB('sample_mflix').movies.estimatedDocumentCount() + db.getSiblingDB('sample_training').zips.estimatedDocumentCount()" \
        2>/dev/null | tr -d '\r' | tail -1 || true)"
  [[ "$n" =~ ^[0-9]+$ ]] && (( n > 0 ))
}

dataset_all_loaded() {
  local name="$1" t
  case "$name" in
    sakila) for t in $(sakila_targets); do sakila_loaded_on "${t%%:*}" "${t##*:}" || return 1; done ;;
    pagila) for t in $(pagila_targets); do pagila_loaded_on "$t" || return 1; done ;;
    atlas)
      local line
      while IFS= read -r line; do
        [[ -z "$line" ]] && continue
        atlas_loaded_on "${line%%:*}" "${line#*:}" || return 1
      done < <(atlas_targets)
      ;;
  esac
  return 0
}

# Espera a que el arranque termine. `up.failed` también termina la espera.
dataset_wait_up() {
  while [[ ! -f "${DATASET_DIR}/up.done" && ! -f "${DATASET_DIR}/up.failed" ]]; do sleep 2; done
  [[ -f "${DATASET_DIR}/up.done" ]]
}

dataset_result() { printf '%s|%s\n' "$2" "$3" > "${DATASET_DIR}/$1.result"; }

job_sakila() {
  set +e
  local log="${DATASET_DIR}/sakila.log" dir="${DATASET_DIR}/sakila"
  mkdir -p "$dir"
  if dataset_all_loaded sakila; then
    emit_event ok "Sakila was already loaded; nothing downloaded"
    dataset_result sakila "Sakila" "already loaded · nothing downloaded"
    return 0
  fi
  emit_event info "Sakila: downloading sakila-db.tar.gz…"
  if ! curl -fSL --max-time 180 "https://downloads.mysql.com/docs/sakila-db.tar.gz" \
         -o "${dir}/sakila-db.tar.gz" >> "$log" 2>&1 \
     || ! tar -xzf "${dir}/sakila-db.tar.gz" -C "$dir" >> "$log" 2>&1 \
     || [[ ! -f "${dir}/sakila-db/sakila-schema.sql" || ! -f "${dir}/sakila-db/sakila-data.sql" ]]; then
    emit_event warn "Sakila could not be downloaded; see ${log}"
    dataset_result sakila "Sakila" "not loaded — the download failed (${log})"
    return 0
  fi
  emit_event ok "Sakila downloaded; it loads as soon as its servers are up"
  dataset_wait_up || { dataset_result sakila "Sakila" "not loaded — the testbed did not start"; return 0; }

  local t svc client loaded=0 skipped=0 failed=0
  for t in $(sakila_targets); do
    svc="${t%%:*}"; client="${t##*:}"
    if ! dataset_wait_node "$svc" "$client -uroot -prootpass -e 'SELECT 1'"; then
      failed=$(( failed + 1 )); dataset_log sakila "${svc}: not answering after 90 s"; continue
    fi
    if sakila_loaded_on "$svc" "$client"; then skipped=$(( skipped + 1 )); continue; fi
    if docker compose -f "${COMPOSE_FILE}" exec -T "$svc" "$client" -uroot -prootpass \
         < "${dir}/sakila-db/sakila-schema.sql" >> "$log" 2>&1 \
       && docker compose -f "${COMPOSE_FILE}" exec -T "$svc" "$client" -uroot -prootpass sakila \
         < "${dir}/sakila-db/sakila-data.sql" >> "$log" 2>&1; then
      loaded=$(( loaded + 1 ))
    else
      failed=$(( failed + 1 )); dataset_log sakila "${svc}: rejected the schema or the data"
    fi
  done
  rm -rf "$dir"
  local text="loaded on ${loaded} server(s)"
  (( skipped > 0 )) && text="${text}, already there on ${skipped}"
  selected_service primary && text="${text} · the MariaDB replicas get it through replication"
  selected_service mysql-primary && text="${text} · the MySQL 8.4 replicas too"
  if (( failed > 0 )); then
    emit_event warn "Sakila: ${failed} server(s) failed; see ${log}"
    dataset_result sakila "Sakila" "${text} · ${failed} failed (${log})"
  else
    emit_event ok "Sakila loaded on ${loaded} server(s)"
    dataset_result sakila "Sakila" "$text"
  fi
}

job_pagila() {
  set +e
  local log="${DATASET_DIR}/pagila.log" dir="${DATASET_DIR}/pagila" f
  mkdir -p "$dir"
  if dataset_all_loaded pagila; then
    emit_event ok "Pagila was already loaded"
    dataset_result pagila "Pagila" "already loaded · nothing downloaded"
    return 0
  fi
  emit_event info "Pagila: downloading ${PAGILA_VERSION}…"
  local base="https://raw.githubusercontent.com/devrimgunduz/pagila/${PAGILA_VERSION}"
  for f in pagila-schema.sql pagila-data.sql; do
    if ! curl -fSL --max-time 600 "${base}/${f}" -o "${dir}/${f}" >> "$log" 2>&1; then
      emit_event warn "Pagila could not be downloaded; see ${log}"
      dataset_result pagila "Pagila" "not loaded — the download failed (${log})"
      return 0
    fi
  done
  emit_event ok "Pagila downloaded; it loads as soon as PostgreSQL is up"
  dataset_wait_up || { dataset_result pagila "Pagila" "not loaded — the testbed did not start"; return 0; }

  local svc loaded=0 skipped=0 failed=0
  local psql_base="psql -U caliope -v ON_ERROR_STOP=1 --quiet"
  for svc in $(pagila_targets); do
    if ! dataset_wait_node "$svc" "pg_isready -U caliope -q"; then
      failed=$(( failed + 1 )); dataset_log pagila "${svc}: not answering after 90 s"; continue
    fi
    if pagila_loaded_on "$svc"; then skipped=$(( skipped + 1 )); continue; fi
    # El rol al que el volcado asigna la propiedad (ver la cabecera de Pagila).
    docker compose -f "${COMPOSE_FILE}" exec -T "$svc" $psql_base -d postgres \
      -c "CREATE ROLE postgres SUPERUSER LOGIN PASSWORD 'caliopepass'" >/dev/null 2>&1
    if docker compose -f "${COMPOSE_FILE}" exec -T "$svc" $psql_base -d postgres \
         -c "DROP DATABASE IF EXISTS pagila WITH (FORCE)" \
         -c "CREATE DATABASE pagila OWNER caliope" >> "$log" 2>&1 \
       && docker compose -f "${COMPOSE_FILE}" exec -T "$svc" $psql_base -d pagila \
         < "${dir}/pagila-schema.sql" >> "$log" 2>&1 \
       && docker compose -f "${COMPOSE_FILE}" exec -T "$svc" $psql_base -d pagila \
         < "${dir}/pagila-data.sql" >> "$log" 2>&1; then
      loaded=$(( loaded + 1 ))
    else
      failed=$(( failed + 1 )); dataset_log pagila "${svc}: rejected the schema or the data"
    fi
  done
  rm -rf "$dir"
  local text="loaded on ${loaded} node(s)"
  (( skipped > 0 )) && text="${text}, already there on ${skipped}"
  selected_service postgres-primary && text="${text} · postgres-replica gets it by streaming"
  if (( failed > 0 )); then
    emit_event warn "Pagila: ${failed} node(s) failed; see ${log}"
    dataset_result pagila "Pagila" "${text} · ${failed} failed (${log})"
  else
    emit_event ok "Pagila loaded on ${loaded} node(s)"
    dataset_result pagila "Pagila" "$text"
  fi
}

job_atlas() {
  set +e
  local log="${DATASET_DIR}/atlas.log" dir="${DATASET_DIR}/atlas"
  mkdir -p "$dir"
  if dataset_all_loaded atlas; then
    emit_event ok "The Atlas sample data was already loaded"
    dataset_result atlas "Atlas" "already loaded · nothing downloaded"
    return 0
  fi
  emit_event info "Atlas sample data: downloading sampledata.archive (380 MB)…"
  local source="atlas"
  if ! curl -fSL --max-time 900 "https://atlas-education.s3.amazonaws.com/sampledata.archive" \
         -o "${dir}/sampledata.archive" >> "$log" 2>&1; then
    # La reserva de la cabecera: dos conjuntos oficiales y pequeños.
    source="fallback"
    emit_event warn "The Atlas archive is unreachable; falling back to zips and restaurants"
    curl -fSL --max-time 300 "https://media.mongodb.org/zips.json" -o "${dir}/zips.json" >> "$log" 2>&1
    curl -fSL --max-time 300 "https://raw.githubusercontent.com/mongodb/docs-assets/primer-dataset/primer-dataset.json" \
      -o "${dir}/restaurants.json" >> "$log" 2>&1
  fi
  emit_event ok "Atlas sample data downloaded; it loads as soon as MongoDB is up"
  dataset_wait_up || { dataset_result atlas "Atlas" "not loaded — the testbed did not start"; return 0; }

  local entry svc args loaded=0 skipped=0 failed=0
  while IFS= read -r entry; do
    [[ -z "$entry" ]] && continue
    svc="${entry%%:*}"; args="${entry#*:}"
    if ! dataset_wait_node "$svc" "mongosh --quiet $args --eval 'db.adminCommand({ping:1}).ok'"; then
      failed=$(( failed + 1 )); dataset_log atlas "${svc}: not answering after 90 s"; continue
    fi
    if atlas_loaded_on "$svc" "$args"; then skipped=$(( skipped + 1 )); continue; fi
    if [[ "$source" == "atlas" ]]; then
      # --drop reemplaza sólo las colecciones del archivo: caliope_demo se queda.
      docker compose -f "${COMPOSE_FILE}" exec -T "$svc" mongorestore $args --archive --drop --quiet \
        < "${dir}/sampledata.archive" >> "$log" 2>&1 \
        && loaded=$(( loaded + 1 )) || { failed=$(( failed + 1 )); dataset_log atlas "${svc}: rejected the archive"; }
    else
      docker compose -f "${COMPOSE_FILE}" exec -T "$svc" mongoimport $args --db sample_training --collection zips --drop --quiet \
          < "${dir}/zips.json" >> "$log" 2>&1 \
        && docker compose -f "${COMPOSE_FILE}" exec -T "$svc" mongoimport $args --db sample_restaurants --collection restaurants --drop --quiet \
          < "${dir}/restaurants.json" >> "$log" 2>&1 \
        && loaded=$(( loaded + 1 )) || { failed=$(( failed + 1 )); dataset_log atlas "${svc}: rejected the fallback data"; }
    fi
  done < <(atlas_targets)
  rm -rf "$dir"
  local what="the Atlas set (9 sample_* databases)"
  [[ "$source" == "fallback" ]] && what="the fallback set (sample_training.zips, sample_restaurants.restaurants)"
  local text="${what} on ${loaded} node(s)"
  (( skipped > 0 )) && text="${text}, already there on ${skipped}"
  text="${text} · caliope_demo untouched"
  if (( failed > 0 )); then
    emit_event warn "Atlas sample data: ${failed} node(s) failed; see ${log}"
    dataset_result atlas "Atlas" "${text} · ${failed} failed (${log})"
  else
    emit_event ok "Atlas sample data loaded on ${loaded} node(s)"
    dataset_result atlas "Atlas" "$text"
  fi
}

start_dataset_jobs() {
  [[ -n "$WIZARD_DATASETS" ]] || return 0
  local ds
  for ds in $WIZARD_DATASETS; do
    case "$ds" in
      sakila) [[ -n "$(sakila_targets)" ]] || continue; job_sakila & ;;
      pagila) [[ -n "$(pagila_targets)" ]] || continue; job_pagila & ;;
      atlas)  [[ -n "$(atlas_targets)" ]]  || continue; job_atlas & ;;
      *) continue ;;
    esac
    DATASET_PIDS="$DATASET_PIDS $!"
  done
}

# Al final, se espera a los trabajos que sigan cargando, con una línea viva y los
# avisos según llegan. Después cada resultado entra en el informe.
finish_dataset_jobs() {
  [[ -n "$DATASET_PIDS" ]] || return 0
  local pid running frames='|/-\' i=0 start elapsed
  start=$(date +%s)
  while :; do
    running=0
    for pid in $DATASET_PIDS; do kill -0 "$pid" 2>/dev/null && running=$(( running + 1 )); done
    drain_events
    (( running == 0 )) && break
    if [[ -t 1 ]]; then
      elapsed=$(( $(date +%s) - start ))
      printf '\r  %s%s%s  %-28s %3dm%02ds  %s%d still loading%s' "$C_AMBER" "${frames:$(( i % 4 )):1}" "$C_RESET" \
        "Sample datasets" "$(( elapsed / 60 ))" "$(( elapsed % 60 ))" "$C_DIM" "$running" "$C_RESET"
    fi
    i=$(( i + 1 ))
    sleep 1
  done
  [[ -t 1 ]] && printf '\r%-100s\r' " "
  for pid in $DATASET_PIDS; do wait "$pid" 2>/dev/null || true; done
  drain_events
  local f d_name d_text
  for f in "${DATASET_DIR}"/*.result; do
    [[ -f "$f" ]] || continue
    IFS='|' read -r d_name d_text < "$f"
    dataset_report "$d_name" "$d_text"
  done
}

# ══════════════════════════════════════════════════════════════════════════════
#  Archivo de conexiones importable en Calíope
# ══════════════════════════════════════════════════════════════════════════════
#
# Escribe los nodos como un archivo que Calíope importa desde
# Ajustes › Conexiones › Importar. **No es `testbed-servers.json`** y no lo
# sustituye: aquél vive en $WORKDIR, lo consume el arnés de QA y lleva
# `container`/`client` porque quien restaura es el cliente de dentro del
# contenedor. Éste es para la app, y el formato lo manda el importador.
#
# El formato es el de `ConnectionsFileFormat`: `{"profiles":[…],"groups":[…]}`,
# sin clave de versión. Se comprobó contra el decodificador
# (`ConnectionsPrefsTab.importFrom`), que acepta eso o un array pelado de
# perfiles, y **fusiona en vez de reemplazar** — importar no se lleva por
# delante las conexiones que ya haya.
#
# Cuatro decisiones que no se ven en el JSON y sí duelen si se cambian:
#
#   · **Va junto al `.command`, no en el directorio de trabajo.** Al abrirlo
#     desde el Finder, Terminal deja el `cwd` en la carpeta personal, así que
#     «la misma carpeta» sólo es predecible derivándola de `$0`. Si esa carpeta
#     no se puede escribir —el `.command` dentro de un DMG— cae a $WORKDIR y lo
#     dice, en vez de fallar en silencio.
#   · **Los UUID se generan en cada corrida.** Es a propósito y tiene un motivo
#     medido: al fusionar, el importador llama a `migrateLegacyFieldsIfNeeded()`
#     ANTES de reasignar el `id` que colisiona, así que un `id` repetido guarda
#     la contraseña en el Llavero bajo el identificador viejo y el perfil se
#     queda sin ella. Con identificadores nuevos cada vez eso no puede pasar. El
#     precio es que importar dos veces deja dos copias del grupo, y es el precio
#     correcto: perfiles de más se ven y se borran, una contraseña que falta se
#     depura contra el servidor equivocado.
#   · **La contraseña viaja en `passwordCipher`,** que pese al nombre es base64
#     del texto en claro y es el único campo que el importador sabe llevar al
#     Llavero. Aquí es aceptable porque `caliopepass` es una credencial de una
#     cama de pruebas local y ya está escrita en claro en este mismo archivo,
#     en `docker-compose.yml` y en la chuleta que imprime el launcher. **No es
#     un patrón para nada que no sea el testbed.**
#   · **La del túnel SSH no viaja, y no hay campo donde meterla:** la contraseña
#     y la passphrase de SSH sólo existen en el Llavero. Los dos perfiles de
#     túnel se importan enteros menos eso, y hay que teclear `tunnel123` la
#     primera vez.
write_connection_import_file() {
  local host_ip="$1"
  local ch_ca_path="${WORKDIR}/clickhouse-tls-ca.pem"
  [[ -f "$ch_ca_path" ]] || ch_ca_path="${CLICKHOUSE_TLS_DIR}/ca.crt"
  # La de `oracle-tls` (plan 63, tarea 3), igual: la del contenedor antes que la local.
  local ora_ca_path="${WORKDIR}/oracle-tls-ca.pem"
  [[ -f "$ora_ca_path" ]] || ora_ca_path="${ORACLE_TLS_DIR}/ca.crt"

  local out_dir fallback=0
  out_dir="${LAUNCHER_DIR}"
  if [[ -z "$out_dir" || ! -w "$out_dir" ]]; then
    out_dir="${WORKDIR}"
    fallback=1
  fi
  local out_file="${out_dir}/caliope-testbed-connections$(target_suffix).json"

  # `altHost` es la segunda dirección que prueba `connectionEndpoints`, y es lo
  # que hace que el MISMO archivo sirva en el Mac y en el iPad: principal
  # 127.0.0.1, alternativa la IP de la red local. Sin IP de LAN se queda vacío,
  # que es como el modelo dice «no hay alternativa».
  #
  # En remoto no hay alternativa que hacer: la dirección principal ya es la IP
  # de la otra máquina, y ésa la alcanzan igual el Mac y el iPad de la red.
  local main_host="127.0.0.1" alt_host="" group_name="Cama de pruebas"
  [[ "$host_ip" != "127.0.0.1" ]] && alt_host="$host_ip"
  if [[ "$TARGET" == "remote" ]]; then
    main_host="$host_ip"
    alt_host=""
    group_name="Cama de pruebas (remota)"
  fi

  # El bastión se declara por la IP de la LAN cuando la hay: el Mac también
  # alcanza su propia dirección de red, así que un solo valor vale para los dos
  # aparatos. Y hace falta un solo valor porque `connectionEndpoints` **ignora
  # `altHost` cuando el perfil usa túnel** — ahí la dirección que cuenta es la
  # del bastión, no la del servidor.
  local ssh_host="$host_ip"

  local group_id pw_cipher
  group_id="$(uuidgen)"
  pw_cipher="$(printf '%s' 'caliopepass' | base64)"

  # nombre|motor|host|puerto|base|usuario|túnel[|ssl|contraseña|ca]
  #
  # `ca` (plan 34, tarea 5) es la ruta del certificado de la CA con que se
  # verifica el servidor: con ella el perfil verifica (`sslSkipVerification`
  # false) en vez de saltarse la comprobación. La usan `clickhouse-tls` y `oracle-tls` (plan 63), cuya
  # CA es la que el contenedor sirve, copiada por `ensure_protocol_fixtures` a
  # `${WORKDIR}/clickhouse-tls-ca.pem` (la de Oracle, a `oracle-tls-ca.pem`); sin copia, la que genera
  # `generate_clickhouse_tls` en `${CLICKHOUSE_TLS_DIR}/ca.crt` de esta máquina
  # (con el testbed en la PC la imagen puede ser de la otra, con otra CA). Los cinco de ClickHouse entran con
  # `caliope_admin` (del seed, contraseña `caliopepass`), no con la cuenta de
  # arranque: es la que puede alterarse a sí misma.
  #
  # Los dos campos del final son opcionales y sólo los usan los nodos de SQL
  # Server (plan 12): usuario `sa` con su propia contraseña, y TLS encendido sin
  # verificar el certificado, que es autofirmado —SQL Server 2022 y posteriores
  # cifran la conexión siempre—.
  #
  # `caliope-mongo-rs` va SIN usuario a propósito: es el único nodo del testbed
  # sin autenticación (su servicio no declara `MONGO_INITDB_ROOT_USERNAME`), y
  # un usuario puesto ahí haría que el driver se autenticara contra `admin`
  # —que es a donde va por omisión— y fallara en un nodo que acepta a todo el
  # mundo. Usuario vacío es lo que el modelo lee como «sin autenticar».
  # Cada registro va precedido del servicio de compose que lo sirve
  # (`servicio=registro`): el asistente puede levantar sólo una parte, y el
  # archivo trae los perfiles de lo que se levantó (`SELECTED_SERVICES`).
  local nodes=(
    "primary=mariadb-primary · 3311 (R/W)|mariadb|127.0.0.1|3311|demo|caliope|false"
    "replica1=mariadb-replica1 · 3312|mariadb|127.0.0.1|3312|demo|caliope|false"
    "replica2=mariadb-replica2 · 3313|mariadb|127.0.0.1|3313|demo|caliope|false"
    "replica3=mariadb-replica3 · 3314|mariadb|127.0.0.1|3314|demo|caliope|false"
    "replica4=mariadb-replica4 · 3315 (lee de replica3)|mariadb|127.0.0.1|3315|demo|caliope|false"
    "standalone=mariadb-standalone · 3316|mariadb|127.0.0.1|3316|demo|caliope|false"
    "mysql-latest=mysql-latest · 3320|mysql|127.0.0.1|3320|demo|caliope|false"
    "mysql8-gis=mysql8-gis · 3321|mysql|127.0.0.1|3321|demo|caliope|false"
    "mysql8-gis=mysql8-gis · 3321 (base gis)|mysql|127.0.0.1|3321|gis|caliope|false"
    "mysql-primary=mysql-primary · 3322 (8.4, R/W)|mysql|127.0.0.1|3322|demo|caliope|false"
    "mysql-replica1=mysql-replica1 · 3323 (8.4)|mysql|127.0.0.1|3323|demo|caliope|false"
    "mysql-replica2=mysql-replica2 · 3324 (8.4)|mysql|127.0.0.1|3324|demo|caliope|false"
    "postgres-gis=postgres-gis · 3330 (PostGIS)|postgres|127.0.0.1|3330|demo|caliope|false"
    "postgres-primary=postgres-primary · 3331|postgres|127.0.0.1|3331|demo|caliope|false"
    "postgres-replica=postgres-replica · 3332 (sólo lectura)|postgres|127.0.0.1|3332|demo|caliope|false"
    "sqlserver=sqlserver · 3340|sqlserver|127.0.0.1|3340|demo|sa|false|true|Caliope!pass1"
    "sqlserver-restore=sqlserver-restore · 3341 (vacío)|sqlserver|127.0.0.1|3341|demo|sa|false|true|Caliope!pass1"
    "oracle=oracle · 1521|oracle|127.0.0.1|1521|FREEPDB1|system|false|false|Caliope_pass1"
    "oracle-tls=oracle-tls · 2484 (TCPS)|oracle|127.0.0.1|2484|FREEPDB1|system|false|true|Caliope_pass1|${ora_ca_path}"
    "oracle=oracle · túnel SSH|oracle|oracle|1521|FREEPDB1|system|true|false|Caliope_pass1"
    "clickhouse=clickhouse · 8123|clickhouse|127.0.0.1|8123|demo|caliope_admin|false"
    "clickhouse-tls=clickhouse-tls · 8443 (TLS)|clickhouse|127.0.0.1|8443|demo|caliope_admin|false|true||${ch_ca_path}"
    "clickhouse-r1=clickhouse-r1 · 8124 (réplica 1)|clickhouse|127.0.0.1|8124|demo|caliope_admin|false"
    "clickhouse-r2=clickhouse-r2 · 8125 (réplica 2)|clickhouse|127.0.0.1|8125|demo|caliope_admin|false"
    "clickhouse-r3=clickhouse-r3 · 8126 (shard 2, réplica 3)|clickhouse|127.0.0.1|8126|demo|caliope_admin|false"
    "clickhouse-r4=clickhouse-r4 · 8127 (shard 2, réplica 4)|clickhouse|127.0.0.1|8127|demo|caliope_admin|false"
    "clickhouse-tunnel=clickhouse-tunnel · túnel SSH|clickhouse|clickhouse-tunnel|8123|demo|caliope_admin|true"
    "mongo=caliope-mongo · 27017|mongodb|127.0.0.1|27017|caliope_demo|caliope|false"
    "mongo-rs=caliope-mongo-rs · 27018 (rs0, sin auth)|mongodb|127.0.0.1|27018|caliope_demo||false"
    "mongo-tls=caliope-mongo-tls · 27019 (TLS)|mongodb|127.0.0.1|27019|caliope_demo|caliope|false|true|caliopepass|${WORKDIR}/mongo-tls/ca.crt"
    "db=mariadb-db · replica5 (túnel SSH)|mariadb|db|3306|demo|caliope|true"
    "mongo=caliope-mongo · túnel SSH|mongodb|mongo|27017|caliope_demo|caliope|true"
    "redis=redis · 6379|redis|127.0.0.1|6379|0||false|false|caliopepass"
    "redis-tls=redis-tls · 6380 (TLS)|redis|127.0.0.1|6380|0||false|true|caliopepass|${WORKDIR}/redis-tls/ca.crt"
    "redis-cluster-1=redis-cluster-1 · 7001 (clúster)|redis|127.0.0.1|7001|0||false|false|caliopepass"
    "redis-tunnel=redis-tunnel · túnel SSH|redis|redis-tunnel|6379|0||true|false|caliopepass"
  )

  local body="" sep="" rec entry svc count=0
  for entry in "${nodes[@]}"; do
    svc="${entry%%=*}"
    rec="${entry#*=}"
    selected_service "$svc" || continue
    # Los perfiles de túnel de Mongo y de Oracle entran por el bastión: sin él no
    # sirven. Su nodo es el mismo que el del perfil directo, así que es el bastión el
    # que decide si van (el séptimo campo es el túnel).
    [[ ( "$svc" == "mongo" || "$svc" == "oracle" ) && "$(cut -d'|' -f7 <<< "$rec")" == "true" ]] \
      && ! selected_service bastion && continue
    count=$(( count + 1 ))
    local n_name n_engine n_host n_port n_db n_user n_ssh n_ssl n_pw n_ca
    IFS='|' read -r n_name n_engine n_host n_port n_db n_user n_ssh n_ssl n_pw n_ca <<< "$rec"
    n_ssl="${n_ssl:-false}"
    local n_skip="$n_ssl"
    [[ -n "$n_ca" ]] && n_skip=false
    [[ "$n_host" == "127.0.0.1" ]] && n_host="$main_host"

    local p_alt="$alt_host" p_ssh_host="" p_ssh_port=22 p_ssh_user="" p_cipher="$pw_cipher"
    [[ -n "$n_pw" ]] && p_cipher="$(printf '%s' "$n_pw" | base64)"
    if [[ "$n_ssh" == "true" ]]; then
      p_alt=""
      p_ssh_host="$ssh_host"
      p_ssh_port=2222
      p_ssh_user="tunneluser"
    fi
    # Sin usuario y sin contraseña propia es «sin autenticar» (`caliope-mongo-rs`).
    # Redis con `requirepass` autentica sólo con contraseña: usuario vacío y la
    # contraseña en el noveno campo (plan 33, tarea 4).
    [[ -z "$n_user" && -z "$n_pw" ]] && p_cipher=""

    # Claves en orden alfabético, como las escribe el exportador de la app
    # (`.sortedKeys`): así este archivo y uno exportado desde Calíope se pueden
    # comparar con `diff` sin que el orden invente diferencias.
    body+="${sep}    {
      \"altHost\": \"${p_alt}\",
      \"autoReconnect\": false,
      \"charset\": \"utf8mb4\",
      \"collation\": \"utf8mb4_unicode_ci\",
      \"defaultDatabase\": \"${n_db}\",
      \"engine\": \"${n_engine}\",
      \"groupID\": \"${group_id}\",
      \"host\": \"${n_host}\",
      \"id\": \"$(uuidgen)\",
      \"mongoConnectionString\": \"\",
      \"name\": \"${n_name}\",
      \"passwordCipher\": \"${p_cipher}\",
      \"passwordHidden\": true,
      \"port\": ${n_port},
      \"product\": \"${n_engine}\",
      \"sshAuthMethod\": \"password\",
      \"sshHost\": \"${p_ssh_host}\",
      \"sshPort\": ${p_ssh_port},
      \"sshPrivateKeyPath\": \"\",
      \"sshUsername\": \"${p_ssh_user}\",
      \"sslCaFile\": \"${n_ca}\",
      \"sslCertFile\": \"\",
      \"sslKeyFile\": \"\",
      \"sslSkipVerification\": ${n_skip},
      \"useCount\": 0,
      \"useSSH\": ${n_ssh},
      \"useSSL\": ${n_ssl},
      \"username\": \"${n_user}\"
    }"
    sep=",
"
  done

  # `_comment` sobra para el decodificador —Swift ignora las claves que no
  # conoce— y está para quien abra el archivo, que es la mitad de por qué el
  # formato es JSON legible.
  cat > "${out_file}" <<JSON_EOF
{
  "_comment": [
    "Conexiones de la cama de pruebas de Caliope, generadas por caliope-testbed.command.",
    "Se regenera en cada arranque con identificadores nuevos: no editar a mano.",
    "Importar desde Ajustes > Conexiones > Importar. La importacion FUSIONA: no borra nada.",
    "Importarlo dos veces deja dos copias del grupo; los identificadores cambian en cada corrida.",
    "La contrasena de los perfiles de tunel SSH (tunnel123) hay que teclearla: solo vive en el Llavero."
  ],
  "groups": [
    {
      "id": "${group_id}",
      "isExpanded": true,
      "name": "${group_name}",
      "sortOrder": 0
    }
  ],
  "profiles": [
${body}
  ]
}
JSON_EOF

  CONN_FILE="${out_file}"
  CONN_GROUP="${group_name}"
  CONN_PROFILE_COUNT="${count}"
  CONN_FALLBACK="${fallback}"
}

# ══════════════════════════════════════════════════════════════════════════════
#  Las bases espaciales de los nodos que no pasan por initdb
# ══════════════════════════════════════════════════════════════════════════════
#
# El seed llega a `standalone`, `mysql-latest` y `mysql8-gis` por
# /docker-entrypoint-initdb.d, y eso Docker lo corre **sólo** la primera vez que
# se crea un directorio de datos. En un Mac que ya había lanzado el testbed el
# volumen existe, así que un archivo de seed añadido después no se aplica nunca
# y no falla nada: el nodo responde, `demo` está entera y lo que falta
# simplemente no está — indistinguible de un fallo del cliente que se está
# probando.
#
# Esto vivía dentro de `setup-replication.sh` y **no podía funcionar ahí**: los
# tres nodos declaran `depends_on: setup: service_completed_successfully`, o sea
# que arrancan cuando ese contenedor ya terminó. Desde dentro no responde
# ninguno, y el paso imprimía «not answering — skipped» para los tres, que se
# lee como un aviso y era el mecanismo entero cayéndose. Aquí, en el lanzador y
# después de `up`, sí están en pie. Medido el 2026-09-12.
#
# Dos guardas distintas, y la diferencia es la idempotencia del seed:
#
#   · Los dos archivos espaciales del seed COMPARTIDO se reaplican siempre: son
#     `CREATE ... IF NOT EXISTS` e `INSERT IGNORE`, así que donde initdb ya hizo
#     el trabajo no cambian nada.
#   · El seed espacial de `mysql8-gis` se aplica ENTERO o ninguno, y sólo si la
#     base `gis` no existe: esos siete archivos llevan `INSERT INTO` pelado, sin
#     `IGNORE`, y pasarlos otra vez duplica filas o muere con clave repetida.
# Espera a que un nodo acepte una consulta, con techo. Se pregunta hasta 90 s y
# no una sola vez: estos tres arrancan DESPUÉS de que `setup` termine —es su
# `depends_on`—, así que en el instante en que la replicación queda configurada
# todavía están inicializándose. Sondearlos una vez decía «not answering» para
# los tres y se leía como un aviso, cuando lo único que pasaba es que aún no
# estaban. Medido el 2026-09-12, en el rearranque con volúmenes ya existentes.
wait_for_sql_node() {
  local node="$1" client="$2" i
  for (( i = 1; i <= 45; i++ )); do
    if docker compose -f "${COMPOSE_FILE}" exec -T "$node" \
         "$client" -uroot -prootpass -e "SELECT 1" >> "$RUN_LOG" 2>&1; then
      return 0
    fi
    sleep 2
  done
  return 1
}

ensure_spatial_databases() {
  # nodo:cliente
  local targets=("standalone:mariadb" "mysql-latest:mysql" "mysql8-gis:mysql")
  local target node client f
  for target in "${targets[@]}"; do
    node="${target%%:*}"
    client="${target##*:}"
    selected_service "$node" || continue
    if ! wait_for_sql_node "$node" "$client"; then
      step "${node}: still not answering after 90 s, the shared spatial seed was not re-applied"
      continue
    fi
    for f in 23-tables-gis.sql 73-seed-gis.sql; do
      docker compose -f "${COMPOSE_FILE}" exec -T "$node" \
        "$client" -uroot -prootpass < "${SEED_DIR}/${f}" >> "$RUN_LOG" 2>&1 || true
    done
  done

  # Y las bases espaciales completas del nodo plegado. A estas alturas ya
  # respondió —lo esperó el bucle de arriba—, así que esto no vuelve a esperar.
  selected_service mysql8-gis || return 0
  if docker compose -f "${COMPOSE_FILE}" exec -T mysql8-gis \
       mysql -uroot -prootpass -N -B \
       -e "SELECT COUNT(*) FROM information_schema.schemata WHERE schema_name='gis'" \
       2>/dev/null | tr -d '\r' | grep -q '^1$'; then
    return 0
  fi

  step "mysql8-gis: creating gis and gis_cartesian from the spatial seed…"
  for f in "${GIS_SEED_DIR}"/*.sql; do
    if ! docker compose -f "${COMPOSE_FILE}" exec -T mysql8-gis \
         mysql -uroot -prootpass < "$f" >> "$RUN_LOG" 2>&1; then
      step "mysql8-gis: $(basename "$f") could not be applied, see the run log"
      return 0
    fi
  done
  step "mysql8-gis: gis and gis_cartesian are in place."
}

# ══════════════════════════════════════════════════════════════════════════════
#  Lo que prueban los clientes propios de MySQL y Postgres (plan 61, tarea 1)
# ══════════════════════════════════════════════════════════════════════════════
#
# Una cuenta por cada manera de autenticarse, tres procedimientos con resultados
# y la CA de `postgres-gis`. Va aquí, después de `up`, y no en `initdb.d` por lo
# mismo que las bases espaciales: el volumen ya existe en toda máquina que lanzó
# el testbed, y un archivo de seed nuevo no llegaría nunca. Todo es idempotente
# (`IF NOT EXISTS`, `DROP … IF EXISTS`, `DO` con su guarda), así que correrlo en
# cada arranque no cambia nada donde ya está.
#
#   mariadb-standalone  ed25519user/ed25519pass  (client_ed25519)
#                       parsecuser/parsecpass    (parsec)
#                       pamuser/pampass          (PAM → mysql_clear_password)
#   mysql-latest        sha2user/sha2pass        (caching_sha2_password; la
#                       caché la vacía un FLUSH PRIVILEGES, que es lo que
#                       fuerza la autenticación completa)
#   mysql8-gis          sha256user/sha256pass    (sha256_password: el plugin
#                       que el cliente propio no habla)
#   los tres            demo.caliope_qa_lista(), caliope_qa_dos(), caliope_qa_sin()
#   postgres-gis        md5user/md5pass (md5), clearuser/clearpass (en claro;
#                       el servidor la pide también sin TLS y el cliente propio
#                       se niega a mandarla: plan 61, tarea 18); `caliope`
#                       sigue en SCRAM
QA_PROCEDURES_SQL='USE demo;
DROP PROCEDURE IF EXISTS caliope_qa_lista;
DROP PROCEDURE IF EXISTS caliope_qa_dos;
DROP PROCEDURE IF EXISTS caliope_qa_sin;
DELIMITER //
CREATE PROCEDURE caliope_qa_lista() SQL SECURITY INVOKER
BEGIN
  SELECT id, nombre FROM productos ORDER BY id LIMIT 3;
END//
CREATE PROCEDURE caliope_qa_dos() SQL SECURITY INVOKER
BEGIN
  SELECT 1 AS a;
  SELECT 2 AS b, 3 AS c;
END//
CREATE PROCEDURE caliope_qa_sin() SQL SECURITY INVOKER
BEGIN
  SET @caliope_qa_sin = 1;
END//
DELIMITER ;'

ensure_protocol_fixtures() {
  local target node client
  for target in "standalone:mariadb" "mysql-latest:mysql" "mysql8-gis:mysql"; do
    node="${target%%:*}"
    client="${target##*:}"
    selected_service "$node" || continue
    if ! wait_for_sql_node "$node" "$client"; then
      step "${node}: still not answering after 90 s, the protocol fixtures were not applied"
      continue
    fi
    printf '%s\n' "$QA_PROCEDURES_SQL" | docker compose -f "${COMPOSE_FILE}" exec -T "$node" \
      "$client" -uroot -prootpass >> "$RUN_LOG" 2>&1 \
      || step "${node}: the QA procedures could not be created, see the run log"
  done

  # La CA de cada uno, para la sonda `tls` (plan 61, tarea 4) y `openssl s_client
  # -starttls mysql`: la de MySQL es la que genera el servidor en su datadir
  # (`ssl_ca` = `ca.pem`); la de MariaDB, el certificado que su entrypoint deja en
  # `/tls`. El registro de nodos las lleva enteras en `ca`.
  local ca_spec ca_file ca_path
  for ca_spec in "standalone:mariadb-standalone:/tls/server.crt" \
                 "mysql-latest:mysql-latest:/var/lib/mysql/ca.pem" \
                 "mysql8-gis:mysql8-gis:/var/lib/mysql/ca.pem"; do
    node="${ca_spec%%:*}"
    ca_file="${ca_spec#*:}"; ca_file="${ca_file%%:*}-ca.pem"
    ca_path="${ca_spec##*:}"
    selected_service "$node" || continue
    if docker compose -f "${COMPOSE_FILE}" exec -T "$node" cat "$ca_path" \
         > "${WORKDIR}/${ca_file}.tmp" 2>> "$RUN_LOG" \
       && grep -q 'BEGIN CERTIFICATE' "${WORKDIR}/${ca_file}.tmp"; then
      mv "${WORKDIR}/${ca_file}.tmp" "${WORKDIR}/${ca_file}"
      step "Copied the CA of ${node} to ${WORKDIR}/${ca_file}"
    else
      rm -f "${WORKDIR}/${ca_file}.tmp"
      step "WARNING: could not copy ${ca_path} out of ${node}"
    fi
  done

  if selected_service standalone; then
    docker compose -f "${COMPOSE_FILE}" exec -T standalone mariadb -uroot -prootpass >> "$RUN_LOG" 2>&1 <<-'SQL' \
      || step "standalone: the authentication accounts could not be created, see the run log"
	CREATE USER IF NOT EXISTS 'ed25519user'@'%' IDENTIFIED VIA ed25519 USING PASSWORD('ed25519pass');
	CREATE USER IF NOT EXISTS 'parsecuser'@'%' IDENTIFIED VIA parsec USING PASSWORD('parsecpass');
	CREATE USER IF NOT EXISTS 'pamuser'@'%' IDENTIFIED VIA pam USING 'mariadb';
	GRANT ALL PRIVILEGES ON demo.* TO 'ed25519user'@'%', 'parsecuser'@'%', 'pamuser'@'%';
SQL
  fi

  if selected_service mysql-latest; then
    docker compose -f "${COMPOSE_FILE}" exec -T mysql-latest mysql -uroot -prootpass >> "$RUN_LOG" 2>&1 <<-'SQL' \
      || step "mysql-latest: sha2user could not be created, see the run log"
	CREATE USER IF NOT EXISTS 'sha2user'@'%' IDENTIFIED WITH caching_sha2_password BY 'sha2pass';
	GRANT ALL PRIVILEGES ON demo.* TO 'sha2user'@'%';
	FLUSH PRIVILEGES;
SQL
  fi

  # sha256user: `sha256_password`, que el cliente propio no habla (plan 61, tarea 6).
  # Es el plugin desconocido del paso 304: MariaDB no pide por cambio de método
  # ninguno que el cliente no conozca —`unix_socket` y `mysql_old_password` los
  # rechaza el servidor sin preguntar, con 1698 y 1275— y su `auth_mysql_sha2`
  # rompe las conexiones locales sin `private_key.pem` (medido el 2026-10-14).
  if selected_service mysql8-gis; then
    docker compose -f "${COMPOSE_FILE}" exec -T mysql8-gis mysql -uroot -prootpass >> "$RUN_LOG" 2>&1 <<-'SQL' \
      || step "mysql8-gis: sha256user could not be created, see the run log"
	CREATE USER IF NOT EXISTS 'sha256user'@'%' IDENTIFIED WITH sha256_password BY 'sha256pass';
	GRANT ALL PRIVILEGES ON demo.* TO 'sha256user'@'%';
SQL
  fi

  if selected_service postgres-gis; then
    local i
    for (( i = 1; i <= 45; i++ )); do
      docker compose -f "${COMPOSE_FILE}" exec -T postgres-gis \
        pg_isready -U caliope -d demo >> "$RUN_LOG" 2>&1 && break
      sleep 2
    done
    # md5user: la contraseña se guarda en md5 sólo si la sesión lo pide antes de
    # crearla; `password_encryption` del servidor es `scram-sha-256`.
    docker compose -f "${COMPOSE_FILE}" exec -T postgres-gis \
      psql -v ON_ERROR_STOP=1 -U caliope -d demo >> "$RUN_LOG" 2>&1 <<-'SQL' \
      || step "postgres-gis: md5user and clearuser could not be created, see the run log"
	SET password_encryption = 'md5';
	DO $$ BEGIN
	  IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'md5user') THEN
	    CREATE ROLE md5user LOGIN PASSWORD 'md5pass';
	  END IF;
	END $$;
	RESET password_encryption;
	DO $$ BEGIN
	  IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'clearuser') THEN
	    CREATE ROLE clearuser LOGIN PASSWORD 'clearpass';
	  END IF;
	END $$;
	GRANT ALL PRIVILEGES ON DATABASE demo TO md5user, clearuser;
SQL
    if docker compose -f "${COMPOSE_FILE}" exec -T postgres-gis cat /tls/server.crt \
         > "${WORKDIR}/postgres-gis-ca.pem.tmp" 2>> "$RUN_LOG" \
       && grep -q 'BEGIN CERTIFICATE' "${WORKDIR}/postgres-gis-ca.pem.tmp"; then
      mv "${WORKDIR}/postgres-gis-ca.pem.tmp" "${WORKDIR}/postgres-gis-ca.pem"
      step "Copied the CA of postgres-gis to ${WORKDIR}/postgres-gis-ca.pem"
    else
      rm -f "${WORKDIR}/postgres-gis-ca.pem.tmp"
      step "WARNING: could not copy /tls/server.crt out of postgres-gis"
    fi
  fi
}

# ══════════════════════════════════════════════════════════════════════════════
#  El informe final
# ══════════════════════════════════════════════════════════════════════════════
#
# Un bloque, y lo último que se imprime en los dos modos. Antes eran seis
# resúmenes repartidos por la corrida: la cabecera con la IP, el del contenedor
# de replicación —que traía su propia tabla de perfiles—, la comprobación de
# puertos, la tabla de perfiles del lanzador, el estado de los contenedores y la
# dirección otra vez al final, más el resumen de cada siembra por el medio. La
# IP salía tres veces y el inventario de nodos, otras tres.
#
# Tres cosas se fundieron aquí, y conviene no volver a separarlas:
#
#   · La comprobación de puertos y la tabla de perfiles eran las MISMAS filas
#     impresas dos veces, una con el puerto y otra con el modo. Ahora es una
#     tabla con columna de estado.
#   · Las siembras no imprimen su resumen: dejan una línea en DATASET_LINES y
#     sale aquí. Se imprimían a mitad de la salida porque detrás del bloque de
#     la dirección lo sepultaban, y consolidando desaparece el motivo.
#   · El volcado de Docker y del contenedor de replicación se va al registro de
#     la corrida, así que ya no hay nada que ordenar: un solo proceso escribe la
#     terminal, que es lo que el comentario de `docker logs -f` pedía en 2026.
final_report() {
  local host_ip="$1" iface_used="$2"

  # servicio|nodo|puerto|qué es. El puerto es el publicado en el Mac; `db` no
  # publica ninguno y va abajo, con los demás destinos de túnel. Sólo salen las
  # filas de lo que se levantó.
  local rows=(
    "primary|mariadb-primary|3311|MariaDB · read / write · GTID source"
    "replica1|mariadb-replica1|3312|MariaDB · read only"
    "replica2|mariadb-replica2|3313|MariaDB · read only"
    "replica3|mariadb-replica3|3314|MariaDB · read only · source of replica4"
    "replica4|mariadb-replica4|3315|MariaDB · read only · reads from replica3"
    "standalone|mariadb-standalone|3316|MariaDB · read / write · outside replication"
    "mysql-latest|mysql-latest|3320|MySQL latest · read / write"
    "mysql8-gis|mysql8-gis|3321|MySQL 8.0 · read / write · demo + gis, gis_cartesian"
    "mysql-primary|mysql-primary|3322|MySQL 8.4 LTS · read / write · GTID source"
    "mysql-replica1|mysql-replica1|3323|MySQL 8.4 LTS · read only"
    "mysql-replica2|mysql-replica2|3324|MySQL 8.4 LTS · read only"
    "postgres-gis|postgres-gis|3330|PostGIS 17 · read / write · databases demo and rrhh"
    "postgres-primary|postgres-primary|3331|Postgres 17 · read / write · streaming source"
    "postgres-replica|postgres-replica|3332|Postgres 17 · read only · physical standby"
    "sqlserver|sqlserver|3340|SQL Server 2025 · read / write · demo (dbo, rrhh) · user sa"
    "sqlserver-restore|sqlserver-restore|3341|SQL Server 2025 · empty, restore target · user sa"
    "oracle|oracle|1521|Oracle 26ai Free · service FREEPDB1 · schemas DEMO, RRHH · user system"
    "oracle-tls|oracle-tls|2484|Oracle 26ai Free · TCPS only · CA in oracle-tls-ca.pem · user system"
    "clickhouse|caliope-clickhouse|8123|ClickHouse 26.8 · HTTP · demo"
    "clickhouse-tls|caliope-clickhouse-tls|8443|ClickHouse 26.8 · HTTPS only · CA in scripts/clickhouse-tls/ca.crt"
    "clickhouse-r1|caliope-clickhouse-r1|8124|ClickHouse · caliope_par and caliope_2x2, shard 01"
    "clickhouse-r2|caliope-clickhouse-r2|8125|ClickHouse · caliope_par and caliope_2x2, shard 01"
    "clickhouse-r3|caliope-clickhouse-r3|8126|ClickHouse · caliope_2x2, shard 02"
    "clickhouse-r4|caliope-clickhouse-r4|8127|ClickHouse · caliope_2x2, shard 02"
    "mongo|caliope-mongo|27017|MongoDB 8 · authenticated"
    "mongo-rs|caliope-mongo-rs|27018|MongoDB 8 · replica set rs0, member 1 (preferred primary) · no auth"
    "mongo-rs2|caliope-mongo-rs2|27020|MongoDB 8 · replica set rs0, member 2"
    "mongo-rs3|caliope-mongo-rs3|27021|MongoDB 8 · replica set rs0, member 3"
    "mongo-tls|caliope-mongo-tls|27019|MongoDB 8 · TLS only + SCRAM · CA in mongo-tls/ca.crt"
    "redis|caliope-redis|6379|Redis 7.4 · password only (caliopepass) · db 0 seeded"
    "redis-tls|caliope-redis-tls|6380|Redis 7.4 · TLS only · CA in redis-tls/ca.crt"
    "redis-cluster-1|caliope-redis-cluster-1|7001|Redis 7.4 · cluster node 1 of 6 (caliopepass)"
    "redis-cluster-2|caliope-redis-cluster-2|7002|Redis 7.4 · cluster node 2 of 6 (caliopepass)"
    "redis-cluster-3|caliope-redis-cluster-3|7003|Redis 7.4 · cluster node 3 of 6 (caliopepass)"
    "redis-cluster-4|caliope-redis-cluster-4|7004|Redis 7.4 · cluster node 4 of 6 (caliopepass)"
    "redis-cluster-5|caliope-redis-cluster-5|7005|Redis 7.4 · cluster node 5 of 6 (caliopepass)"
    "redis-cluster-6|caliope-redis-cluster-6|7006|Redis 7.4 · cluster node 6 of 6 (caliopepass)"
    "bastion|bastion SSH|2222|tunnel entry for db, clickhouse-tunnel and redis-tunnel"
  )

  local row svc name port what state all_ok=true open_count=0 shown=0

  echo ""
  banner "ready"
  echo ""

  printf "  %sWhere to connect%s\n" "$C_BOLD" "$C_RESET"
  if [[ "$TARGET" == "remote" ]]; then
    printf "    %-22s %s   ← this Mac, iPad, iPhone, other machines\n" "Remote machine" "$host_ip"
    printf "    %-22s %s\n" "" "(Docker context ${DOCKER_CTX}; same ports, same credentials)"
  else
  printf "    %-22s %s\n" "From this Mac" "127.0.0.1"
  if [[ "$host_ip" == "127.0.0.1" ]]; then
    printf "    %-22s %s\n" "From the LAN" "unavailable — this Mac has no LAN address right now"
    printf "    %-22s %s\n" "" "(iPad and other devices cannot reach the testbed)"
  else
    printf "    %-22s %s   ← iPad, iPhone, other machines\n" "From the LAN" "$host_ip"
    printf "    %-22s %s\n" "" "(interface ${iface_used}; same ports, same credentials)"
  fi
  fi
  printf "    %-22s %s\n" "User / password" "caliope / caliopepass"
  printf "    %-22s %s\n" "Default database" "demo  ·  caliope_demo on the MongoDB nodes"
  echo ""

  printf "  %sNodes%s\n" "$C_BOLD" "$C_RESET"
  printf "    %-24s %-7s %-7s %s\n" "Node" "Port" "State" "What it is"
  printf "    %-24s %-7s %-7s %s\n" "------------------------" "-------" "-------" "----------"
  for row in "${rows[@]}"; do
    IFS='|' read -r svc name port what <<< "$row"
    selected_service "$svc" || continue
    shown=$(( shown + 1 ))
    if nc -z -G 3 "$host_ip" "$port" 2>/dev/null; then
      state="${C_GREEN}open${C_RESET}   "
      open_count=$(( open_count + 1 ))
    else
      state="${C_RED}CLOSED${C_RESET} "
      all_ok=false
    fi
    printf "    %-24s %-7s %s %s%s%s\n" "$name" "$port" "$state" "$C_DIM" "$what" "$C_RESET"
  done
  echo ""
  if ! $all_ok; then
    printf "    %d of %d ports answered. For the ones that did not:\n" "$open_count" "$shown"
    if [[ "$TARGET" == "remote" ]]; then
      printf "    the remote machine's firewall must let those ports in (ufw, firewalld).\n"
    else
      printf "    System Settings › Network › Firewall → allow Docker Desktop.\n"
    fi
    echo ""
  fi

  if selected_service bastion; then
    printf "  Through the SSH bastion   (port 2222, tunneluser / tunnel123)\n"
    selected_service db && printf "    %-18s %s\n" "mariadb-db" "db:3306 · replica5, database demo · no other way in"
    selected_service mongo && printf "    %-18s %s\n" "caliope-mongo" "mongo:27017 · that name only resolves inside Docker"
    selected_service clickhouse-tunnel && printf "    %-18s %s\n" "clickhouse-tunnel" "clickhouse-tunnel:8123 · no published port"
    selected_service redis-tunnel && printf "    %-18s %s\n" "redis-tunnel" "redis-tunnel:6379 · no published port"
    echo ""
  fi

  if selected_service postgres-gis; then
    printf "  PostgreSQL differs from the MySQL nodes in two ways, both on purpose\n"
    printf "    There is no separate root account: 'caliope' IS the superuser.\n"
    printf "    What MySQL calls a database is a SCHEMA here, so postgres-gis holds two\n"
    printf "    databases: demo (schemas demo, analytics, caliope_gis_test, gis) and rrhh.\n"
    echo ""
  fi

  if [[ "$DATASET_COUNT" -gt 0 ]]; then
    printf "  %sSample datasets%s\n" "$C_BOLD" "$C_RESET"
    local d d_name d_text
    for d in "${DATASET_LINES[@]}"; do
      IFS='|' read -r d_name d_text <<< "$d"
      printf "    %-9s %s\n" "$d_name" "$d_text"
    done
    echo ""
  fi

  if [[ "${#REPLICATION_LINES[@]}" -gt 0 ]]; then
    printf "  Replication\n"
    local rl
    for rl in "${REPLICATION_LINES[@]}"; do printf "    %s\n" "$rl"; done
    echo ""
  fi

  if [[ -n "$CONN_FILE" ]]; then
    printf "  %sConnection profiles to import into Caliope%s\n" "$C_BOLD" "$C_RESET"
    printf "    %s\n" "$CONN_FILE"
    printf "    %s profiles in one group, \"%s\". Settings › Connections ›\n" "$CONN_PROFILE_COUNT" "$CONN_GROUP"
    printf "    Import, which MERGES: nothing you already have is replaced.\n"
    if [[ "$TARGET" == "remote" ]]; then
      printf "    Every profile points at %s, which this Mac and the iPad reach alike.\n" "$host_ip"
      printf "    If that machine's IP changes, re-run with --remote and import again.\n"
    elif [[ "$host_ip" != "127.0.0.1" ]]; then
      printf "    Every profile carries 127.0.0.1 plus %s as the alternate, so the same\n" "$host_ip"
      printf "    file works on this Mac and on the iPad without editing anything.\n"
    else
      printf "    No LAN address right now, so the profiles only carry 127.0.0.1: usable\n"
      printf "    from this Mac only. Re-run with the Mac on a network to get the iPad one.\n"
    fi
    printf "    The SSH-tunnel profiles need tunnel123 typed once: SSH secrets live\n"
    printf "    only in the Keychain, so no file can carry them.\n"
    if [[ "$CONN_FALLBACK" == "1" ]]; then
      printf "    NOTE: the folder holding this launcher is not writable, so the file went\n"
      printf "    to the testbed working directory instead.\n"
    fi
    echo ""
  fi

  local cl_host="127.0.0.1"
  if [[ "$TARGET" == "remote" ]]; then
    cl_host="$host_ip"
    printf "  Command line   (the remote machine, %s)\n" "$host_ip"
  else
    printf "  Command line   (127.0.0.1 on this Mac; from anywhere else use %s)\n" "$host_ip"
  fi
  selected_service primary && printf "    mysql -h %s -P 3311 -u caliope -pcaliopepass demo\n" "$cl_host"
  selected_service mysql-primary && printf "    mysql -h %s -P 3322 -u caliope -pcaliopepass demo    # MySQL 8.4\n" "$cl_host"
  selected_service postgres-gis && printf "    psql -h %s -p 3330 -U caliope demo\n" "$cl_host"
  selected_service mongo && printf "    mongosh 'mongodb://caliope:caliopepass@%s:27017/caliope_demo?authSource=admin'\n" "$cl_host"
  selected_service mongo-rs && printf "    mongosh 'mongodb://%s:27018,%s:27020,%s:27021/caliope_demo?replicaSet=rs0'\n" "$cl_host" "$cl_host" "$cl_host"
  selected_service redis && printf "    redis-cli -h %s -p 6379 -a caliopepass\n" "$cl_host"
  if selected_service bastion; then
    printf "    ssh -p 2222 tunneluser@%s\n" "$cl_host"
    selected_service db && printf "    ssh -N -L 13306:db:3306 tunneluser@%s -p 2222    # 13306 is then replica5\n" "$cl_host"
  fi
  printf "    docker --context %s compose -f %s ps\n" "${DOCKER_CTX:-default}" "${COMPOSE_FILE}"
  echo ""

  printf "  %sFiles and commands%s\n" "$C_BOLD" "$C_RESET"
  printf "    %-20s %s\n" "Working directory" "${WORKDIR}"
  printf "    %-20s %s\n" "Run log" "${RUN_LOG}"
  printf "    %-20s %s\n" "QA node registry" "${WORKDIR}/testbed-servers$(target_suffix).json"
  printf "    %-20s %s --%s --status\n" "Container status" "$0" "$TARGET"
  printf "    %-20s %s --%s --logs\n" "Follow the logs" "$0" "$TARGET"
  printf "    %-20s %s --%s --stop\n" "Stop the cluster" "$0" "$TARGET"
  printf "    %-20s %s --%s --fresh\n" "Wipe and rebuild" "$0" "$TARGET"
  line
  echo ""
}

# La CA del nodo `redis-tls` la genera el contenedor (plan 33, tarea 2), y el
# perfil y las verificaciones la leen de `~/CaliopeTestbed/redis-tls/ca.crt` en
# la máquina que corre el lanzador. Se copia cuando el nodo ya está sano: antes,
# el certificado puede estar regenerándose con la IP de hoy. Sin el nodo
# arriba no hace nada.
copy_redis_tls_ca() {
  docker inspect caliope-redis-tls >/dev/null 2>&1 || return 0
  local waited=0 health=""
  while (( waited < 60 )); do
    health="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{end}}' caliope-redis-tls 2>/dev/null || true)"
    [[ "$health" == "healthy" ]] && break
    sleep 1
    waited=$(( waited + 1 ))
  done
  if [[ "$health" != "healthy" ]]; then
    step "WARNING: caliope-redis-tls is not healthy after 60 s; its CA was not copied"
    return 0
  fi
  mkdir -p "${WORKDIR}/redis-tls"
  if docker cp caliope-redis-tls:/tls/ca.crt "${WORKDIR}/redis-tls/ca.crt" >/dev/null 2>&1; then
    step "Copied the CA of redis-tls to ${WORKDIR}/redis-tls/ca.crt"
  else
    step "WARNING: could not copy /tls/ca.crt out of caliope-redis-tls"
  fi
}

# La CA del nodo `mongo-tls` la genera el contenedor (plan 60, tarea 1), igual, y el
# perfil y las verificaciones la leen de `~/CaliopeTestbed/mongo-tls/ca.crt` en
# la máquina que corre el lanzador. Se copia cuando el nodo ya está sano: antes,
# el certificado puede estar regenerándose con la IP de hoy. Sin el nodo
# arriba no hace nada.
# La CA de `clickhouse-tls`, **la del contenedor**, y no la que `generate_clickhouse_tls`
# deja en `${CLICKHOUSE_TLS_DIR}/ca.crt` de esta máquina: con el testbed en la PC lo
# levantan el Mac y la VM, cada uno con su CA, y el certificado que se sirve es el de
# quien construyó la imagen la última vez. Medido el 2026-10-14 (plan 61, tarea 12): la
# imagen era del Mac (CA del 11 de octubre) y la VM validaba con la suya (del 6), y el
# paso 226 fallaba con «certificate is not trusted». El registro y el archivo de
# conexiones leen esta copia antes que la local. Va también en `--files`, que no
# reinicia nada: basta para ponerse al día con una imagen que construyó la otra máquina.
copy_clickhouse_tls_ca() {
  docker inspect caliope-clickhouse-tls >/dev/null 2>&1 || return 0
  if docker cp caliope-clickhouse-tls:/etc/clickhouse-server/ca.crt "${WORKDIR}/clickhouse-tls-ca.pem.tmp" >/dev/null 2>&1 \
     && grep -q 'BEGIN CERTIFICATE' "${WORKDIR}/clickhouse-tls-ca.pem.tmp"; then
    mv "${WORKDIR}/clickhouse-tls-ca.pem.tmp" "${WORKDIR}/clickhouse-tls-ca.pem"
    step "Copied the CA of clickhouse-tls to ${WORKDIR}/clickhouse-tls-ca.pem"
  else
    rm -f "${WORKDIR}/clickhouse-tls-ca.pem.tmp"
    step "WARNING: could not copy /etc/clickhouse-server/ca.crt out of caliope-clickhouse-tls"
  fi
}

# La CA de `oracle-tls` (plan 63, tarea 3), la del contenedor, por lo mismo que la de
# `clickhouse-tls`: la imagen la construyó la última máquina que corrió el lanzador, y la CA
# de ésta puede ser otra. Va a `${WORKDIR}/oracle-tls-ca.pem`, que leen el registro y el
# archivo de conexiones antes que `${ORACLE_TLS_DIR}/ca.crt`. Sin el nodo, nada.
copy_oracle_tls_ca() {
  docker inspect oracle-tls >/dev/null 2>&1 || return 0
  if docker cp oracle-tls:/opt/oracle/tls/ca.crt "${WORKDIR}/oracle-tls-ca.pem.tmp" >/dev/null 2>&1 \
     && grep -q 'BEGIN CERTIFICATE' "${WORKDIR}/oracle-tls-ca.pem.tmp"; then
    mv "${WORKDIR}/oracle-tls-ca.pem.tmp" "${WORKDIR}/oracle-tls-ca.pem"
    step "Copied the CA of oracle-tls to ${WORKDIR}/oracle-tls-ca.pem"
  else
    rm -f "${WORKDIR}/oracle-tls-ca.pem.tmp"
    step "WARNING: could not copy /opt/oracle/tls/ca.crt out of oracle-tls"
  fi
}

copy_mongo_tls_ca() {
  docker inspect caliope-mongo-tls >/dev/null 2>&1 || return 0
  local waited=0 health=""
  while (( waited < 60 )); do
    health="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{end}}' caliope-mongo-tls 2>/dev/null || true)"
    [[ "$health" == "healthy" ]] && break
    sleep 1
    waited=$(( waited + 1 ))
  done
  if [[ "$health" != "healthy" ]]; then
    step "WARNING: caliope-mongo-tls is not healthy after 60 s; its CA was not copied"
    return 0
  fi
  mkdir -p "${WORKDIR}/mongo-tls"
  if docker cp caliope-mongo-tls:/tls/ca.crt "${WORKDIR}/mongo-tls/ca.crt" >/dev/null 2>&1; then
    step "Copied the CA of mongo-tls to ${WORKDIR}/mongo-tls/ca.crt"
  else
    step "WARNING: could not copy /tls/ca.crt out of caliope-mongo-tls"
  fi
}

do_up() {
  require_docker

  # Always re-extract to keep the on-disk copy in sync with this launcher.
  extract_all

  # Detect the LAN IP. First try the default-route interface (works on any
  # macOS setup — Wi-Fi, Ethernet, Thunderbolt/USB adapters, docks — regardless
  # of whether it is en0 or en7); if that fails, scan en0..en10 as a fallback;
  # last resort: 127.0.0.1 with a warning.
  #
  # Y en Linux —la sesión en la nube de Claude Code, que corre el testbed para
  # sus arneses— `route -n get default` e `ipconfig` no existen. Sin el guarda
  # de `command -v`, la primera asignación salía 127 y `set -e` mataba el
  # arranque entero DESPUÉS de extraer, con el error tragado por `2>/dev/null`:
  # 65 archivos escritos, ningún contenedor y un log de dos líneas.
  local host_ip=""
  local iface_used=""
  local default_iface=""
  local candidate=""

  if [[ "$TARGET" == "remote" ]]; then
    # En remoto la dirección es la de la otra máquina, y es la misma para
    # todos: este Mac, el iPad y cualquiera de la red.
    host_ip="$REMOTE_HOST"
    iface_used="remote"
  elif command -v route >/dev/null 2>&1 && command -v ipconfig >/dev/null 2>&1; then
    default_iface=$(route -n get default 2>/dev/null | awk '/interface:/{print $2}' || true)
    if [[ -n "$default_iface" ]]; then
      candidate=$(ipconfig getifaddr "$default_iface" 2>/dev/null || true)
      if [[ -n "$candidate" ]]; then
        host_ip="$candidate"
        iface_used="$default_iface"
      fi
    fi

    if [[ -z "$host_ip" ]]; then
      for iface in en0 en1 en2 en3 en4 en5 en6 en7 en8 en9 en10; do
        candidate=$(ipconfig getifaddr "$iface" 2>/dev/null || true)
        if [[ -n "$candidate" ]]; then
          host_ip="$candidate"
          iface_used="$iface"
          break
        fi
      done
    fi
  else
    # Linux. `ip route get` da además la interfaz, pero el contenedor de la
    # sesión en la nube no trae `iproute2`: `hostname -I` sí está, y lista las
    # direcciones v4 separadas por espacios. Se descarta la del puente de
    # Docker, que no lleva a ninguna parte.
    if command -v ip >/dev/null 2>&1; then
      candidate=$(ip -4 route get 1.1.1.1 2>/dev/null | awk '{for(i=1;i<NF;i++){if($i=="dev")d=$(i+1); if($i=="src")s=$(i+1)}} END{print s" "d}' || true)
      host_ip="${candidate%% *}"
      iface_used="${candidate##* }"
      [[ -z "$iface_used" || "$iface_used" == "$host_ip" ]] && iface_used="n/a"
    fi
    if [[ -z "$host_ip" ]] && command -v hostname >/dev/null 2>&1; then
      for candidate in $(hostname -I 2>/dev/null || true); do
        case "$candidate" in
          172.1[678].*|172.19.*|172.2*.*|172.3[01].*|127.*) continue ;;
        esac
        host_ip="$candidate"
        iface_used="n/a"
        break
      done
    fi
  fi

  if [[ -z "$host_ip" ]]; then
    printf "WARNING: no active IP detected (default route missing, en0-en10 sin IP).\n"
    printf "Falling back to 127.0.0.1. iPad / LAN clients no podrán conectarse hasta\n"
    printf "que el Mac tenga red.\n\n"
    host_ip="127.0.0.1"
    iface_used="n/a"
  fi

  export HOST_IP="$host_ip"

  cd "${WORKDIR}"

  # El registro de la corrida. Se abre aquí porque todo lo que sigue escribe en
  # él, y se guardan las diez últimas corridas: un arranque completo con
  # `--fresh` deja unos cientos de kilobytes y mirar el de ayer sirve.
  mkdir -p "${LOG_DIR}"
  RUN_LOG="${LOG_DIR}/run-$(date '+%Y%m%d-%H%M%S').log"
  : > "$RUN_LOG"
  # Con lo que dejaron los datos de ejemplo de esa corrida: su cola de avisos y
  # su carpeta (`run-<fecha>.events` y `run-<fecha>.d/`).
  ls -1t "${LOG_DIR}"/run-*.log 2>/dev/null | tail -n +11 | while read -r old_log; do
    rm -rf "$old_log" "${old_log%.log}.events" "${old_log%.log}.d"
  done
  EVENTS_FILE="${RUN_LOG%.log}.events"
  DATASET_DIR="${RUN_LOG%.log}.d"
  : > "$EVENTS_FILE"
  mkdir -p "$DATASET_DIR"

  banner "starting"

  # El asistente, con terminal y sin `--all`; si no, todo, como siempre.
  if [[ "$ALL_FLAG" != "1" && -t 0 && -t 1 ]]; then
    run_wizard
    echo ""
  else
    # **Sin asistente, los datos de ejemplo de la última selección** (auditoría 58, tarea 11,
    # 2026-10-19). Sin esto `WIZARD_DATASETS` quedaba vacío y no se cargaba ninguno; con
    # `--fresh`, que borra los volúmenes donde vivían, `--remote --fresh --all` —lo que el
    # runbook manda tras un cambio de IP de la PC— dejaba el testbed sin sakila, pagila ni
    # atlas, y los pasos de QA que los leen salían rojos («Unknown database 'sakila'»). Sólo
    # se toma esa línea del archivo: los motores siguen siendo «todos», como siempre con `--all`.
    WIZARD_DATASETS="$(sed -n 's/^datasets=//p' "$SELECTION_FILE" 2>/dev/null | tail -1)"
  fi
  # Las réplicas de MariaDB que `setup` tiene que esperar y configurar, en orden.
  local r setup_reps=""
  for r in replica1 replica2 replica3 replica4 db; do
    selected_service "$r" && setup_reps="$setup_reps $r"
  done
  export SETUP_REPLICAS="${setup_reps# }"

  # Las descargas empiezan ya: mientras Docker baja imágenes y arranca.
  start_dataset_jobs

  if [[ "$MODE" == "fresh" ]]; then
    line
    echo " --fresh: system-wide Docker cleanup"
    line
    echo ""
    echo " This will run the following DESTRUCTIVE actions on your Docker instance:"
    echo ""
    echo " Target: $(target_label) (Docker context ${DOCKER_CTX:-default})."
    echo ""
    echo "   1. docker stop \$(docker ps -a -q)      → stops EVERY container of that Docker"
    echo "   2. docker system prune --all --force   → removes all unused images,"
    echo "                                             containers, networks and build cache"
    echo "   3. docker builder prune --all --force  → wipes the BuildKit cache"
    echo "   4. docker volume prune --all --force   → deletes ALL unused volumes,"
    echo "                                             named ones included"
    echo "   5. docker compose pull                 → downloads fresh images for the testbed"
    echo ""
    echo " WARNING: this affects ALL Docker resources of that Docker, not only the"
    echo "          Caliope testbed. Other projects using Docker will lose their"
    echo "          containers, images and volumes."
    echo ""
    read -r -p " Continue with the full cleanup? [y/N] " reply
    echo ""
    case "$reply" in
      [yY]|[yY][eE][sS])
        step "Cleaning up Docker…"
        local all_ids
        all_ids="$(docker ps -a -q)"
        if [[ -n "$all_ids" ]]; then
          logged docker stop $all_ids || true
        fi
        logged docker system prune --all --force || true
        logged docker builder prune --all --force || true
        # `--all` no es de más: sin él, `volume prune` sólo borra los volúmenes
        # ANÓNIMOS, y los dieciséis del testbed tienen nombre. Con `-f` a secas
        # este paso reclamaba 0 B y el arranque siguiente reusaba los datos de
        # antes — o sea que `--fresh` no era fresh, y sin síntoma: el testbed
        # levantaba igual. Medido el 2026-09-12 contra Docker 29.7.2, tras un
        # `--fresh` entero: sobrevivieron los dieciséis, incluidos los cuatro de
        # los nodos que la poda había retirado, que ya no puede alcanzar nadie.
        # Y sí, alcanza a los volúmenes sin usar de otros proyectos: es lo que el
        # aviso de arriba dice palabra por palabra.
        logged docker volume prune --all --force || true
        if ! run_with_progress "Pulling the images" \
             docker compose -f "${COMPOSE_FILE}" pull; then
          show_log_tail "docker compose pull failed."
          exit 1
        fi
        step "Cleanup finished."
        ;;
      *)
        step "Cleanup cancelled. Starting the cluster without wiping."
        ;;
    esac
  fi

  # Lo que corría de una vez anterior y ahora no se eligió se para, con sus
  # volúmenes: lo que se elige es lo que queda arriba.
  stop_unselected

  # `--remove-orphans` no es cosmética: al retirar un servicio del compose, su
  # contenedor sigue corriendo y sigue ocupando su puerto y su memoria, y en la
  # app aparece un nodo que la topología ya no tiene. Al pasar de dieciséis
  # nodos a doce (2026-09-12) eran cuatro contenedores vivos de más. Los
  # volúmenes huérfanos sí se quedan: borrarlos es de `--fresh`, que pregunta.
  local -a up_args=(up -d --build --remove-orphans)
  if [[ -n "$SELECTED_SERVICES" ]]; then
    # shellcheck disable=SC2206
    up_args+=($SELECTED_SERVICES)
  fi
  if ! run_with_progress "Building and starting" \
       docker compose -f "${COMPOSE_FILE}" "${up_args[@]}"; then
    : > "${DATASET_DIR}/up.failed"
    [[ -n "$DATASET_PIDS" ]] && kill $DATASET_PIDS 2>/dev/null
    show_log_tail "docker compose up failed, so the cluster is NOT running."
    exit 1
  fi
  ok "Containers started on $(target_label)."
  drain_events

  # Cada grupo de replicación lo configura un contenedor que corre una vez y se
  # apaga. `docker logs -f` sobre él termina cuando él termina, así que esto ES
  # la espera, con su línea viva; su salida va al registro y el veredicto se lee
  # de ahí. Sólo los de lo que se levantó.
  REPLICATION_LINES=()
  wait_setup setup                     "mariadb-setup"                  "MariaDB replication"
  wait_setup mysql-setup               "mysql-setup"                    "MySQL replication"
  wait_setup mongo-rs-setup            "caliope-mongo-rs-setup"         "MongoDB replica set"
  wait_setup clickhouse-replica-setup  "caliope-clickhouse-replica-setup" "ClickHouse pair"
  wait_setup clickhouse-shard-setup    "caliope-clickhouse-shard-setup" "ClickHouse shards"
  wait_setup redis-cluster-setup       "caliope-redis-cluster-setup"    "Redis cluster"

  ensure_spatial_databases
  ensure_protocol_fixtures
  selected_service redis-tls && copy_redis_tls_ca
  selected_service mongo-tls && copy_mongo_tls_ca
  selected_service clickhouse-tls && copy_clickhouse_tls_ca
  selected_service oracle-tls && copy_oracle_tls_ca
  drain_events

  if [[ "$TARGET" == "remote" ]]; then
    write_servers_registry "$host_ip"
  else
    write_servers_registry "127.0.0.1"
  fi
  write_connection_import_file "$host_ip"

  # Los datos de ejemplo cargan desde aquí, con los nodos ya en pie.
  : > "${DATASET_DIR}/up.done"
  finish_dataset_jobs

  final_report "$host_ip" "$iface_used"
  notify_os "The testbed is ready on $(target_label)."
}

# La cabecera de cada arranque: el nombre en ámbar y, debajo, la versión del
# lanzador, el destino y la máquina.
banner() {
  echo ""
  printf '  %s%sCalíope testbed%s  %s%s%s\n' "$C_BOLD" "$C_AMBER" "$C_RESET" "$C_DIM" "$1" "$C_RESET"
  printf '  %s%s · %s · %s%s\n' "$C_DIM" "$(date '+%Y-%m-%d %H:%M')" "$(target_label)" \
    "$( [[ "$TARGET" == "remote" ]] && echo "${REMOTE_USER}@${REMOTE_HOST}" || hostname -s )" "$C_RESET"
  line
}

# Los servicios que corren y no se eligieron, parados con sus volúmenes. Una
# sola llamada a la API para saber cuáles (lo mismo que `--status`).
stop_unselected() {
  [[ -n "$SELECTED_SERVICES" ]] || return 0
  local project running svc list=""
  project="$(compose_project)"
  running="$(docker ps --filter "label=com.docker.compose.project=${project}" \
               --format '{{.Label "com.docker.compose.service"}}' 2>/dev/null || true)"
  for svc in $running; do
    selected_service "$svc" || list="$list $svc"
  done
  [[ -n "$list" ]] || return 0
  # shellcheck disable=SC2086
  if run_with_progress "Stopping the rest" docker compose -f "${COMPOSE_FILE}" stop $list; then
    step "Stopped, volumes kept:${list}"
  fi
}

# Espera a un contenedor de preparación y anota su veredicto. servicio, nombre
# del contenedor, rótulo.
wait_setup() {
  local svc="$1" name="$2" label="$3" waited=0 verdict="" rc
  selected_service "$svc" || return 0
  # `up -d` puede volver antes de que exista: se le dan 30 s.
  while ! docker inspect "$name" >/dev/null 2>&1; do
    sleep 1
    waited=$(( waited + 1 ))
    if [[ "$waited" -ge 30 ]]; then
      REPLICATION_LINES+=("${label}: its setup container never appeared")
      announce warn "${label}: its setup container never appeared"
      return 0
    fi
  done
  run_with_progress "${label}" docker logs -f "$name" || true
  rc="$(docker inspect -f '{{.State.ExitCode}}' "$name" 2>/dev/null || echo '?')"
  case "$svc" in
    setup)
      local reps failed_reps
      reps="$(grep -o 'Replication configured successfully. Replicas: [0-9]* ([^)]*)' "$RUN_LOG" | tail -1 | sed 's/.*Replicas: //' || true)"
      failed_reps="$(grep -o '[0-9]* replica(s) could not be synchronized' "$RUN_LOG" | tail -1 || true)"
      if [[ -n "$reps" ]]; then
        verdict="ok|${reps%% *} replica(s) configured ${reps#* } · replica4 reads from replica3"
        [[ " $SETUP_REPLICAS " == *" replica4 "* && " $SETUP_REPLICAS " == *" replica3 "* ]] \
          || verdict="ok|${reps%% *} replica(s) configured ${reps#* }"
      elif [[ -n "$failed_reps" ]]; then
        verdict="warn|${failed_reps} — the rest is up; see the run log"
      else
        verdict="warn|no verdict in the run log"
      fi
      ;;
    mysql-setup)
      local m
      m="$(grep -o 'MySQL replication configured. Replicas: [0-9]* of 2' "$RUN_LOG" | tail -1 | sed 's/.*Replicas: //' || true)"
      if [[ "$m" == "2 of 2" ]]; then verdict="ok|2 replicas reading from mysql-primary (8.4 LTS, GTID)"
      else verdict="warn|${m:-no verdict} replicas replicating; see the run log"; fi
      ;;
    mongo-rs-setup)
      local members
      members="$(grep -o '\[caliope\] rs0: [0-9].*' "$RUN_LOG" | tail -1 | sed 's/\[caliope\] rs0: //' || true)"
      if [[ "$rc" == "0" && "$members" == *PRIMARY* ]]; then verdict="ok|rs0: ${members}"
      else verdict="warn|rs0: ${members:-no verdict}; see the run log"; fi
      ;;
    clickhouse-replica-setup)
      if [[ "$rc" == "0" ]]; then verdict="ok|caliope_par: par.replicada reached r2"
      else verdict="warn|caliope_par: the replicated table did not reach r2; see the run log"; fi
      ;;
    clickhouse-shard-setup)
      local line_ch
      line_ch="$(grep -o 'reparto.ventas: .*' "$RUN_LOG" | tail -1 || true)"
      if [[ "$rc" == "0" ]]; then verdict="ok|caliope_2x2: ${line_ch}"
      else verdict="warn|caliope_2x2: ${line_ch:-no verdict}; see the run log"; fi
      ;;
    *)
      if [[ "$rc" == "0" ]]; then verdict="ok|formed"; else verdict="warn|exited ${rc}; see the run log"; fi
      ;;
  esac
  REPLICATION_LINES+=("${label}: ${verdict#*|}")
  announce "${verdict%%|*}" "${label}: ${verdict#*|}"
  drain_events
}

# ==============================================================================
#  --only: levantar sólo los nodos que la tarea nombra (David, 2026-09-23)
# ==============================================================================
# Un vídeo del Editor SQL no necesita los tres generadores de carga ni los
# nodos de Postgres y Mongo; levantar los dieciocho para usar uno gasta batería
# y mete filas del `loadgen` donde nadie las pidió. Así que antes de subir el
# testbed se decide qué nodos hacen falta —el método y la tabla están en
# `agent/runbooks/automated-qa.md`, «Levantar sólo los nodos que hacen falta»—
# y se suben sólo ésos. Va con `--no-deps` a propósito: `depends_on` arrastra
# `setup`, y `setup` arrastra los cuatro MariaDB; con los volúmenes ya sembrados
# por un arranque completo anterior, cada nodo arranca solo con sus datos, y la
# lista que se pasa es EXACTAMENTE lo que se levanta. Por eso exige que haya
# habido un arranque completo antes: sin volúmenes, un nodo suelto arranca vacío
# y sin réplica configurada, y eso se parece a un fallo de la app.
do_only() {
  require_docker
  if [[ -z "$ONLY_SERVICES" ]]; then
    echo "--only needs a comma-separated list of services, e.g. --only primary,replica1,replica2,loadgen"
    exit 2
  fi

  # Always re-extract to keep the on-disk copy in sync with this launcher.
  extract_all
  cd "${WORKDIR}"

  # El registro de la corrida, como en do_up: `run_with_progress` y `logged`
  # escriben en él, y sin fijarlo aquí fallan con «: No such file or directory»
  # (medido el 2026-09-23, la primera vez que se corrió `--only`).
  mkdir -p "${LOG_DIR}"
  RUN_LOG="${LOG_DIR}/run-$(date '+%Y%m%d-%H%M%S').log"
  : > "$RUN_LOG"
  ls -1t "${LOG_DIR}"/run-*.log 2>/dev/null | tail -n +11 | while read -r old_log; do
    rm -f "$old_log"
  done
  # La IP de la LAN sólo la necesita el iPad para llegar al Mac; un arranque
  # parcial es para una tarea del Mac, y con esto el compose no avisa de una
  # variable vacía. Un arranque completo la detecta de verdad.
  export HOST_IP="${HOST_IP:-127.0.0.1}"
  [[ "$TARGET" == "remote" ]] && export HOST_IP="$REMOTE_HOST"

  # Que Docker no conteste no es «no hay arranque previo»: con `--remote` el
  # `ps` va por SSH y a veces falla de paso, y con el error tirado a /dev/null
  # salía este mensaje con 24 contenedores en pie (medido el 2026-10-08, dos de
  # cinco `--only` seguidos). Se distingue, y se dice qué contestó.
  #
  # Y la pregunta va en UNA llamada a la API (`docker ps` filtrado por el
  # proyecto), no con `docker compose ps -a`: ése inspecciona cada contenedor en
  # paralelo, cada inspección es una sesión más sobre la conexión SSH
  # multiplexada, y pasadas las diez (`MaxSessions` de la PC) el servidor la
  # rechaza —«Session open refused by peer»— y sus `PerSourcePenalties` cortan
  # las siguientes con `kex_exchange_identification`. Con 30 contenedores fallaba
  # tres de cada tres veces seguidas; `COMPOSE_PARALLEL_LIMIT` no lo cambia
  # (medido el 2026-10-08, plan 33, tarea 2). El nombre del proyecto sale de
  # `config`, que no habla con Docker.
  local previous project rc=0
  project="$(compose_project)"
  previous="$(docker ps -a --filter "label=com.docker.compose.project=${project}" --format '{{.Names}}' 2>&1)" || rc=$?
  if (( rc != 0 )); then
    echo "Docker on $(target_label) did not answer (docker ps exited ${rc}); try again:"
    printf '%s\n' "$previous" | tail -n 3 | sed 's/^/  /'
    exit 1
  fi
  if ! grep -q . <<< "$previous"; then
    echo "No previous full start found: run without arguments once, so the seeds and the"
    echo "replication get set up. --only starts nodes over volumes that already exist."
    exit 1
  fi

  # Lo que no tiene sentido levantar a medias va junto: un conjunto de réplicas
  # de tres miembros con dos abajo se queda sin primario y no acepta escrituras,
  # y eso se parece a un fallo de la app (2026-10-13).
  case ",${ONLY_SERVICES}," in
    *,mongo-rs,*)
      [[ ",${ONLY_SERVICES}," == *",mongo-rs2,"* ]] || ONLY_SERVICES="${ONLY_SERVICES},mongo-rs2"
      [[ ",${ONLY_SERVICES}," == *",mongo-rs3,"* ]] || ONLY_SERVICES="${ONLY_SERVICES},mongo-rs3"
      ;;
  esac

  # Y un nodo va con su siembra: el contenedor `*-seed` de su opción en el
  # CATALOG. Sin él, un nodo que nunca se sembró en esta máquina arranca vacío,
  # y eso tampoco se parece a lo que es: `--only redis` en la PC dejó la base 0
  # sin una clave y el guion del vídeo abortó por un ancla que no estaba (retro
  # del 2026-10-13). La siembra mira antes si ya hay datos, así que repetirla
  # sobre un volumen sembrado no hace nada.
  local row row_services wanted extra
  for row in "${CATALOG[@]}"; do
    row_services="$(cut -d'|' -f4 <<< "$row")"
    for wanted in $row_services; do
      [[ ",${ONLY_SERVICES}," == *",${wanted},"* ]] || continue
      for extra in $row_services; do
        [[ "$extra" == *-seed ]] || continue
        [[ ",${ONLY_SERVICES}," == *",${extra},"* ]] || ONLY_SERVICES="${ONLY_SERVICES},${extra}"
      done
      break
    done
  done

  local known
  known="$(docker compose -f "${COMPOSE_FILE}" config --services)"
  local -a list=()
  IFS=',' read -r -a list <<< "$ONLY_SERVICES"
  local svc
  for svc in "${list[@]}"; do
    if ! grep -qx "$svc" <<< "$known"; then
      echo "Unknown service: ${svc}. Known services:"
      echo "$known" | sed 's/^/  /'
      exit 2
    fi
  done

  # `--build` como en do_up: la semilla va DENTRO de la imagen
  # (`Dockerfile.seeded`), y sin él `up` reutiliza la imagen ya construida con la
  # semilla vieja aunque el lanzador acabe de extraer la nueva (medido el
  # 2026-10-06 con el nodo `clickhouse`, plan 34, tarea 2). La caché de capas
  # hace que no cueste nada cuando no cambió, y el volumen no se toca: una
  # semilla sólo vuelve a correr sobre un volumen vacío.
  step "Starting only: ${list[*]} on $(target_label) (--no-deps)"
  if ! run_with_progress "Starting ${#list[@]} service(s)" \
       docker compose -f "${COMPOSE_FILE}" up -d --build --no-deps "${list[@]}"; then
    show_log_tail "docker compose up failed."
    exit 1
  fi
  docker compose -f "${COMPOSE_FILE}" ps "${list[@]}"
  # Las CA de los nodos TLS que estén arriba, todos y no sólo los de la lista (plan 61,
  # tarea 18): `write_target_files` reescribe el registro entero, y la máquina que corre
  # `--only` puede no ser la que levantó el nodo —la VM sobre la PC que montó el Mac—, así que
  # sin su copia el registro perdía la CA de `mongo-tls` y tres pasos de Mongo salían en rojo.
  # Cada copia vuelve sin hacer nada si su contenedor no existe.
  copy_redis_tls_ca
  copy_mongo_tls_ca
  copy_clickhouse_tls_ca
  copy_oracle_tls_ca
  # Las cuentas y los procedimientos del plan 61 también aquí, y por lo mismo que
  # el registro de abajo: en una corrida nadie hace un arranque completo. Sólo
  # sobre lo que se levantó (`selected_service` lee SELECTED_SERVICES).
  ( SELECTED_SERVICES="${list[*]}"; ensure_protocol_fixtures )
  # Los dos archivos se reescriben también aquí (plan 34, tarea 5): la PC es
  # volátil y en una corrida nadie hace un arranque completo, así que sin esto
  # un nodo nuevo del lanzador no llegaba nunca al registro ni a los perfiles.
  write_target_files
  echo ""
  echo "Only ${list[*]} are up; nothing else was started. Stop with --stop."
}

# El registro de nodos y el archivo de perfiles del destino, y nada más. La
# dirección es la de la otra máquina en remoto; en local, la de la LAN que el
# llamante pase en HOST_IP —desde la VM, la del Mac—, o ninguna alternativa.
write_target_files() {
  if [[ "$TARGET" == "remote" ]]; then
    write_servers_registry "$REMOTE_HOST"
    write_connection_import_file "$REMOTE_HOST"
  else
    write_servers_registry "127.0.0.1"
    write_connection_import_file "${HOST_IP:-127.0.0.1}"
  fi
  step "Wrote $(basename "${WORKDIR}/testbed-servers$(target_suffix).json") and ${CONN_FILE} (${CONN_PROFILE_COUNT} profiles)"
}

do_files() {
  extract_all
  cd "${WORKDIR}"
  copy_redis_tls_ca
  copy_mongo_tls_ca
  copy_clickhouse_tls_ca
  copy_oracle_tls_ca
  write_target_files
}

# ==============================================================================
#  Dispatch
# ==============================================================================
resolve_target
case "$MODE" in
  up|fresh) do_up ;;
  only)     do_only ;;
  files)    do_files ;;
  stop)     do_stop ;;
  status)   do_status ;;
  health)   do_health ;;
  logs)     do_logs ;;
  *)        usage; exit 2 ;;
esac
