import { Injectable, Logger } from "@nestjs/common";
import { execFile } from "node:child_process";
import { promisify } from "node:util";

const exec = promisify(execFile);

export interface ReplicationStatus {
  /** false when the replica is not configured for this environment at all. */
  configured: boolean;
  /** true only when both threads run, no error is set, and lag is within bounds. */
  healthy: boolean;
  host: string | null;
  ioRunning: string | null;
  sqlRunning: string | null;
  /** null when MySQL reports NULL, which it does whenever a thread is down. */
  secondsBehind: number | null;
  lastIoError: string | null;
  lastSqlError: string | null;
  sourceHost: string | null;
  /** Human-readable reason when healthy is false. */
  problem: string | null;
  checkedAt: string;
}

/**
 * Reports whether the my.jorgecuadros.com read replica is still replicating.
 *
 * The replica is what the public site reads once the platformDataSource flag is
 * on, and a replica that has silently stopped applying serves stale balances
 * rather than erroring — the failure is invisible from the site itself, which is
 * why it needs a panel.
 *
 * Shells out to the mysql client for the same reason the rest of OpsService
 * does: there is no MySQL driver in this API's dependencies, and the image
 * already ships one.
 */
@Injectable()
export class ReplicationService {
  private readonly logger = new Logger(ReplicationService.name);

  /** Lag above this many seconds is reported as unhealthy. */
  private readonly maxLagSeconds = Number(process.env.REPLICA_MAX_LAG ?? 60);

  async status(): Promise<ReplicationStatus> {
    const host = process.env.REPLICA_DB_HOST;
    const user = process.env.REPLICA_DB_USER;
    const password = process.env.REPLICA_DB_PASS;
    const now = new Date().toISOString();

    const empty: ReplicationStatus = {
      configured: false,
      healthy: false,
      host: host ?? null,
      ioRunning: null,
      sqlRunning: null,
      secondsBehind: null,
      lastIoError: null,
      lastSqlError: null,
      sourceHost: null,
      problem: null,
      checkedAt: now,
    };

    if (!host || !user || !password) {
      return { ...empty, problem: "REPLICA_DB_* no configuradas" };
    }

    let raw: string;
    try {
      // --ssl is required: the replica sets require_secure_transport=ON.
      //
      // --ssl-verify-server-cert=0 is deliberate and is NOT the same trade-off
      // the website makes. This hop never leaves Tailscale — the replica is
      // reached on its CGNAT tailnet address and its firewall admits only this
      // host — so WireGuard already authenticates the peer. The DreamHost leg
      // crosses the public internet and therefore pins the CA instead. The
      // client here is MariaDB's, which rejects our self-signed CA outright
      // unless it is handed the CA file, which would mean shipping a cert into
      // this image for a link that is already authenticated.
      const { stdout } = await exec(
        "mysql",
        [
          `--host=${host}`,
          `--user=${user}`,
          "--ssl",
          "--ssl-verify-server-cert=0",
          "--connect-timeout=5",
          "-e",
          "SHOW REPLICA STATUS\\G",
        ],
        {
          env: { ...process.env, MYSQL_PWD: password },
          timeout: 15_000,
        },
      );
      raw = stdout;
    } catch (e) {
      const msg = e instanceof Error ? e.message : String(e);
      this.logger.warn(`no se pudo consultar la réplica: ${msg}`);
      return { ...empty, configured: true, problem: `No se pudo conectar: ${msg}` };
    }

    const field = (name: string): string | null => replicaField(raw, name);

    // An empty result set means the server is not configured as a replica at
    // all — distinct from "configured but broken", and worth saying plainly.
    if (!raw.includes("Replica_IO_Running")) {
      return {
        ...empty,
        configured: true,
        problem: "El servidor no está configurado como réplica",
      };
    }

    const ioRunning = field("Replica_IO_Running");
    const sqlRunning = field("Replica_SQL_Running");
    const lagRaw = field("Seconds_Behind_Source");
    const secondsBehind =
      lagRaw === null || lagRaw === "NULL" ? null : Number(lagRaw);
    const lastIoError = field("Last_IO_Error");
    const lastSqlError = field("Last_SQL_Error");

    // Order matters: report the most specific cause first. Checking lag before
    // the threads would blame "sin dato de retraso" for what is really a
    // stopped thread, because MySQL reports NULL lag whenever either is down.
    let problem: string | null = null;
    if (ioRunning !== "Yes") problem = "El hilo de E/S no está corriendo";
    else if (sqlRunning !== "Yes") problem = "El hilo SQL no está corriendo";
    else if (lastSqlError) problem = `Error SQL: ${lastSqlError}`;
    else if (lastIoError) problem = `Error de E/S: ${lastIoError}`;
    else if (secondsBehind === null) problem = "Sin dato de retraso";
    else if (secondsBehind > this.maxLagSeconds)
      problem = `Retraso de ${secondsBehind}s (máximo ${this.maxLagSeconds}s)`;

    return {
      configured: true,
      healthy: problem === null,
      host,
      ioRunning,
      sqlRunning,
      secondsBehind,
      lastIoError,
      lastSqlError,
      sourceHost: field("Source_Host"),
      problem,
      checkedAt: now,
    };
  }
}

/**
 * Read one field out of `SHOW REPLICA STATUS\G` output.
 *
 * Exported for testing, and worth testing: the obvious regex is wrong.
 * `\s` matches newlines in JavaScript, so `^\s*NAME:\s*(.*)$` lets the `\s*`
 * after the colon swallow the line break of an EMPTY field and capture the
 * following line instead. Last_SQL_Error is empty on a healthy replica, so that
 * version reported the next line ("Replicate_Ignore_Server_Ids:") as a SQL
 * error and rendered a perfectly healthy replica as broken.
 *
 * Hence `[^\S\n]` — horizontal whitespace only — on both sides of the name.
 *
 * @returns the trimmed value, or null when the field is absent OR empty. Empty
 *          and absent mean the same thing to every caller here: MySQL prints
 *          error fields as blank rather than omitting them.
 */
export function replicaField(raw: string, name: string): string | null {
  const m = raw.match(new RegExp(`^[^\\S\\n]*${name}:[^\\S\\n]*(.*)$`, "m"));
  const v = m?.[1]?.trim();
  return v === undefined || v === "" ? null : v;
}
