/* Any copyright is dedicated to the Public Domain.
   http://creativecommons.org/publicdomain/zero/1.0/ */

/* eslint-disable mozilla/no-arbitrary-setTimeout */

const { setTimeout } = ChromeUtils.importESModule(
  "resource://gre/modules/Timer.sys.mjs"
);

function getProfileFile(name) {
  let file = do_get_profile();
  file.append(name);
  return file;
}

function promiseAsyncDatabase(name, openOptions = 0) {
  return new Promise((resolve, reject) => {
    let file = getProfileFile(name);
    const connOptions = Ci.mozIStorageService.CONNECTION_DEFAULT;
    Services.storage.openAsyncDatabase(
      file,
      openOptions,
      connOptions,
      (status, connection) => {
        if (!Components.isSuccessCode(status)) {
          reject(new Error(`Failed to open database: ${status}`));
        } else {
          connection.QueryInterface(Ci.mozIStorageAsyncConnection);
          resolve(connection);
        }
      }
    );
  });
}

function promiseClone(db, readOnly = false) {
  return new Promise((resolve, reject) => {
    db.asyncClone(readOnly, (status, clone) => {
      if (!Components.isSuccessCode(status)) {
        reject(new Error(`Failed to clone connection: ${status}`));
      } else {
        clone.QueryInterface(Ci.mozIStorageAsyncConnection);
        resolve(clone);
      }
    });
  });
}

function promiseClose(db) {
  return new Promise((resolve, reject) => {
    db.asyncClose(status => {
      if (!Components.isSuccessCode(status)) {
        reject(new Error(`Failed to close connection: ${status}`));
      } else {
        resolve();
      }
    });
  });
}

function promiseExecuteStatement(statement) {
  return new Promise((resolve, reject) => {
    let rows = [];
    statement.executeAsync({
      handleResult(resultSet) {
        let row = null;
        do {
          row = resultSet.getNextRow();
          if (row) {
            rows.push(row);
          }
        } while (row);
      },
      handleError(error) {
        reject(new Error(`Failed to execute statement: ${error.message}`));
      },
      handleCompletion(reason) {
        if (reason == Ci.mozIStorageStatementCallback.REASON_FINISHED) {
          resolve(rows);
        } else {
          reject(new Error("Statement failed to execute or was cancelled"));
        }
      },
    });
  });
}

add_task(async function test_retry_on_busy() {
  info("Open first writer in WAL mode and set up schema");
  let db1 = await promiseAsyncDatabase("retry-on-busy.sqlite");

  let walStmt = db1.createAsyncStatement(`PRAGMA journal_mode = WAL`);
  await promiseExecuteStatement(walStmt);
  let createAStmt = db1.createAsyncStatement(`CREATE TABLE a(
    b INTEGER PRIMARY KEY
  )`);
  await promiseExecuteStatement(createAStmt);
  let createPrevAStmt = db1.createAsyncStatement(`CREATE TEMP TABLE prevA(
    curB INTEGER PRIMARY KEY,
    prevB INTEGER NOT NULL
  )`);
  await promiseExecuteStatement(createPrevAStmt);
  let createATriggerStmt = db1.createAsyncStatement(`
    CREATE TEMP TRIGGER a_afterinsert_trigger
    AFTER UPDATE ON a FOR EACH ROW
    BEGIN
      REPLACE INTO prevA(curB, prevB) VALUES(NEW.b, OLD.b);
    END`);
  await promiseExecuteStatement(createATriggerStmt);

  info("Open second writer");
  let db2 = await promiseClone(db1);

  info("Attach second writer to new database");
  let attachStmt = db2.createAsyncStatement(`ATTACH :path AS newDB`);
  attachStmt.bindByName(
    "path",
    getProfileFile("retry-on-busy-attach.sqlite").path
  );
  await promiseExecuteStatement(attachStmt);

  info("Create triggers on second writer");
  let createCStmt = db2.createAsyncStatement(`CREATE TABLE newDB.c(
    d INTEGER PRIMARY KEY
  )`);
  await promiseExecuteStatement(createCStmt);
  let createCTriggerStmt = db2.createAsyncStatement(`
    CREATE TEMP TRIGGER c_afterdelete_trigger
    AFTER DELETE ON c FOR EACH ROW
    BEGIN
      INSERT INTO a(b) VALUES(OLD.d);
    END`);
  await promiseExecuteStatement(createCTriggerStmt);

  info("Begin transaction on second writer");
  let begin2Stmt = db2.createAsyncStatement("BEGIN IMMEDIATE");
  await promiseExecuteStatement(begin2Stmt);

  info(
    "Begin transaction on first writer; should busy-wait until second writer is done"
  );
  let begin1Stmt = db1.createAsyncStatement("BEGIN IMMEDIATE");
  let promise1Began = promiseExecuteStatement(begin1Stmt);
  let update1Stmt = db1.createAsyncStatement(`UPDATE a SET b = 3 WHERE b = 1`);
  let promise1Updated = promiseExecuteStatement(update1Stmt);

  info("Wait 5 seconds");
  await new Promise(resolve => setTimeout(resolve, 5000));

  info("Commit transaction on second writer");
  let insertIntoA2Stmt = db2.createAsyncStatement(`INSERT INTO a(b) VALUES(1)`);
  await promiseExecuteStatement(insertIntoA2Stmt);
  let insertIntoC2Stmt = db2.createAsyncStatement(`INSERT INTO c(d) VALUES(2)`);
  await promiseExecuteStatement(insertIntoC2Stmt);
  let deleteFromC2Stmt = db2.createAsyncStatement(`DELETE FROM c`);
  await promiseExecuteStatement(deleteFromC2Stmt);
  let commit2Stmt = db2.createAsyncStatement("COMMIT");
  await promiseExecuteStatement(commit2Stmt);

  info("Await and commit transaction on first writer");
  await promise1Began;
  await promise1Updated;

  let commit1Stmt = db1.createAsyncStatement("COMMIT");
  await promiseExecuteStatement(commit1Stmt);

  info("Verify our writes succeeded");
  let select1Stmt = db2.createAsyncStatement("SELECT b FROM a");
  let rows = await promiseExecuteStatement(select1Stmt);
  deepEqual(
    rows.map(row => row.getResultByName("b")),
    [2, 3]
  );

  info("Clean up");
  for (let stmt of [
    walStmt,
    createAStmt,
    createPrevAStmt,
    createATriggerStmt,
    attachStmt,
    createCStmt,
    createCTriggerStmt,
    begin2Stmt,
    begin1Stmt,
    insertIntoA2Stmt,
    insertIntoC2Stmt,
    deleteFromC2Stmt,

    commit2Stmt,
    update1Stmt,
    commit1Stmt,
    select1Stmt,
  ]) {
    stmt.finalize();
  }
  await promiseClose(db1);
  await promiseClose(db2);
});
