#!/usr/bin/env bash
# ==============================================================================
#  Caliope Testbed — self-extracting launcher
# ==============================================================================
#  Single-file distribution of the Docker test environment for Caliope.
#  Double-click from Finder or run from Terminal:
#
#      ./caliope-testbed.command            # start the full topology
#      ./caliope-testbed.command --fresh    # wipe everything and rebuild
#                                             (offers to seed Sakila, Pagila and
#                                              the MongoDB sample data as well)
#      ./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
#
#  Topology (all containers run on your Mac via Docker Desktop):
#
#      Mac (Caliope)
#        ├── localhost:3311 ──► mariadb-primary     (read / write, GTID master)
#        ├── localhost:3312 ──► mariadb-replica1    (read-only)
#        ├── localhost:3313 ──► mariadb-replica2    (read-only)
#        ├── localhost:3316 ──► mariadb-standalone  (independent, read / write)
#        ├── localhost:3320 ──► mysql-latest        (MySQL latest, seeded)
#        ├── localhost:3321 ──► mysql8-gis          (MySQL 8.0, seeded + espacial)
#        ├── 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:27017 ─► caliope-mongo       (MongoDB 8, authenticated)
#        ├── localhost:27018 ─► caliope-mongo-rs    (MongoDB 8, replica set rs0)
#        └── localhost:2222 ──► SSH bastion ──► db  (replica5, tunnel-only)
#
#  Los siete nodos MySQL/MariaDB registran consultas lentas en `mysql.slow_log`
#  (umbral 0,1 s, `log_output=TABLE`), y el generador de carga las produce
#  en seis de ellos — todos menos `db`, que sólo recibe replicación.
#
#  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.
#
#  Requirements:
#      • macOS 12 or later
#      • Docker Desktop 4.x (or Docker Engine + compose plugin)
#      • ~2 GB free RAM and ~2 GB disk space for images + volumes
#
#  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"
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 ─────────────────────────────────────────────────────
line() { printf '%s\n' "──────────────────────────────────────────────────────────────────────"; }

# 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\n" "$*"; }

# 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)"
    if [[ "$tty" == "1" ]]; then
      printf '\r  %s  %-28s %3dm%02ds  %-58s' \
        "${frames:$(( i % 4 )):1}" "$label" "$(( elapsed / 60 ))" "$(( elapsed % 60 ))" "$last"
    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 '  ·  %-28s %3dm%02ds  done\n' "$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\n" "$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 las siembras opcionales. Cada `maybe_seed_*`
# añade aquí una línea en vez de imprimir 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_PROFILE_COUNT=0
CONN_FALLBACK=0
REPLICATION_LINE="not configured yet"

usage() {
  cat <<'USAGE'
Caliope Testbed — Docker-based MySQL/MariaDB playground for Caliope.

Usage:
  caliope-testbed.command                 Extract files and start the cluster
  caliope-testbed.command --fresh         System-wide Docker cleanup + rebuild (asks confirmation).
                                          After the cluster is up, offers to download and load
                                          three sample datasets, one per engine: Sakila (MySQL /
                                          MariaDB), Pagila (PostgreSQL) and the Atlas sample
                                          data (MongoDB). Each one is asked for separately.
  caliope-testbed.command --stop          Stop containers (volumes preserved)
  caliope-testbed.command --status        Show container status
  caliope-testbed.command --logs          Follow the logs of every service
  caliope-testbed.command --help          Show this help

The full topology is created by default: 1 primary + 2 GTID replicas +
1 standalone node + 1 SSH-only replica + 1 SSH bastion + 2 MySQL nodes
(3320 latest, 3321 MySQL 8.0 with the spatial databases) + 3 PostgreSQL nodes
(3330 PostGIS working node, 3331 primary, 3332 physical standby) + 2 MongoDB
nodes (27017 authenticated, 27018 replica set) + auto-configured replication
and continuous load generators.
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.

Every run also writes caliope-testbed-connections.json next to this launcher: the
12 nodes as one importable group of 14 profiles, for Settings > Connections >
Import in Caliope.
USAGE
}

MODE="up"
case "${1:-}" in
  --help|-h) usage; exit 0 ;;
  --fresh)   MODE="fresh" ;;
  --stop)    MODE="stop" ;;
  --status)  MODE="status" ;;
  --logs)    MODE="logs" ;;
  "")        MODE="up" ;;
  *)
    printf "Unknown option: %s\n\n" "$1"
    usage
    exit 2
    ;;
esac

# ── 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}" "${MYSQL8_GIS_SEED_DIR}"

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.
#
# Los siete 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

  # ────────────────────── STANDALONE NODE ──────────────────────
  # Independent node, outside replication. Useful for isolated tests
  # or schema comparison. Applies the shared seed on first data-dir
  # initialization via a bind mount into /docker-entrypoint-initdb.d.
  standalone:
    image: mariadb:latest
    container_name: mariadb-standalone
    command:
      - --event-scheduler=ON
      - --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:
      - standalone-data:/var/lib/mysql
      - ./scripts/seed:/docker-entrypoint-initdb.d:ro
    ports:
      - "3316:3306"
    networks:
      - replication
    depends_on:
      setup:
        condition: service_completed_successfully
    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:
    image: mysql:latest
    container_name: mysql-latest
    command:
      - --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
      - ./scripts/seed:/docker-entrypoint-initdb.d:ro
    ports:
      - "3320:3306"
    networks:
      - replication
    depends_on:
      setup:
        condition: service_completed_successfully
    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:
    image: mysql:8.0
    container_name: mysql8-gis
    command:
      - --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
      - ./scripts/mysql8-gis-seed:/docker-entrypoint-initdb.d:ro
    ports:
      - "3321:3306"
    networks:
      - replication
    depends_on:
      setup:
        condition: service_completed_successfully
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-prootpass"]
      interval: 5s
      timeout: 5s
      retries: 30
      start_period: 20s
    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.
  postgres-gis:
    image: imresamu/postgis:17-3.5
    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
    environment:
      POSTGRES_USER:     caliope
      POSTGRES_PASSWORD: caliopepass
      POSTGRES_DB:       demo
    volumes:
      - postgres-gis-data:/var/lib/postgresql/data
      - ./scripts/pg-seed:/docker-entrypoint-initdb.d:ro
    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:
    image: imresamu/postgis:17-3.5
    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
      - ./scripts/pg-setup:/docker-entrypoint-initdb.d:ro
    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

  # 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
  #
  # 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:
    image: mongo:8.2
    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
      - ./scripts/mongo-seed:/docker-entrypoint-initdb.d:ro
    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.
  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"
    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:'127.0.0.1:27018'}]}).ok }"
      interval: 5s
      timeout: 10s
      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:
    image: mongo:8.2
    container_name: caliope-mongo-rs-setup
    depends_on:
      mongo-rs:
        condition: service_healthy
    volumes:
      - ./scripts/mongo-seed:/seed:ro
    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
        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

  # ── 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

  # ── 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}
    depends_on:
      primary:
        condition: service_healthy
      replica1:
        condition: service_healthy
      replica2:
        condition: service_healthy
      db:
        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:
  standalone-data:
  db-data:
  mysql-latest-data:
  mysql8-data:
  postgres-gis-data:
  postgres-primary-data:
  postgres-replica-data:
  mongo-data:
  mongo-rs-data:

# ────────────────────── 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

  # ── 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"
REPLICAS=(replica1 replica2 db)

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() {
  $CLIENT -h "$1" <<-SQL
    STOP SLAVE;
    CHANGE MASTER TO
      MASTER_HOST     = 'primary',
      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)

  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 (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[@]}"
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`. Seis de los siete nodos MySQL/MariaDB del testbed: `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"
  "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..."

wait_for "$PRIMARY"
for r in "${REPLICAS[@]}"; do wait_for "$r"; done
echo "All 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 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 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.

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).

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.

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).

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)

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.

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.

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.

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.

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.

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.

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' } ] });

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

  # ── testbed-servers.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.
  #
  # 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.
  # `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.
  cat > "${WORKDIR}/testbed-servers.json" <<'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 --config ~/CaliopeTestbed/testbed-servers.json"
  ],
  "servers": {
    "primary":           { "host": "127.0.0.1", "port": 3311,  "user": "caliope", "password": "caliopepass", "engine": "mariadb",  "container": "mariadb-primary",     "client": "mariadb", "containerPort": 3306 },
    "replica1":          { "host": "127.0.0.1", "port": 3312,  "user": "caliope", "password": "caliopepass", "engine": "mariadb",  "container": "mariadb-replica1",    "client": "mariadb", "containerPort": 3306 },
    "replica2":          { "host": "127.0.0.1", "port": 3313,  "user": "caliope", "password": "caliopepass", "engine": "mariadb",  "container": "mariadb-replica2",    "client": "mariadb", "containerPort": 3306 },
    "standalone":        { "host": "127.0.0.1", "port": 3316,  "user": "caliope", "password": "caliopepass", "engine": "mariadb",  "container": "mariadb-standalone",  "client": "mariadb", "containerPort": 3306 },
    "mysql-latest":      { "host": "127.0.0.1", "port": 3320,  "user": "caliope", "password": "caliopepass", "engine": "mysql",    "container": "mysql-latest",        "client": "mysql",   "containerPort": 3306 },
    "mysql8-gis":        { "host": "127.0.0.1", "port": 3321,  "user": "caliope", "password": "caliopepass", "engine": "mysql",    "container": "mysql8-gis",          "client": "mysql",   "containerPort": 3306 },
    "postgres-gis":      { "host": "127.0.0.1", "port": 3330,  "user": "caliope", "password": "caliopepass", "engine": "postgres", "container": "postgres-gis",        "client": "psql",    "containerPort": 5432 },
    "postgres-primary":  { "host": "127.0.0.1", "port": 3331,  "user": "caliope", "password": "caliopepass", "engine": "postgres", "container": "postgres-primary",    "client": "psql",    "containerPort": 5432 },
    "postgres-replica":  { "host": "127.0.0.1", "port": 3332,  "user": "caliope", "password": "caliopepass", "engine": "postgres", "container": "postgres-replica",    "client": "psql",    "containerPort": 5432 },
    "mongo":             { "host": "127.0.0.1", "port": 27017, "user": "caliope", "password": "caliopepass", "engine": "mongodb",  "container": "caliope-mongo",       "client": "mongosh", "containerPort": 27017 },
    "mongo-rs":          { "host": "127.0.0.1", "port": 27018, "user": "caliope", "password": "caliopepass", "engine": "mongodb",  "container": "caliope-mongo-rs",    "client": "mongosh", "containerPort": 27018 }
  }
}
SERVERS_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

  # 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."
}

# ==============================================================================
#  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}"
  echo "Stopping Caliope testbed containers (volumes preserved)..."
  docker compose -f "${COMPOSE_FILE}" stop
  echo "Done. Use --fresh for a full system-wide Docker cleanup (asks confirmation)."
}

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}"
  docker compose -f "${COMPOSE_FILE}" ps
}

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
}

maybe_seed_sakila() {
  # Only offered on a fresh start. Downloads from dev.mysql.com every time
  # (no local cache, no vendored copy). Loads into the primary (which
  # propagates to replica1, replica2 and db via GTID) and each independent
  # server.
  if [[ "${MODE}" != "fresh" ]]; then
    return 0
  fi

  echo ""
  line
  echo " Sakila sample database"
  line
  echo ""
  echo " Sakila is MySQL's official sample schema (film rental store): 16 tables,"
  echo " ~1000 films, ~16000 rentals, plus views, triggers, procedures and functions."
  echo " Handy for exploring Caliope's ER diagram, Visual EXPLAIN and catalog views."
  echo ""
  echo " Load target:"
  echo "   • mariadb-primary (3311)   — propagates to replica1, replica2 and db via GTID"
  echo "   • mariadb-standalone (3316)"
  echo "   • mysql-latest (3320)"
  echo "   • mysql8-gis (3321)"
  echo ""
  echo " Source: https://downloads.mysql.com/docs/sakila-db.tar.gz (downloaded fresh)"
  echo ""
  read -r -p " Download and load Sakila into every server? [y/N] " reply
  echo ""
  case "$reply" in
    [yY]|[yY][eE][sS]) ;;
    *) dataset_report "Sakila" "not loaded — declined; --fresh asks again"; line; echo ""; return 0 ;;
  esac

  local tmp_dir
  tmp_dir="$(mktemp -d -t caliope-sakila.XXXXXX)"

  step "Sakila: downloading sakila-db.tar.gz…"
  if ! logged curl -fSL --max-time 180 \
       "https://downloads.mysql.com/docs/sakila-db.tar.gz" \
       -o "${tmp_dir}/sakila-db.tar.gz"; then
    step "Sakila: download failed — skipped"
    dataset_report "Sakila" "not loaded — the download failed"
    rm -rf "${tmp_dir}"
    return 0
  fi

  if ! logged tar -xzf "${tmp_dir}/sakila-db.tar.gz" -C "${tmp_dir}"; then
    step "Sakila: the archive could not be extracted — skipped"
    dataset_report "Sakila" "not loaded — the archive could not be extracted"
    rm -rf "${tmp_dir}"
    return 0
  fi

  local schema_sql="${tmp_dir}/sakila-db/sakila-schema.sql"
  local data_sql="${tmp_dir}/sakila-db/sakila-data.sql"

  if [[ ! -f "$schema_sql" || ! -f "$data_sql" ]]; then
    step "Sakila: sakila-schema.sql or sakila-data.sql missing in the archive — skipped"
    dataset_report "Sakila" "not loaded — the archive did not carry schema and data"
    rm -rf "${tmp_dir}"
    return 0
  fi

  # service_name:client_binary (MariaDB containers use 'mariadb', MySQL use 'mysql').
  local targets=(
    "primary:mariadb"
    "standalone:mariadb"
    "mysql-latest:mysql"
    "mysql8-gis:mysql"
  )

  local target failed=0 loaded=0 films="?"
  for target in "${targets[@]}"; do
    local service="${target%%:*}"
    local client="${target##*:}"

    step "Sakila: loading into ${service}…"
    if ! docker compose -f "${COMPOSE_FILE}" exec -T "${service}" \
         "${client}" -u root -prootpass < "$schema_sql" >> "$RUN_LOG" 2>&1; then
      step "Sakila: ${service} rejected the schema — this server is skipped"
      failed=$(( failed + 1 ))
      continue
    fi

    if ! docker compose -f "${COMPOSE_FILE}" exec -T "${service}" \
         "${client}" -u root -prootpass sakila < "$data_sql" >> "$RUN_LOG" 2>&1; then
      step "Sakila: ${service} rejected the data"
      failed=$(( failed + 1 ))
      continue
    fi

    films="$(docker compose -f "${COMPOSE_FILE}" exec -T "${service}" \
             "${client}" -u root -prootpass -N -B -e \
             "SELECT COUNT(*) FROM sakila.film" 2>/dev/null | tr -d '\r' || echo "?")"
    loaded=$(( loaded + 1 ))
  done

  rm -rf "${tmp_dir}"

  if [[ "$failed" -eq 0 ]]; then
    dataset_report "Sakila" "loaded on ${loaded} servers · sakila.film: ${films} rows · replica1, replica2 and db catch up via GTID"
  else
    dataset_report "Sakila" "loaded on ${loaded} of ${#targets[@]} servers · ${failed} failed, see the run log"
  fi
}

# ── MongoDB sample data — el «Sakila» de Mongo ───────────────────────────────
# Mismo trato que Sakila: sólo se ofrece en --fresh, 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 los dos nodos Mongo del testbed.
maybe_seed_mongo_sample() {
  if [[ "${MODE}" != "fresh" ]]; then
    return 0
  fi

  echo ""
  line
  echo " MongoDB sample data"
  line
  echo ""
  echo " MongoDB's equivalent of Sakila: the Atlas sample dataset — 9 databases,"
  echo " 23 collections. Decimal128 money, deeply nested documents, GeoJSON with"
  echo " 2dsphere indexes, capped collections, views and 1536-float arrays: the"
  echo " variety Caliope's document browser has to survive."
  echo ""
  echo " Load target:"
  echo "   • caliope-mongo (27017)     — authenticated"
  echo "   • caliope-mongo-rs (27018)  — replica set rs0"
  echo ""
  echo " Source: https://atlas-education.s3.amazonaws.com/sampledata.archive  (380 MB)"
  echo " Fallback if unreachable: zips.json + primer-dataset.json  (14 MB total)"
  echo ""
  echo " Note: mongorestore warns that the archive was dumped from a different"
  echo " server version. Measured on 2026-08-09 (8.0.26 → 8.2.12): 425367 documents"
  echo " restored, 0 failed. The warning is conservative, not a problem."
  echo ""
  read -r -p " Download and load the MongoDB sample data? [y/N] " reply
  echo ""
  case "$reply" in
    [yY]|[yY][eE][sS]) ;;
    *) dataset_report "MongoDB" "not loaded — declined; --fresh asks again"
       line; echo ""; return 0 ;;
  esac

  local tmp_dir
  tmp_dir="$(mktemp -d -t caliope-mongo-sample.XXXXXX)"

  # mongo:<service>:<extra args for the client inside that container>
  local mongo_targets=(
    "mongo:--username caliope --password caliopepass --authenticationDatabase admin"
    "mongo-rs:--port 27018"
  )

  local failed=0 loaded=""

  step "MongoDB: downloading sampledata.archive (380 MB)…"
  if logged curl -fSL --max-time 900 \
       "https://atlas-education.s3.amazonaws.com/sampledata.archive" \
       -o "${tmp_dir}/sampledata.archive"; then
    local target service args
    for target in "${mongo_targets[@]}"; do
      service="${target%%:*}"
      args="${target#*:}"
      step "MongoDB: restoring the archive into ${service}…"
      # --drop reemplaza sólo las colecciones que vienen en el archivo, así que
      # caliope_demo (el seed propio del testbed) se queda intacta.
      if ! docker compose -f "${COMPOSE_FILE}" exec -T "${service}" \
           mongorestore $args --archive --drop --quiet \
           < "${tmp_dir}/sampledata.archive" >> "$RUN_LOG" 2>&1; then
        step "MongoDB: ${service} rejected the archive"
        failed=$(( failed + 1 ))
        continue
      fi
    done
    loaded="atlas"
  else
    step "MongoDB: the Atlas archive is unreachable — falling back to the two official datasets"

    # db:collection:url
    local small=(
      "sample_training:zips:https://media.mongodb.org/zips.json"
      "sample_restaurants:restaurants:https://raw.githubusercontent.com/mongodb/docs-assets/primer-dataset/primer-dataset.json"
    )
    local entry db coll url file target service args
    for entry in "${small[@]}"; do
      db="${entry%%:*}"
      coll="$(printf '%s' "$entry" | cut -d: -f2)"
      url="$(printf '%s' "$entry" | cut -d: -f3-)"
      file="${tmp_dir}/${db}.${coll}.json"

      step "MongoDB: downloading ${coll}…"
      if ! logged curl -fSL --max-time 300 "$url" -o "$file"; then
        step "MongoDB: ${coll} could not be downloaded — skipped"
        failed=$(( failed + 1 ))
        continue
      fi

      for target in "${mongo_targets[@]}"; do
        service="${target%%:*}"
        args="${target#*:}"
        if ! docker compose -f "${COMPOSE_FILE}" exec -T "${service}" \
             mongoimport $args --db "$db" --collection "$coll" --drop --quiet \
             < "$file" >> "$RUN_LOG" 2>&1; then
          step "MongoDB: ${service} rejected ${db}.${coll}"
          failed=$(( failed + 1 ))
          continue
        fi
      done
    done
    loaded="fallback"
  fi

  rm -rf "${tmp_dir}"

  if [[ "$failed" -gt 0 ]]; then
    dataset_report "MongoDB" "loaded with ${failed} error(s), see the run log · caliope_demo untouched"
  elif [[ "$loaded" == "atlas" ]]; then
    dataset_report "MongoDB" "Atlas set on both nodes · 9 sample_* databases, 23 collections · caliope_demo untouched"
  else
    dataset_report "MongoDB" "fallback set on both nodes · sample_training.zips and sample_restaurants.restaurants · the Atlas archive was unreachable"
  fi
}

# ── Pagila — el «Sakila» de PostgreSQL ───────────────────────────────────────
# Mismo trato que Sakila y que los datos de ejemplo de Mongo: sólo se ofrece en
# `--fresh`, 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"

maybe_seed_pagila() {
  if [[ "${MODE}" != "fresh" ]]; then
    return 0
  fi

  echo ""
  line
  echo " Pagila sample database (PostgreSQL)"
  line
  echo ""
  echo " Pagila is the official PostgreSQL port of Sakila: same film-rental schema,"
  echo " same table names, same row counts — so both engines hold the same data and"
  echo " can be compared side by side. It adds what MySQL cannot show the same way:"
  echo " tsvector full-text search with its trigger, domains, a native ENUM type, a"
  echo " range-partitioned payment table and two materialized views."
  echo ""
  echo " Load target:"
  echo "   • postgres-gis (3330)"
  echo "   • postgres-primary (3331) — reaches postgres-replica (3332) by streaming"
  echo ""
  echo " Source: https://github.com/devrimgunduz/pagila  (tag ${PAGILA_VERSION}, ~3 MB)"
  echo " Pinned on purpose: pagila master needs PostgreSQL 18 (uuidv7, VIRTUAL"
  echo " generated columns, pgvector) and this testbed runs 17."
  echo ""
  echo " Note: this REPLACES any existing 'pagila' database on those two nodes."
  echo " The testbed's own demo and rrhh databases are untouched."
  echo ""
  read -r -p " Download and load Pagila? [y/N] " reply
  echo ""
  case "$reply" in
    [yY]|[yY][eE][sS]) ;;
    *) dataset_report "Pagila" "not loaded — declined; --fresh asks again"; line; echo ""; return 0 ;;
  esac

  local tmp_dir
  tmp_dir="$(mktemp -d -t caliope-pagila.XXXXXX)"

  local base="https://raw.githubusercontent.com/devrimgunduz/pagila/${PAGILA_VERSION}"
  local f
  for f in pagila-schema.sql pagila-data.sql; do
    step "Pagila: downloading ${f}…"
    if ! logged curl -fSL --max-time 600 "${base}/${f}" -o "${tmp_dir}/${f}"; then
      step "Pagila: the download failed — skipped"
      dataset_report "Pagila" "not loaded — the download failed"
      rm -rf "${tmp_dir}"
      return 0
    fi
  done

  local targets=("postgres-gis" "postgres-primary")
  local svc failed=0
  local psql_base=(psql -U caliope -v ON_ERROR_STOP=1 --quiet)

  local films="?"
  for svc in "${targets[@]}"; do
    step "Pagila: preparing ${svc}…"

    # El rol al que el volcado asigna la propiedad. `|| true` porque en la
    # segunda pasada ya está, y eso no es un fallo.
    docker compose -f "${COMPOSE_FILE}" exec -T "${svc}" \
      "${psql_base[@]}" -d postgres \
      -c "CREATE ROLE postgres SUPERUSER LOGIN PASSWORD 'caliopepass'" >/dev/null 2>&1 || true

    # FORCE corta las conexiones abiertas contra la base: sin eso el `DROP`
    # falla en cuanto alguien tenga Calíope mirando pagila, y el mensaje habla
    # de usuarios activos, no de lo que se estaba intentando hacer.
    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" >/dev/null 2>&1; then
      step "Pagila: the pagila database could not be created on ${svc} — this node is skipped"
      failed=$(( failed + 1 ))
      continue
    fi

    # `ON_ERROR_STOP=1` en los dos: media carga silenciosa es peor que ninguna,
    # porque deja una base que existe, responde y miente sobre lo que contiene.
    step "Pagila: loading into ${svc}…"
    if ! docker compose -f "${COMPOSE_FILE}" exec -T "${svc}" \
         "${psql_base[@]}" -d pagila < "${tmp_dir}/pagila-schema.sql" >> "$RUN_LOG" 2>&1; then
      step "Pagila: ${svc} rejected the schema — this node is skipped"
      failed=$(( failed + 1 ))
      continue
    fi

    if ! docker compose -f "${COMPOSE_FILE}" exec -T "${svc}" \
         "${psql_base[@]}" -d pagila < "${tmp_dir}/pagila-data.sql" >> "$RUN_LOG" 2>&1; then
      step "Pagila: ${svc} rejected the data"
      failed=$(( failed + 1 ))
      continue
    fi

    films="$(docker compose -f "${COMPOSE_FILE}" exec -T "${svc}" \
             psql -U caliope -d pagila -tAc "SELECT COUNT(*) FROM public.film" 2>/dev/null | tr -d '\r' || echo "?")"
  done

  rm -rf "${tmp_dir}"

  if [[ "$failed" -eq 0 ]]; then
    dataset_report "Pagila" "loaded on postgres-gis and postgres-primary · public.film: ${films} rows (1000 expected) · postgres-replica gets it by streaming"
  else
    dataset_report "Pagila" "loaded with ${failed} error(s), see the run log"
  fi
}

# ══════════════════════════════════════════════════════════════════════════════
#  Archivo de conexiones importable en Calíope
# ══════════════════════════════════════════════════════════════════════════════
#
# Escribe los doce 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 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.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».
  local alt_host=""
  [[ "$host_ip" != "127.0.0.1" ]] && alt_host="$host_ip"

  # 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
  #
  # `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».
  local nodes=(
    "mariadb-primary · 3311 (R/W)|mariadb|127.0.0.1|3311|demo|caliope|false"
    "mariadb-replica1 · 3312|mariadb|127.0.0.1|3312|demo|caliope|false"
    "mariadb-replica2 · 3313|mariadb|127.0.0.1|3313|demo|caliope|false"
    "mariadb-standalone · 3316|mariadb|127.0.0.1|3316|demo|caliope|false"
    "mysql-latest · 3320|mysql|127.0.0.1|3320|demo|caliope|false"
    "mysql8-gis · 3321|mysql|127.0.0.1|3321|demo|caliope|false"
    "mysql8-gis · 3321 (base gis)|mysql|127.0.0.1|3321|gis|caliope|false"
    "postgres-gis · 3330 (PostGIS)|postgres|127.0.0.1|3330|demo|caliope|false"
    "postgres-primary · 3331|postgres|127.0.0.1|3331|demo|caliope|false"
    "postgres-replica · 3332 (sólo lectura)|postgres|127.0.0.1|3332|demo|caliope|false"
    "caliope-mongo · 27017|mongodb|127.0.0.1|27017|caliope_demo|caliope|false"
    "caliope-mongo-rs · 27018 (rs0, sin auth)|mongodb|127.0.0.1|27018|caliope_demo||false"
    "mariadb-db · replica5 (túnel SSH)|mariadb|db|3306|demo|caliope|true"
    "caliope-mongo · túnel SSH|mongodb|mongo|27017|caliope_demo|caliope|true"
  )

  local body="" sep="" rec
  for rec in "${nodes[@]}"; do
    local n_name n_engine n_host n_port n_db n_user n_ssh
    IFS='|' read -r n_name n_engine n_host n_port n_db n_user n_ssh <<< "$rec"

    local p_alt="$alt_host" p_ssh_host="" p_ssh_port=22 p_ssh_user="" p_cipher="$pw_cipher"
    if [[ "$n_ssh" == "true" ]]; then
      p_alt=""
      p_ssh_host="$ssh_host"
      p_ssh_port=2222
      p_ssh_user="tunneluser"
    fi
    [[ -z "$n_user" ]] && 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},
      \"sshAuthMethod\": \"password\",
      \"sshHost\": \"${p_ssh_host}\",
      \"sshPort\": ${p_ssh_port},
      \"sshPrivateKeyPath\": \"\",
      \"sshUsername\": \"${p_ssh_user}\",
      \"sslCaFile\": \"\",
      \"sslCertFile\": \"\",
      \"sslKeyFile\": \"\",
      \"sslSkipVerification\": false,
      \"useCount\": 0,
      \"useSSH\": ${n_ssh},
      \"useSSL\": false,
      \"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 dos perfiles de tunel SSH (tunnel123) hay que teclearla: solo vive en el Llavero."
  ],
  "groups": [
    {
      "id": "${group_id}",
      "isExpanded": true,
      "name": "Cama de pruebas",
      "sortOrder": 0
    }
  ],
  "profiles": [
${body}
  ]
}
JSON_EOF

  CONN_FILE="${out_file}"
  CONN_PROFILE_COUNT="${#nodes[@]}"
  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##*:}"
    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.
  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."
}

# ══════════════════════════════════════════════════════════════════════════════
#  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"

  # nodo|puerto|qué es. El puerto es el publicado en el Mac; `db` no publica
  # ninguno y va abajo, con los dos destinos de túnel.
  local rows=(
    "mariadb-primary|3311|MariaDB · read / write · GTID source"
    "mariadb-replica1|3312|MariaDB · read only"
    "mariadb-replica2|3313|MariaDB · read only"
    "mariadb-standalone|3316|MariaDB · read / write · outside replication"
    "mysql-latest|3320|MySQL latest · read / write"
    "mysql8-gis|3321|MySQL 8.0 · read / write · demo + gis, gis_cartesian"
    "postgres-gis|3330|PostGIS 17 · read / write · databases demo and rrhh"
    "postgres-primary|3331|Postgres 17 · read / write · streaming source"
    "postgres-replica|3332|Postgres 17 · read only · physical standby"
    "caliope-mongo|27017|MongoDB 8 · authenticated"
    "caliope-mongo-rs|27018|MongoDB 8 · replica set rs0 · no auth"
    "bastion SSH|2222|tunnel entry for db and mongo"
  )

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

  echo ""
  line
  printf " Caliope testbed — ready   ·   %s\n" "$(date '+%Y-%m-%d %H:%M')"
  line
  echo ""

  printf "  Where to connect\n"
  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
  printf "    %-22s %s\n" "User / password" "caliope / caliopepass"
  printf "    %-22s %s\n" "Default database" "demo  ·  caliope_demo on the MongoDB nodes"
  echo ""

  printf "  Nodes\n"
  printf "    %-20s %-7s %-7s %s\n" "Node" "Port" "State" "What it is"
  printf "    %-20s %-7s %-7s %s\n" "--------------------" "-------" "-------" "----------"
  for row in "${rows[@]}"; do
    IFS='|' read -r name port what <<< "$row"
    if nc -z -G 3 "$host_ip" "$port" 2>/dev/null; then
      state="open"
      open_count=$(( open_count + 1 ))
    else
      state="CLOSED"
      all_ok=false
    fi
    printf "    %-20s %-7s %-7s %s\n" "$name" "$port" "$state" "$what"
  done
  echo ""
  if ! $all_ok; then
    printf "    %d of %d ports answered. For the ones that did not:\n" "$open_count" "${#rows[@]}"
    printf "    System Settings › Network › Firewall → allow Docker Desktop.\n"
    echo ""
  fi

  printf "  Through the SSH bastion   (port 2222, tunneluser / tunnel123)\n"
  printf "    %-16s %s\n" "mariadb-db" "db:3306 · replica5, database demo · no other way in"
  printf "    %-16s %s\n" "caliope-mongo" "mongo:27017 · that name only resolves inside Docker"
  echo ""

  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 ""

  if [[ "$DATASET_COUNT" -gt 0 ]]; then
    printf "  Sample datasets\n"
    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

  printf "  Replication\n"
  printf "    %s\n" "$REPLICATION_LINE"
  echo ""

  if [[ -n "$CONN_FILE" ]]; then
    printf "  Connection profiles to import into Caliope\n"
    printf "    %s\n" "$CONN_FILE"
    printf "    %s profiles in one group, \"Cama de pruebas\". Settings › Connections ›\n" "$CONN_PROFILE_COUNT"
    printf "    Import, which MERGES: nothing you already have is replaced.\n"
    if [[ "$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 two 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

  printf "  Command line   (127.0.0.1 on this Mac; from anywhere else use %s)\n" "$host_ip"
  printf "    mysql -h 127.0.0.1 -P 3311 -u caliope -pcaliopepass demo\n"
  printf "    psql -h 127.0.0.1 -p 3330 -U caliope demo\n"
  printf "    mongosh 'mongodb://caliope:caliopepass@127.0.0.1:27017/caliope_demo?authSource=admin'\n"
  printf "    ssh -p 2222 tunneluser@127.0.0.1\n"
  printf "    ssh -N -L 13306:db:3306 tunneluser@127.0.0.1 -p 2222    # 13306 is then replica5\n"
  printf "    docker compose -f %s exec primary mariadb -u caliope -pcaliopepass demo\n" "${COMPOSE_FILE}"
  echo ""

  printf "  Files and commands\n"
  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.json"
  printf "    %-20s %s --status\n" "Container status" "$0"
  printf "    %-20s %s --logs\n" "Follow the logs" "$0"
  printf "    %-20s %s --stop\n" "Stop the cluster" "$0"
  printf "    %-20s %s --fresh\n" "Wipe and rebuild" "$0"
  line
  echo ""
}

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.
  local host_ip=""
  local iface_used=""
  local default_iface
  default_iface=$(route -n get default 2>/dev/null | awk '/interface:/{print $2}')
  if [[ -n "$default_iface" ]]; then
    local candidate
    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
      local candidate
      candidate=$(ipconfig getifaddr "$iface" 2>/dev/null || true)
      if [[ -n "$candidate" ]]; then
        host_ip="$candidate"
        iface_used="$iface"
        break
      fi
    done
  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"
  ls -1t "${LOG_DIR}"/run-*.log 2>/dev/null | tail -n +11 | while read -r old_log; do
    rm -f "$old_log"
  done

  echo ""
  line
  printf " Caliope testbed — starting   ·   %s\n" "$(date '+%Y-%m-%d %H:%M')"
  line
  echo ""

  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 "   1. docker stop \$(docker ps -a -q)      → stops EVERY container on your Mac"
    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 on your Mac, 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

  # `--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.
  if ! run_with_progress "Building and starting" \
       docker compose -f "${COMPOSE_FILE}" up -d --build --remove-orphans; then
    show_log_tail "docker compose up failed, so the cluster is NOT running."
    exit 1
  fi

  # La espera de la replicación también lleva indicador: configurar tres
  # réplicas incluye un volcado del primario cuando alguna necesita reponerse,
  # y eso son minutos sin una línea en pantalla.
  # Wait for the setup container to exist (up -d may return before Compose
  # finished creating dependents).
  local waited=0
  while ! docker inspect mariadb-setup >/dev/null 2>&1; do
    sleep 1
    waited=$(( waited + 1 ))
    if [[ "$waited" -ge 30 ]]; then
      step "the mariadb-setup container did not appear within 30 s"
      break
    fi
  done

  # `docker logs -f` sobre un contenedor concreto termina solo cuando el
  # contenedor para, así que esto ES la espera. Y va al registro, no a la
  # pantalla: son cientos de líneas y lo único que hace falta de ellas es si la
  # replicación quedó en pie, que se lee justo debajo. Un solo proceso escribe
  # la terminal, así que tampoco hay salida que se descoloque — la versión que
  # seguía el log en segundo plano y lo mataba metía el volcado del contenedor
  # dentro de la comprobación de puertos.
  run_with_progress "Configuring replication" \
    docker logs -f mariadb-setup || true

  # El veredicto de la replicación se lee del registro, que es donde quedó.
  local reps failed_reps
  reps="$(grep -o 'Replication configured successfully. Replicas: [0-9]*' "$RUN_LOG" | tail -1 | awk '{print $NF}')"
  failed_reps="$(grep -o '[0-9]* replica(s) could not be synchronized' "$RUN_LOG" | tail -1 || true)"
  if [[ -n "$reps" ]]; then
    REPLICATION_LINE="${reps} replicas reading from mariadb-primary (replica1, replica2 and db)"
    step "Replication configured."
  elif [[ -n "$failed_reps" ]]; then
    REPLICATION_LINE="${failed_reps} — the rest of the testbed is up; see the run log"
    step "WARNING: ${failed_reps}"
  else
    REPLICATION_LINE="unknown — mariadb-setup left no verdict in the run log"
    step "WARNING: replication state unknown, see the run log."
  fi

  ensure_spatial_databases

  # Las tres siembras opcionales van antes del informe y sólo se ofrecen en
  # `--fresh`. Cada una pregunta y descarga, pero ya no imprime su resumen: deja
  # una línea en DATASET_LINES y sale en el informe, con las demás.
  maybe_seed_sakila
  maybe_seed_mongo_sample
  maybe_seed_pagila

  write_connection_import_file "$host_ip"

  final_report "$host_ip" "$iface_used"
}

# ==============================================================================
#  Dispatch
# ==============================================================================
case "$MODE" in
  up|fresh) do_up ;;
  stop)     do_stop ;;
  status)   do_status ;;
  logs)     do_logs ;;
  *)        usage; exit 2 ;;
esac
