import { ASRouterStorage } from "modules/ASRouterStorage.sys.mjs";
import { GlobalOverrider } from "tests/unit/utils";

let overrider = new GlobalOverrider();

describe("ASRouterStorage", () => {
  let sandbox;
  let indexedDB;
  let storage;
  beforeEach(() => {
    sandbox = sinon.createSandbox();
    indexedDB = {
      open: sandbox.stub().resolves({}),
      deleteDatabase: sandbox.stub().resolves(),
    };
    overrider.set({ IndexedDB: indexedDB });
    storage = new ASRouterStorage({
      storeNames: ["storage_test"],
      telemetry: { handleUndesiredEvent: sandbox.stub() },
    });
  });
  afterEach(() => {
    sandbox.restore();
  });
  it("should throw if required arguments not provided", () => {
    assert.throws(() => new ASRouterStorage({ telemetry: true }));
  });
  describe(".db", () => {
    it("should not throw an error when accessing db", async () => {
      assert.ok(storage.db);
    });

    it("should delete and recreate the db if opening db fails", async () => {
      const newDb = {};
      indexedDB.open.onFirstCall().rejects(new Error("fake error"));
      indexedDB.open.onSecondCall().resolves(newDb);

      const db = await storage.db;
      assert.calledOnce(indexedDB.deleteDatabase);
      assert.calledTwice(indexedDB.open);
      assert.equal(db, newDb);
    });
  });
  describe("#getDbTable", () => {
    let testStorage;
    let storeStub;
    beforeEach(() => {
      storeStub = {
        getAll: sandbox.stub().resolves(),
        getAllKeys: sandbox.stub().resolves(),
        get: sandbox.stub().resolves(),
        put: sandbox.stub().resolves(),
      };
      sandbox.stub(storage, "_getStore").resolves(storeStub);
      testStorage = storage.getDbTable("storage_test");
    });
    it("should reverse key value parameters for put", async () => {
      await testStorage.set("key", "value");

      assert.calledOnce(storeStub.put);
      assert.calledWith(storeStub.put, "value", "key");
    });
    it("should return the correct value for get", async () => {
      storeStub.get.withArgs("foo").resolves("foo");

      const result = await testStorage.get("foo");

      assert.calledOnce(storeStub.get);
      assert.equal(result, "foo");
    });
    it("should return the correct value for getAll", async () => {
      storeStub.getAll.resolves(["bar"]);

      const result = await testStorage.getAll();

      assert.calledOnce(storeStub.getAll);
      assert.deepEqual(result, ["bar"]);
    });
    it("should return the correct value for getAllKeys", async () => {
      storeStub.getAllKeys.resolves(["key1", "key2", "key3"]);

      const result = await testStorage.getAllKeys();

      assert.calledOnce(storeStub.getAllKeys);
      assert.deepEqual(result, ["key1", "key2", "key3"]);
    });
    it("should query the correct object store", async () => {
      await testStorage.get();

      assert.calledOnce(storage._getStore);
      assert.calledWithExactly(storage._getStore, "storage_test");
    });
    it("should throw if table is not found", () => {
      assert.throws(() => storage.getDbTable("undefined_store"));
    });
  });
  it("should get the correct objectStore when calling _getStore", async () => {
    const objectStoreStub = sandbox.stub();
    indexedDB.open.resolves({ objectStore: objectStoreStub });

    await storage._getStore("foo");

    assert.calledOnce(objectStoreStub);
    assert.calledWithExactly(objectStoreStub, "foo", "readwrite");
  });
  it("should create a db with the correct store name", async () => {
    const dbStub = {
      createObjectStore: sandbox.stub(),
      objectStoreNames: { contains: sandbox.stub().returns(false) },
    };
    await storage.db;

    // call the cb with a stub
    indexedDB.open.args[0][2](dbStub);

    assert.calledOnce(dbStub.createObjectStore);
    assert.calledWithExactly(dbStub.createObjectStore, "storage_test");
  });
  it("should handle an array of object store names", async () => {
    storage = new ASRouterStorage({
      storeNames: ["store1", "store2"],
      telemetry: {},
    });
    const dbStub = {
      createObjectStore: sandbox.stub(),
      objectStoreNames: { contains: sandbox.stub().returns(false) },
    };
    await storage.db;

    // call the cb with a stub
    indexedDB.open.args[0][2](dbStub);

    assert.calledTwice(dbStub.createObjectStore);
    assert.calledWith(dbStub.createObjectStore, "store1");
    assert.calledWith(dbStub.createObjectStore, "store2");
  });
  it("should skip creating existing stores", async () => {
    storage = new ASRouterStorage({
      storeNames: ["store1", "store2"],
      telemetry: {},
    });
    const dbStub = {
      createObjectStore: sandbox.stub(),
      objectStoreNames: { contains: sandbox.stub().returns(true) },
    };
    await storage.db;

    // call the cb with a stub
    indexedDB.open.args[0][2](dbStub);

    assert.notCalled(dbStub.createObjectStore);
  });
  describe("#_requestWrapper", () => {
    it("should return a successful result", async () => {
      const result = await storage._requestWrapper(() =>
        Promise.resolve("foo")
      );

      assert.equal(result, "foo");
      assert.notCalled(storage.telemetry.handleUndesiredEvent);
    });
    it("should report failures", async () => {
      try {
        await storage._requestWrapper(() => Promise.reject(new Error()));
      } catch (e) {
        assert.calledOnce(storage.telemetry.handleUndesiredEvent);
      }
    });
  });
});

function assertMessageBlockedTransaction(mockConnection, expectedMessageId) {
  assert.callCount(mockConnection.executeCached, 2);

  const [call1, call2] = [
    mockConnection.executeCached.getCall(0),
    mockConnection.executeCached.getCall(1),
  ];

  assert.match(call1.args[0], /INSERT INTO MessagingSystemMessageBlocklist/);
  assert.match(call2.args[0], /DELETE FROM MessagingSystemMessageImpressions/);

  assert.deepEqual(call1.args[1], { messageId: expectedMessageId });
  assert.deepEqual(call2.args[1], { messageId: expectedMessageId });
}

describe("Shared database methods", () => {
  let sandbox;
  let mockConnection;
  let storage;
  let errorStub;

  beforeEach(() => {
    sandbox = sinon.createSandbox();
    errorStub = sandbox.stub();

    storage = new ASRouterStorage({
      storeNames: ["storage_test"],
      telemetry: { handleUndesiredEvent: sandbox.stub() },
    });

    mockConnection = {
      executeCached: sandbox.stub(),
      executeBeforeShutdown: sandbox.stub(),
      executeTransaction: sandbox.stub(),
    };

    mockConnection.executeBeforeShutdown.callsFake(async (label, fn) => {
      return await fn();
    });
    mockConnection.executeTransaction.callsFake(async fn => {
      return await fn();
    });

    overrider.set({
      ASRouterPreferences: {
        console: {
          error: errorStub,
        },
      },
      IndexedDB: indexedDB,
      ProfilesDatastoreService: {
        getConnection: sandbox.stub().resolves(mockConnection),
        notify: sandbox.stub(),
      },
    });
  });

  afterEach(() => {
    sandbox.restore();
    overrider.restore();
  });

  describe("#getSharedMessageImpressions", () => {
    it("should return message impressions data when records exist", async () => {
      const mockRows = [
        {
          getResultByName: columnName => {
            if (columnName === "messageId") {
              return "message1";
            }
            if (columnName === "impressions") {
              return JSON.stringify([123, 456]);
            }
            return null;
          },
        },
        {
          getResultByName: columnName => {
            if (columnName === "messageId") {
              return "message2";
            }
            if (columnName === "impressions") {
              return JSON.stringify([123, 456, 789]);
            }
            return null;
          },
        },
      ];

      mockConnection.executeCached.resolves(mockRows);

      const result = await storage.getSharedMessageImpressions();

      // Execute should be called with expected SQL
      assert.calledOnce(mockConnection.executeCached);
      assert.calledWith(
        mockConnection.executeCached,
        "SELECT messageId, json(impressions) AS impressions FROM MessagingSystemMessageImpressions;"
      );

      assert.deepEqual(result, {
        message1: [123, 456],
        message2: [123, 456, 789],
      });
    });

    it("should return null when no records exist", async () => {
      mockConnection.executeCached.resolves([]);

      const result = await storage.getSharedMessageImpressions();

      assert.ok(result === null);
    });

    it("should handle database errors and call telemetry", async () => {
      const error = new Error("Database connection failed");
      mockConnection.executeCached.rejects(error);

      const result = await storage.getSharedMessageImpressions();

      assert.equal(result, null);
      assert.calledOnce(errorStub);
      assert.calledWith(
        errorStub,
        "ASRouterStorage: Failed reading from MessagingSystemMessageImpressions",
        error
      );
      assert.calledOnce(storage.telemetry.handleUndesiredEvent);
      assert.calledWith(storage.telemetry.handleUndesiredEvent, {
        event: "SHARED_DB_READ_FAILED",
      });
    });

    it("should return null when getConnection throws an error", async () => {
      const connectionError = new Error("Failed to get database connection");

      overrider.set({
        ASRouterPreferences: {
          console: { error: errorStub },
        },
        ProfilesDatastoreService: {
          getConnection: sandbox.stub().rejects(connectionError),
          notify: sandbox.stub(),
        },
      });

      const result = await storage.getSharedMessageImpressions();

      assert.equal(result, null);
      assert.calledOnce(errorStub);
      assert.calledWith(
        errorStub,
        "ASRouterStorage: Failed reading from MessagingSystemMessageImpressions",
        connectionError
      );
      assert.calledOnce(storage.telemetry.handleUndesiredEvent);
      assert.calledWith(storage.telemetry.handleUndesiredEvent, {
        event: "SHARED_DB_READ_FAILED",
      });
    });

    it("should return null when getConnection returns null", async () => {
      overrider.set({
        ProfilesDatastoreService: {
          getConnection: sandbox.stub().resolves(null),
          notify: sandbox.stub(),
        },
      });

      const result = await storage.getSharedMessageImpressions();

      assert.equal(result, null);
    });
  });

  describe("#setSharedMessageImpressions", () => {
    it("should return a true success state when the transaction succeeds", async () => {
      mockConnection.executeCached.resolves();

      const result = await storage.setSharedMessageImpressions(
        "test_message",
        [123, 456]
      );

      assert.equal(result, true);
      assert.calledOnce(mockConnection.executeBeforeShutdown);
      assert.calledWith(
        mockConnection.executeBeforeShutdown,
        "ASRouter: setSharedMessageImpressions"
      );
    });

    it("should return a false success state when the transaction fails", async () => {
      const error = new Error("Database write failed");
      mockConnection.executeCached.rejects(error);

      const result = await storage.setSharedMessageImpressions(
        "test_message",
        [123, 456]
      );

      assert.equal(result, false);
      assert.calledOnce(errorStub);
      assert.calledWith(
        errorStub,
        "ASRouterStorage: Failed writing to MessagingSystemMessageImpressions",
        error
      );
      assert.calledOnce(storage.telemetry.handleUndesiredEvent);
      assert.calledWith(storage.telemetry.handleUndesiredEvent, {
        event: "SHARED_DB_WRITE_FAILED",
      });
    });

    it("should gracefully fail when called with no message ID", async () => {
      const result = await storage.setSharedMessageImpressions(
        null,
        [123, 456]
      );

      assert.equal(result, false);
      assert.calledOnce(errorStub);
      assert.calledOnce(storage.telemetry.handleUndesiredEvent);
      assert.calledWith(storage.telemetry.handleUndesiredEvent, {
        event: "SHARED_DB_WRITE_FAILED",
      });
    });

    it("should execute the appropriate sql query with the appropriate message ID", async () => {
      mockConnection.executeCached.resolves();

      await storage.setSharedMessageImpressions("test_message", [123, 456]);

      assert.calledOnce(mockConnection.executeCached);
      const executeCall = mockConnection.executeCached.getCall(0);

      assert.match(
        executeCall.args[0],
        /INSERT INTO MessagingSystemMessageImpressions/
      );
      assert.match(executeCall.args[0], /ON CONFLICT \(messageId\) DO UPDATE/);

      assert.deepEqual(executeCall.args[1], {
        messageId: "test_message",
        impressions: JSON.stringify([123, 456]),
      });
    });

    it("should still create a record if the array is empty", async () => {
      mockConnection.executeCached.resolves();

      await storage.setSharedMessageImpressions("test_message", []);

      assert.calledOnce(mockConnection.executeCached);
      let executeCall = mockConnection.executeCached.getCall(0);
      assert.match(
        executeCall.args[0],
        /INSERT INTO MessagingSystemMessageImpressions/
      );
      assert.deepEqual(executeCall.args[1], {
        messageId: "test_message",
        impressions: JSON.stringify([]),
      });
    });

    it("should delete the record when impressions is falsy", async () => {
      mockConnection.executeCached.resolves();

      await storage.setSharedMessageImpressions("test_message", null);

      assert.calledOnce(mockConnection.executeCached);
      let executeCall = mockConnection.executeCached.getCall(0);
      assert.match(
        executeCall.args[0],
        /DELETE FROM MessagingSystemMessageImpressions/
      );
    });

    it("should call ProfilesDatastoreService.notify() after successful operation", async () => {
      mockConnection.executeCached.resolves();

      const notifySpy = sandbox.spy();

      overrider.set({
        ProfilesDatastoreService: {
          getConnection: sandbox.stub().resolves(mockConnection),
          notify: notifySpy,
        },
      });

      await storage.setSharedMessageImpressions("test_message", [123]);
      assert.calledOnce(notifySpy);
    });

    it("should return false when getConnection throws an error", async () => {
      const connectionError = new Error("Failed to get database connection");

      overrider.set({
        ASRouterPreferences: {
          console: { error: errorStub },
        },
        ProfilesDatastoreService: {
          getConnection: sandbox.stub().rejects(connectionError),
          notify: sandbox.stub(),
        },
      });

      const result = await storage.setSharedMessageImpressions(
        "test_message",
        [123, 456]
      );

      assert.equal(result, false);
      assert.calledOnce(errorStub);
      assert.calledWith(
        errorStub,
        "ASRouterStorage: Failed writing to MessagingSystemMessageImpressions",
        connectionError
      );
      assert.calledOnce(storage.telemetry.handleUndesiredEvent);
      assert.calledWith(storage.telemetry.handleUndesiredEvent, {
        event: "SHARED_DB_WRITE_FAILED",
      });
    });

    it("should return false when getConnection returns null", async () => {
      overrider.set({
        ProfilesDatastoreService: {
          getConnection: sandbox.stub().resolves(null),
          notify: sandbox.stub(),
        },
      });

      const result = await storage.setSharedMessageImpressions(
        "test_message",
        [123, 456]
      );

      assert.equal(result, false);
    });
  });

  describe("#getSharedMessageBlocklist", () => {
    it("should return array of blocked message IDs when records exist", async () => {
      const mockRows = [
        {
          getResultByName: columnName => {
            if (columnName === "messageId") {
              return "blocked_message1";
            }
            return null;
          },
        },
        {
          getResultByName: columnName => {
            if (columnName === "messageId") {
              return "blocked_message2";
            }
            return null;
          },
        },
      ];

      mockConnection.executeCached.resolves(mockRows);

      const result = await storage.getSharedMessageBlocklist();

      // Execute should be called with expected SQL
      assert.calledOnce(mockConnection.executeCached);
      assert.calledWith(
        mockConnection.executeCached,
        "SELECT messageId FROM MessagingSystemMessageBlocklist;"
      );

      assert.deepEqual(result, ["blocked_message1", "blocked_message2"]);
    });

    it("should return empty array when no blocked messages exist", async () => {
      mockConnection.executeCached.resolves([]);

      const result = await storage.getSharedMessageBlocklist();

      assert.deepEqual(result, []);
    });

    it("should handle database errors and return null", async () => {
      const error = new Error("Database connection failed");
      mockConnection.executeCached.rejects(error);

      const result = await storage.getSharedMessageBlocklist();

      assert.equal(result, null);
      assert.calledOnce(errorStub);
      assert.calledWith(
        errorStub,
        "ASRouterStorage: Failed reading from MessagingSystemMessageBlocklist",
        error
      );

      assert.calledOnce(storage.telemetry.handleUndesiredEvent);
      assert.calledWith(storage.telemetry.handleUndesiredEvent, {
        event: "SHARED_DB_READ_FAILED",
      });
    });

    it("should return null when getConnection throws an error", async () => {
      const connectionError = new Error("Failed to get database connection");

      overrider.set({
        ASRouterPreferences: {
          console: { error: errorStub },
        },
        ProfilesDatastoreService: {
          getConnection: sandbox.stub().rejects(connectionError),
          notify: sandbox.stub(),
        },
      });

      const result = await storage.getSharedMessageBlocklist();

      assert.equal(result, null);
      assert.calledOnce(errorStub);
      assert.calledWith(
        errorStub,
        "ASRouterStorage: Failed reading from MessagingSystemMessageBlocklist",
        connectionError
      );
      assert.calledOnce(storage.telemetry.handleUndesiredEvent);
      assert.calledWith(storage.telemetry.handleUndesiredEvent, {
        event: "SHARED_DB_READ_FAILED",
      });
    });

    it("should return null when getConnection returns null", async () => {
      overrider.set({
        ProfilesDatastoreService: {
          getConnection: sandbox.stub().resolves(null),
          notify: sandbox.stub(),
        },
      });

      const result = await storage.getSharedMessageBlocklist();

      assert.equal(result, null);
    });
  });

  describe("#setSharedMessageBlocked", () => {
    it("should return true success state when blocking a message succeeds", async () => {
      mockConnection.executeCached.resolves();

      const result = await storage.setSharedMessageBlocked(
        "test_message",
        true
      );

      assert.equal(result, true);
      assert.calledOnce(mockConnection.executeTransaction);
    });

    it("should return false success state when the transaction fails", async () => {
      const error = new Error("Database write failed");
      mockConnection.executeCached
        .withArgs(sinon.match(/INSERT/))
        .rejects(error);

      const result = await storage.setSharedMessageBlocked(
        "test_message",
        true
      );

      assert.equal(result, false);
      assert.calledOnce(errorStub);
      assert.calledWith(
        errorStub,
        "ASRouterStorage: Failed writing to MessagingSystemMessageBlocklist",
        error
      );
      assert.calledOnce(storage.telemetry.handleUndesiredEvent);
      assert.calledWith(storage.telemetry.handleUndesiredEvent, {
        event: "SHARED_DB_WRITE_FAILED",
      });
    });

    it("should execute INSERT and DELETE queries in a transaction when blocking a message", async () => {
      const messageId = "test_blocking_message";
      mockConnection.executeCached.resolves();
      await storage.setSharedMessageBlocked(messageId, true);

      assertMessageBlockedTransaction(mockConnection, messageId);
    });

    it("should execute DELETE query when unblocking a message", async () => {
      mockConnection.executeCached.resolves();

      await storage.setSharedMessageBlocked("test_unblocking_message", false);

      assert.calledOnce(mockConnection.executeCached);
      const executeCall = mockConnection.executeCached.getCall(0);
      assert.match(
        executeCall.args[0],
        /DELETE FROM MessagingSystemMessageBlocklist/
      );
      assert.deepEqual(executeCall.args[1], {
        messageId: "test_unblocking_message",
      });
    });

    it("should default to blocking when isBlocked parameter is not provided", async () => {
      const messageId = "default_blocked_message";
      mockConnection.executeCached.resolves();

      await storage.setSharedMessageBlocked(messageId);

      assertMessageBlockedTransaction(mockConnection, messageId);
    });

    it("should call ProfilesDatastoreService.notify() after successful operation", async () => {
      mockConnection.executeCached.resolves();

      const notifySpy = sandbox.spy();

      overrider.set({
        ProfilesDatastoreService: {
          getConnection: sandbox.stub().resolves(mockConnection),
          notify: notifySpy,
        },
      });

      await storage.setSharedMessageBlocked("test_message", true);
      assert.calledOnce(notifySpy);
    });

    it("should return false when getConnection throws an error when blocking a message", async () => {
      const connectionError = new Error("Failed to get database connection");

      overrider.set({
        ASRouterPreferences: {
          console: { error: errorStub },
        },
        ProfilesDatastoreService: {
          getConnection: sandbox.stub().rejects(connectionError),
          notify: sandbox.stub(),
        },
      });

      const result = await storage.setSharedMessageBlocked(
        "test_message",
        true
      );

      assert.equal(result, false);
      assert.calledOnce(errorStub);
      assert.calledWith(
        errorStub,
        "ASRouterStorage: Failed writing to MessagingSystemMessageBlocklist",
        connectionError
      );
      assert.calledOnce(storage.telemetry.handleUndesiredEvent);
      assert.calledWith(storage.telemetry.handleUndesiredEvent, {
        event: "SHARED_DB_WRITE_FAILED",
      });
    });

    it("should return false when getConnection throws an error when unblocking a message", async () => {
      const connectionError = new Error("Failed to get database connection");

      overrider.set({
        ASRouterPreferences: {
          console: { error: errorStub },
        },
        ProfilesDatastoreService: {
          getConnection: sandbox.stub().rejects(connectionError),
          notify: sandbox.stub(),
        },
      });

      const result = await storage.setSharedMessageBlocked(
        "test_message",
        false
      );

      assert.equal(result, false);
      assert.calledOnce(errorStub);
      assert.calledWith(
        errorStub,
        "ASRouterStorage: Failed writing to MessagingSystemMessageBlocklist",
        connectionError
      );
      assert.calledOnce(storage.telemetry.handleUndesiredEvent);
      assert.calledWith(storage.telemetry.handleUndesiredEvent, {
        event: "SHARED_DB_WRITE_FAILED",
      });
    });

    it("should return false when getConnection returns null when blocking a mesasge", async () => {
      overrider.set({
        ProfilesDatastoreService: {
          getConnection: sandbox.stub().resolves(null),
          notify: sandbox.stub(),
        },
      });

      const result = await storage.setSharedMessageBlocked(
        "test_message",
        true
      );

      assert.equal(result, false);
    });

    it("should return false when getConnection returns null when unblokcing a message", async () => {
      overrider.set({
        ProfilesDatastoreService: {
          getConnection: sandbox.stub().resolves(null),
          notify: sandbox.stub(),
        },
      });

      const result = await storage.setSharedMessageBlocked(
        "test_message",
        false
      );

      assert.equal(result, false);
    });
  });
});
