#!/usr/bin/env node
/**
 * Take a mysqldump immediately before a deploy runs `prisma migrate deploy`.
 *
 * The dump runs in a DEDICATED, throwaway container built from the MySQL image,
 * with the API's backup volume mounted — not inside the API container. Three
 * reasons, each learned the hard way:
 *
 *  1. Deadlock. Dumping inside the API container makes the backup depend on
 *     whatever toolchain that image happens to carry. When the image shipped a
 *     MySQL client that could not authenticate, the backup failed, which blocked
 *     the very deploy that would have replaced the broken image. The backup must
 *     not depend on the thing being deployed.
 *  2. The right client. Alpine's `mysql-client` is MariaDB's and cannot perform
 *     caching_sha2_password (MySQL 8.4's default auth). The official MySQL image
 *     obviously can.
 *  3. Diagnosability. A container's logs can simply be read, whereas a detached
 *     exec reports nothing but an exit code.
 *
 * The file still lands in the API's BACKUP_DIR volume, because the only restore
 * path this platform has is the "Operaciones" admin screen, which lists whatever
 * `*.sql.gz` sits there (apps/api/src/ops/ops.service.ts).
 *
 * It must run BEFORE the app stack is re-applied, while the old container is up
 * — that container is how the backup volume's name is discovered.
 *
 * Required env:
 *   PORTAINER_URL           https://<host>:9443
 *   PORTAINER_API_KEY       Portainer access token
 *   PORTAINER_ENDPOINT_ID   numeric endpoint id (galactus = 3)
 *   DATABASE_URL            mysql://user:pass@host:port/db — host/port/db only
 *   MYSQL_ROOT_PASSWORD     the dump runs as root, see below
 *   BACKUP_TAG              label for the filename, e.g. the deployed tag
 * Optional env:
 *   ALLOW_MISSING_CONTAINER=true   exit 0 when no API container exists yet
 *   BACKUP_VOLUME           override the auto-discovered volume name
 *   DUMP_IMAGE              default mysql:8.4
 *   API_CONTAINER_LABEL     default io.jorgecuadros.role=api
 *   TAILSCALE_DNS / FALLBACK_DNS / TAILNET_SUFFIX
 *   EXEC_TIMEOUT_SECONDS    default 1800
 *
 * Why root: mysqldump --single-transaction issues FLUSH TABLES, which needs the
 * global RELOAD (or FLUSH_TABLES) privilege. The application user is granted
 * only ALL ON `<db>`.* by the MySQL image and deliberately has no global rights,
 * so it cannot take a consistent dump. Backups are an administrative operation;
 * elevating the app's own runtime user instead would be the worse trade.
 *
 * TLS: Portainer here is self-signed; the caller sets
 * NODE_TLS_REJECT_UNAUTHORIZED=0 for this step.
 */

function required(name) {
  const v = process.env[name];
  if (!v) {
    console.error(`missing required env: ${name}`);
    process.exit(1);
  }
  return v;
}

const PORTAINER_URL = required("PORTAINER_URL").replace(/\/+$/, "");
const API_KEY = required("PORTAINER_API_KEY");
const ENDPOINT_ID = required("PORTAINER_ENDPOINT_ID");
const DATABASE_URL = required("DATABASE_URL");
const ROOT_PASSWORD = required("MYSQL_ROOT_PASSWORD");
const BACKUP_TAG = required("BACKUP_TAG");

const CONTAINER_LABEL =
  process.env.API_CONTAINER_LABEL ?? "io.jorgecuadros.role=api";
const ALLOW_MISSING = process.env.ALLOW_MISSING_CONTAINER === "true";
const DUMP_IMAGE = process.env.DUMP_IMAGE ?? "mysql:8.4";
const DNS = [
  process.env.TAILSCALE_DNS ?? "100.100.100.100",
  process.env.FALLBACK_DNS ?? "1.1.1.1",
];
const DNS_SEARCH = [process.env.TAILNET_SUFFIX ?? "tail01aa2.ts.net"];
const TIMEOUT_MS = Number(process.env.EXEC_TIMEOUT_SECONDS ?? 1800) * 1000;

const DOCKER = `${PORTAINER_URL}/api/endpoints/${ENDPOINT_ID}/docker`;

async function docker(path, init = {}) {
  const res = await fetch(`${DOCKER}${path}`, {
    ...init,
    headers: {
      "X-API-Key": API_KEY,
      ...(init.body ? { "Content-Type": "application/json" } : {}),
      ...(init.headers ?? {}),
    },
  });
  const text = await res.text();
  if (!res.ok) {
    throw new Error(`docker ${path} -> ${res.status} ${text.slice(0, 400)}`);
  }
  return text ? JSON.parse(text) : null;
}

/** Single-quote for `sh -c`, the same discipline ops.service.ts uses. */
function shq(value) {
  return `'${String(value).replace(/'/g, `'\\''`)}'`;
}

function parseDbUrl(raw) {
  const u = new URL(raw);
  return {
    host: u.hostname,
    port: u.port || "3306",
    database: u.pathname.replace(/^\//, ""),
  };
}

/** Matches ops.service.ts's own naming: ISO, colons and dots flattened. */
function timestamp() {
  return new Date()
    .toISOString()
    .replace(/[:.]/g, "-")
    .replace("T", "_")
    .slice(0, 19);
}

/**
 * ops.service.ts refuses to restore any name outside this character set, so a
 * file written with, say, a `/` in the tag would be permanently unrestorable
 * through the UI. Sanitise before writing, not after.
 */
function safeTag(tag) {
  return tag.replace(/[^A-Za-z0-9._-]/g, "-");
}

async function findApiContainer() {
  const [key, value] = CONTAINER_LABEL.split("=");
  const filters = encodeURIComponent(
    JSON.stringify({ label: [`${key}=${value}`], status: ["running"] }),
  );
  const list = await docker(`/containers/json?filters=${filters}`);
  return list.length ? list[0] : null;
}

/** The named volume the API mounts at /data/backups — where restores look. */
function backupVolumeOf(container) {
  const mount = (container.Mounts ?? []).find(
    (m) => m.Destination === "/data/backups",
  );
  return mount?.Name ?? null;
}

/**
 * Ensure the dump image is present. A `scope: app` deploy never touches the db
 * stack, so a host can legitimately be missing it — and container/create fails
 * with a bare 404 that reads like a Portainer problem rather than a missing
 * image. The image is public, so no registry auth is involved.
 */
async function ensureDumpImage() {
  const [repo, tag = "latest"] = DUMP_IMAGE.split(":");
  const existing = await docker(`/images/${encodeURIComponent(DUMP_IMAGE)}/json`)
    .then(() => true)
    .catch(() => false);
  if (existing) return;
  console.log(`pulling ${DUMP_IMAGE} (not present on the host)...`);
  const res = await fetch(
    `${DOCKER}/images/create?fromImage=${encodeURIComponent(repo)}&tag=${encodeURIComponent(tag)}`,
    { method: "POST", headers: { "X-API-Key": API_KEY } },
  );
  const body = await res.text();
  if (!res.ok) {
    throw new Error(`pull ${DUMP_IMAGE} -> HTTP ${res.status} ${body.slice(0, 300)}`);
  }
  for (const line of body.split("\n").filter((l) => l.trim())) {
    try {
      const obj = JSON.parse(line);
      if (obj.error) throw new Error(`pull ${DUMP_IMAGE} failed: ${obj.error}`);
    } catch (e) {
      if (e.message.startsWith("pull ")) throw e;
    }
  }
}

async function runDumpContainer(cmd, env) {
  await ensureDumpImage();
  const created = await docker(`/containers/create`, {
    method: "POST",
    body: JSON.stringify({
      Image: DUMP_IMAGE,
      Entrypoint: ["sh", "-c"],
      Cmd: [cmd],
      Env: env,
      HostConfig: {
        AutoRemove: false, // we read the logs before removing it ourselves
        Binds: [`${BACKUP_VOLUME}:/data/backups`],
        Dns: DNS,
        DnsSearch: DNS_SEARCH,
      },
    }),
  });

  const id = created.Id;
  try {
    await docker(`/containers/${id}/start`, { method: "POST" });

    const deadline = Date.now() + TIMEOUT_MS;
    for (;;) {
      const info = await docker(`/containers/${id}/json`);
      if (!info.State.Running) {
        const logs = await fetch(
          `${DOCKER}/containers/${id}/logs?stdout=true&stderr=true&tail=40`,
          { headers: { "X-API-Key": API_KEY } },
        ).then((r) => r.text());
        // Strip Docker's 8-byte stream framing and any stray control bytes.
        const clean = logs
          .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, "")
          .trim();
        return { code: info.State.ExitCode ?? 1, logs: clean };
      }
      if (Date.now() > deadline) {
        throw new Error(`dump timed out after ${TIMEOUT_MS / 1000}s`);
      }
      await new Promise((r) => setTimeout(r, 3000));
    }
  } finally {
    await docker(`/containers/${id}?force=true`, { method: "DELETE" }).catch(
      () => {},
    );
  }
}

let BACKUP_VOLUME = process.env.BACKUP_VOLUME ?? null;

async function main() {
  const container = await findApiContainer();
  if (!container) {
    const message = `no running container matching label ${CONTAINER_LABEL}`;
    if (ALLOW_MISSING) {
      console.warn(`skipping pre-migrate backup: ${message}`);
      return;
    }
    throw new Error(
      `${message} — pass bootstrap=true only if this is the first deploy and ` +
        `there is genuinely no data to lose`,
    );
  }

  BACKUP_VOLUME = BACKUP_VOLUME ?? backupVolumeOf(container);
  if (!BACKUP_VOLUME) {
    throw new Error(
      "could not determine the backup volume from the API container's mounts; " +
        "set BACKUP_VOLUME explicitly",
    );
  }

  const conn = parseDbUrl(DATABASE_URL);
  const file = `pre-migrate-${safeTag(BACKUP_TAG)}-${timestamp()}.sql.gz`;
  const out = `/data/backups/${file}`;

  console.log(`database  : ${conn.host}:${conn.port}/${conn.database}`);
  console.log(`volume    : ${BACKUP_VOLUME}`);
  console.log(`image     : ${DUMP_IMAGE}`);
  console.log(`writing   : ${out}`);

  // --set-gtid-purged=OFF because this server is the replication SOURCE with
  // GTID on. Without it the dump embeds SET @@GLOBAL.GTID_PURGED, which makes
  // the file unrestorable onto the very server it came from.
  //
  // pipefail is essential: without it the exit status is gzip's, so a dump that
  // failed on the first statement still produces a small, perfectly valid .gz —
  // a "successful" backup containing nothing.
  //
  // The table count is asserted for the same reason: valid gzip is not evidence
  // of a usable dump. It is echoed so the log records how much was captured.
  //
  // A failed attempt deletes its own output. Otherwise every failure leaves a
  // truncated .sql.gz sitting in the volume, and the Operaciones restore screen
  // lists it as a perfectly ordinary restore point.
  const dump =
    `set -o pipefail; ` +
    `( mysqldump --host=${conn.host} --port=${conn.port} --user=root ` +
    `--single-transaction --routines --triggers --no-tablespaces ` +
    `--set-gtid-purged=OFF ${shq(conn.database)} | gzip -c > ${shq(out)} && ` +
    `gzip -t ${shq(out)} && ` +
    `TABLES=$(gunzip -c ${shq(out)} | grep -c 'CREATE TABLE') && ` +
    `echo "tables captured: $TABLES" && ` +
    `[ "$TABLES" -ge 1 ] ); ` +
    `rc=$?; ` +
    `if [ $rc -ne 0 ]; then rm -f ${shq(out)}; ` +
    `echo "removed incomplete backup ${file}"; fi; ` +
    `exit $rc`;

  const { code, logs } = await runDumpContainer(dump, [
    // Password via MYSQL_PWD, never argv — argv is readable through `ps`.
    `MYSQL_PWD=${ROOT_PASSWORD}`,
  ]);

  if (logs) console.log(logs);
  if (code !== 0) {
    throw new Error(
      `dump failed (exit ${code}) — refusing to migrate. See the output above.`,
    );
  }

  console.log(`ok: ${file} written and verified in ${BACKUP_VOLUME}`);
}

main().catch((err) => {
  console.error(`pre-migrate backup FAILED: ${err.message}`);
  process.exit(1);
});
