import { applyProgress, replicaField } from "./replication.service";

/**
 * Verbatim shape of `SHOW REPLICA STATUS\G` from the live replica, trimmed to
 * the fields the panel reads plus the neighbours that matter.
 *
 * The empty `Last_SQL_Error:` immediately followed by
 * `Replicate_Ignore_Server_Ids:` is the whole point of the fixture — that exact
 * adjacency is what the first implementation misread.
 */
const HEALTHY = [
  "*************************** 1. row ***************************",
  "             Replica_IO_State: Waiting for source to send event",
  "                  Source_Host: 100.103.77.46",
  "                  Source_User: repl",
  "              Source_Log_File: binlog.000042",
  "          Read_Source_Log_Pos: 194884231",
  "        Relay_Source_Log_File: binlog.000042",
  "          Exec_Source_Log_Pos: 194884231",
  "           Replica_IO_Running: Yes",
  "          Replica_SQL_Running: Yes",
  "              Replicate_Do_DB: ",
  "                   Last_Errno: 0",
  "                   Last_Error: ",
  "        Seconds_Behind_Source: 0",
  "                Last_IO_Errno: 0",
  "                Last_IO_Error: ",
  "               Last_SQL_Errno: 0",
  "               Last_SQL_Error: ",
  "  Replicate_Ignore_Server_Ids: ",
  "             Source_Server_Id: 1",
].join("\n");

const BROKEN = [
  "           Replica_IO_Running: Yes",
  "          Replica_SQL_Running: No",
  "        Seconds_Behind_Source: NULL",
  "                Last_IO_Error: ",
  "               Last_SQL_Error: Could not execute Write_rows event on table jorgecuadros.customers",
  "  Replicate_Ignore_Server_Ids: ",
].join("\n");

describe("replicaField", () => {
  it("reads plain values", () => {
    expect(replicaField(HEALTHY, "Replica_IO_Running")).toBe("Yes");
    expect(replicaField(HEALTHY, "Replica_SQL_Running")).toBe("Yes");
    expect(replicaField(HEALTHY, "Source_Host")).toBe("100.103.77.46");
    expect(replicaField(HEALTHY, "Seconds_Behind_Source")).toBe("0");
  });

  /**
   * The regression this file exists for. `\s` matches newlines in JavaScript,
   * so `^\s*NAME:\s*(.*)$` walks past an empty field's line break and captures
   * the NEXT line — turning a healthy replica into
   * "Error SQL: Replicate_Ignore_Server_Ids:" in the admin panel.
   */
  it("returns null for an empty field instead of the following line", () => {
    expect(replicaField(HEALTHY, "Last_SQL_Error")).toBeNull();
    expect(replicaField(HEALTHY, "Last_IO_Error")).toBeNull();
    expect(replicaField(HEALTHY, "Last_Error")).toBeNull();
    expect(replicaField(HEALTHY, "Replicate_Do_DB")).toBeNull();
    expect(replicaField(HEALTHY, "Replicate_Ignore_Server_Ids")).toBeNull();
  });

  it("still reads a real error when there is one", () => {
    expect(replicaField(BROKEN, "Last_SQL_Error")).toBe(
      "Could not execute Write_rows event on table jorgecuadros.customers",
    );
    expect(replicaField(BROKEN, "Replica_SQL_Running")).toBe("No");
  });

  /** NULL is a distinct state from empty and must survive as the literal. */
  it("preserves the literal NULL that MySQL prints for unknown lag", () => {
    expect(replicaField(BROKEN, "Seconds_Behind_Source")).toBe("NULL");
  });

  it("returns null for a field that is not present at all", () => {
    expect(replicaField(HEALTHY, "Nonexistent_Field")).toBeNull();
  });

  /**
   * Field names are matched at the start of a line. Without the line anchor,
   * "Last_Error" would also match inside "Last_SQL_Error" and read the wrong
   * value — the two carry different things and both feed the panel.
   */
  it("does not match a field name that is a suffix of another", () => {
    const raw = "               Last_SQL_Error: boom\n                   Last_Error: ";
    expect(replicaField(raw, "Last_Error")).toBeNull();
    expect(replicaField(raw, "Last_SQL_Error")).toBe("boom");
  });
});

/** Builds the four position fields the apply-progress reader cares about. */
function positions(
  sourceFile: string,
  readPos: number | string,
  relayFile: string,
  execPos: number | string,
): string {
  return [
    `              Source_Log_File: ${sourceFile}`,
    `          Read_Source_Log_Pos: ${readPos}`,
    `        Relay_Source_Log_File: ${relayFile}`,
    `          Exec_Source_Log_Pos: ${execPos}`,
  ].join("\n");
}

describe("applyProgress", () => {
  it("reports zero backlog and 100% when both positions match", () => {
    const p = applyProgress(HEALTHY)!;
    expect(p.sameFile).toBe(true);
    expect(p.sourceLogFile).toBe("binlog.000042");
    expect(p.readPos).toBe(194884231);
    expect(p.execPos).toBe(194884231);
    expect(p.backlogBytes).toBe(0);
    expect(p.percent).toBe(100);
  });

  it("reports the byte delta when the SQL thread trails inside one file", () => {
    const p = applyProgress(positions("binlog.000042", 2_000_000, "binlog.000042", 1_500_000))!;
    expect(p.backlogBytes).toBe(500_000);
    expect(p.percent).toBe(75);
  });

  /**
   * The reason the byte delta exists at all. `Seconds_Behind_Source` holds at 0
   * while the SQL thread is mid-transaction, so the backlog is the only field
   * that moves — and the only one that says the replica is not caught up.
   */
  it("shows a backlog even when the lag counter reads zero", () => {
    const raw = [
      "        Seconds_Behind_Source: 0",
      positions("binlog.000042", 900, "binlog.000042", 400),
    ].join("\n");
    expect(replicaField(raw, "Seconds_Behind_Source")).toBe("0");
    expect(applyProgress(raw)!.backlogBytes).toBe(500);
  });

  /**
   * Positions restart near 4 in every new binlog file, so subtracting across
   * files produces a number that is not a backlog — here it would be a large
   * NEGATIVE one, which would render as "ahead of the source".
   */
  it("refuses to compare positions across different binlog files", () => {
    const p = applyProgress(positions("binlog.000043", 500, "binlog.000042", 194_000_000))!;
    expect(p.sameFile).toBe(false);
    expect(p.backlogBytes).toBeNull();
    expect(p.percent).toBeNull();
    expect(p.sourceLogFile).toBe("binlog.000043");
    expect(p.relayLogFile).toBe("binlog.000042");
  });

  /**
   * Percent must not round up to 100 while bytes remain: binlog positions are
   * large, so a genuine backlog is a rounding error away from the whole file
   * and would otherwise render as "caught up" on a replica that is not.
   */
  it("stops short of 100% while any backlog remains", () => {
    const p = applyProgress(positions("binlog.000042", 194_884_231, "binlog.000042", 194_884_230))!;
    expect(p.backlogBytes).toBe(1);
    expect(p.percent).toBe(99.99);
  });

  /** Sampled independently, so a rotation racing the read can invert them. */
  it("clamps a momentarily negative delta to zero", () => {
    const p = applyProgress(positions("binlog.000042", 400, "binlog.000042", 500))!;
    expect(p.backlogBytes).toBe(0);
    expect(p.percent).toBe(100);
  });

  it("returns null when the server is not a replica and prints no positions", () => {
    expect(applyProgress("")).toBeNull();
    expect(applyProgress(BROKEN)).toBeNull();
  });

  /** A stopped thread makes MySQL print NULL, which is not a position. */
  it("returns null when a position is NULL", () => {
    expect(applyProgress(positions("binlog.000042", "NULL", "binlog.000042", 400))).toBeNull();
  });
});
