/*
 * (c) Copyright Ascensio System SIA 2010-2024
 *
 * This program is a free software product. You can redistribute it and/or
 * modify it under the terms of the GNU Affero General Public License (AGPL)
 * version 3 as published by the Free Software Foundation. In accordance with
 * Section 7(a) of the GNU AGPL its Section 15 shall be amended to the effect
 * that Ascensio System SIA expressly excludes the warranty of non-infringement
 * of any third-party rights.
 *
 * This program is distributed WITHOUT ANY WARRANTY; without even the implied
 * warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR  PURPOSE. For
 * details, see the GNU AGPL at: http://www.gnu.org/licenses/agpl-3.0.html
 *
 * You can contact Ascensio System SIA at 20A-6 Ernesta Birznieka-Upish
 * street, Riga, Latvia, EU, LV-1050.
 *
 * The  interactive user interfaces in modified source and object code versions
 * of the Program must display Appropriate Legal Notices, as required under
 * Section 5 of the GNU AGPL version 3.
 *
 * Pursuant to Section 7(b) of the License you must retain the original Product
 * logo when distributing the program. Pursuant to Section 7(e) we decline to
 * grant you any rights under trademark law for use of our trademarks.
 *
 * All the Product's GUI elements, including illustrations and icon sets, as
 * well as technical writing content are licensed under the terms of the
 * Creative Commons Attribution-ShareAlike 4.0 International. See the License
 * terms at http://creativecommons.org/licenses/by-sa/4.0/legalcode
 *
 */

const {describe, test, expect, afterAll} = require('@jest/globals');
const config = require('../../../Common/node_modules/config');

const baseConnector = require('../../../DocService/sources/databaseConnectors/baseConnector');
const operationContext = require('../../../Common/sources/operationContext');
const taskResult = require('../../../DocService/sources/taskresult');
const commonDefines = require('../../../Common/sources/commondefines');
const constants = require('../../../Common/sources/constants');
const utils = require('../../../Common/sources/utils');
const configSql = config.get('services.CoAuthoring.sql');

const ctx = new operationContext.Context();
const cfgDbType = configSql.get('type');
const cfgTableResult = configSql.get('tableResult');
const cfgTableChanges = configSql.get('tableChanges');
const dbTypes = {
  oracle: {
    number: 'NUMBER',
    string: 'NVARCHAR(50)'
  },
  mssql: {
    number: 'INT',
    string: 'NVARCHAR(50)'
  },
  mysql: {
    number: 'INT',
    string: 'VARCHAR(50)'
  },
  dameng: {
    number: 'INT',
    string: 'VARCHAR(50)'
  },
  postgres: {
    number: 'INT',
    string: 'VARCHAR(50)'
  },
  number() {
    return this[cfgDbType].number;
  },
  string() {
    return this[cfgDbType].string;
  }
};

const insertCases = {
  5: 'baseConnector-insert()-tester-5-rows',
  500: 'baseConnector-insert()-tester-500-rows',
  1000: 'baseConnector-insert()-tester-1000-rows',
  5000: 'baseConnector-insert()-tester-5000-rows',
  10000: 'baseConnector-insert()-tester-10000-rows'
};
const largeChangeCase = 'baseConnector-insert()-tester-large-change';
const changesCases = {
  range: 'baseConnector-getChangesPromise()-tester',
  index: 'baseConnector-getChangesIndexPromise()-tester',
  delete: 'baseConnector-deleteChangesPromise()-tester'
};
const emptyCallbacksCase = [
  'baseConnector-getEmptyCallbacks()-tester-0',
  'baseConnector-getEmptyCallbacks()-tester-1',
  'baseConnector-getEmptyCallbacks()-tester-2',
  'baseConnector-getEmptyCallbacks()-tester-3',
  'baseConnector-getEmptyCallbacks()-tester-4'
];
const documentsWithChangesCase = ['baseConnector-getDocumentsWithChanges()-tester-0', 'baseConnector-getDocumentsWithChanges()-tester-1'];
const getExpiredCase = ['baseConnector-getExpired()-tester-0', 'baseConnector-getExpired()-tester-1', 'baseConnector-getExpired()-tester-2'];
const getCountWithStatusCase = ['baseConnector-getCountWithStatusCase()-tester-0'];
const upsertCases = {
  insert: 'baseConnector-upsert()-tester-row-inserted',
  update: 'baseConnector-upsert()-tester-row-updated'
};
const updateIfCases = {
  notEmpty: 'baseConnector-updateIf()-tester-not-empty-callback',
  emptyCallback: 'baseConnector-updateIf()-tester-empty-callback'
};
const oracleNullHandlingCases = ['baseConnector-oracle-nclob-null-handling', 'baseConnector-oracle-nclob-null-handling-2'];

function createChanges(changesLength, date) {
  const objChanges = [
    {
      docid: '__ffff_127.0.0.1new.docx41692082262909',
      change: '"64;AgAAADEA//8BAG+X6xGnEAMAjgAAAAIAAAAEAAAABAAAAAUAAACCAAAAggAAAA4AAAAwAC4AMAAuADAALgAwAA=="',
      time: date,
      user: 'uid-18',
      useridoriginal: 'uid-1'
    }
  ];

  const length = changesLength - 1;
  for (let i = 1; i <= length; i++) {
    objChanges.push({
      docid: '__ffff_127.0.0.1new.docx41692082262909',
      change: '"39;CgAAADcAXwA2ADQAMAACABwAAQAAAAAAAAABAAAALgAAAAAAAAAA"',
      time: date,
      user: 'uid-18',
      useridoriginal: 'uid-1'
    });
  }

  return objChanges;
}

async function getRowsCountById(table, id) {
  const result = await executeSql(`SELECT COUNT(id) AS count FROM ${table} WHERE id = '${id}';`);
  // Return type of COUNT() in postgres is bigint which treats as string by connector. Dameng DB returns js bigint type.
  return Number(result[0].count);
}

async function noRowsExistenceCheck(table, id) {
  const noRows = await getRowsCountById(table, id);
  expect(noRows).toEqual(0);
}

function deleteRowsByIds(table, ids) {
  const idToDelete = ids.map(id => `id = '${id}'`).join(' OR ');
  return executeSql(`DELETE FROM ${table} WHERE ${idToDelete};`);
}

function executeSql(sql, values = []) {
  return new Promise((resolve, reject) => {
    baseConnector.sqlQuery(
      ctx,
      sql,
      (error, result) => {
        if (error) {
          reject(error);
        } else {
          resolve(result);
        }
      },
      false,
      false,
      values
    );
  });
}

function createTask(id, callback = '', baseurl = '') {
  const task = new taskResult.TaskResultData();
  task.tenant = ctx.tenant;
  task.key = id;
  task.status = commonDefines.FileStatus.None;
  task.statusInfo = constants.NO_ERROR;
  task.callback = callback;
  task.baseurl = baseurl;
  task.completeDefaults();

  return task;
}

function insertIntoResultTable(dateNow, task) {
  let cbInsert = task.callback;
  if (task.callback) {
    const userCallback = new baseConnector.UserCallback();
    userCallback.fromValues(task.userIndex, task.callback);
    cbInsert = userCallback.toSQLInsert();
  }

  const columns = ['tenant', 'id', 'status', 'status_info', 'last_open_date', 'user_index', 'change_id', 'callback', 'baseurl'];
  const values = [];
  const placeholder = [
    baseConnector.addSqlParameter(task.tenant, values),
    baseConnector.addSqlParameter(task.key, values),
    baseConnector.addSqlParameter(task.status, values),
    baseConnector.addSqlParameter(task.statusInfo, values),
    baseConnector.addSqlParameter(dateNow, values),
    baseConnector.addSqlParameter(task.userIndex, values),
    baseConnector.addSqlParameter(task.changeId, values),
    baseConnector.addSqlParameter(cbInsert, values),
    baseConnector.addSqlParameter(task.baseurl, values)
  ];

  return executeSql(`INSERT INTO ${cfgTableResult}(${columns.join(', ')}) VALUES(${placeholder.join(', ')});`, values);
}

afterAll(async () => {
  const insertIds = Object.values(insertCases);
  const changesIds = Object.values(changesCases);
  const upsertIds = Object.values(upsertCases);
  const updateIfIds = Object.values(updateIfCases);

  const tableChangesIds = [...emptyCallbacksCase, ...documentsWithChangesCase, ...changesIds, ...insertIds, largeChangeCase];
  const tableResultIds = [
    ...emptyCallbacksCase,
    ...documentsWithChangesCase,
    ...getExpiredCase,
    ...getCountWithStatusCase,
    ...upsertIds,
    ...updateIfIds,
    ...oracleNullHandlingCases
  ];

  const deletionPool = [
    deleteRowsByIds(cfgTableChanges, tableChangesIds),
    deleteRowsByIds(cfgTableResult, tableResultIds),
    executeSql('DROP TABLE test_table;')
  ];

  await Promise.allSettled(deletionPool);
  baseConnector.closePool?.();
}, 10000); //default timeout is 5000ms. increased to 10000ms to prevent timeout on Oracle DB

// Assumed that at least default DB was installed and configured.
describe('Base database connector', () => {
  test('Availability of configured DB', async () => {
    const result = await baseConnector.healthCheck(ctx);

    expect(result.length).toEqual(1);
  });

  test('Correct return format of requested rows', async () => {
    const result = await baseConnector.healthCheck(ctx);

    // The [[constructor]] field is referring to a parent class instance, so for Object-like values it is equal to itself.
    expect(result.constructor).toEqual(Array);
    // SQL in healthCheck() request column with value 1, so we expect only one value. The default format, that used here is [{ columnName: columnValue }, { columnName: columnValue }].
    expect(result.length).toEqual(1);
    expect(result[0].constructor).toEqual(Object);
    expect(Object.values(result[0]).length).toEqual(1);
    // Value itself.
    expect(Object.values(result[0])[0]).toEqual(1);
  });

  test('Correct return format of changing in DB', async () => {
    const createTableSql = `CREATE TABLE test_table(num ${dbTypes.number()});`;
    const alterTableSql = `INSERT INTO test_table VALUES(1);`;

    await executeSql(createTableSql);
    const result = await executeSql(alterTableSql);

    expect(result).toEqual({affectedRows: 1});
  });

  describe('DB tables existence', () => {
    const tables = {
      [cfgTableResult]: constants.TABLE_RESULT_SCHEMA.map(column => {
        return {column_name: column};
      }),
      [cfgTableChanges]: constants.TABLE_CHANGES_SCHEMA.map(column => {
        return {column_name: column};
      })
    };

    for (const table in tables) {
      test(`${table} table existence`, async () => {
        const result = await baseConnector.getTableColumns(ctx, table);
        for (const row of tables[table]) {
          expect(result).toContainEqual(row);
        }
      });
    }

    const table = 'unused_table';
    test(`${table} table absence`, async () => {
      const result = await baseConnector.getTableColumns(ctx, table);
      expect(result).toEqual([]);
    });
  });

  describe('Changes manipulations', () => {
    const date = new Date();
    const index = 0;
    const user = {
      id: 'uid-18',
      idOriginal: 'uid-1',
      username: 'John Smith',
      indexUser: 8,
      view: false
    };

    describe('Add changes', () => {
      for (const testCase in insertCases) {
        // Increase timeout for large inserts (5000+ rows can take longer on some databases)
        const timeout = +testCase >= 5000 ? 15000 : 5000;
        test(
          `${testCase} rows inserted`,
          async () => {
            const docId = insertCases[testCase];
            const objChanges = createChanges(+testCase, date);

            await noRowsExistenceCheck(cfgTableChanges, docId);

            await baseConnector.insertChangesPromise(ctx, objChanges, docId, index, user);
            const result = await getRowsCountById(cfgTableChanges, docId);

            expect(result).toEqual(objChanges.length);
          },
          timeout
        );
      }
    });

    test('Insert change with large change_data (> 8188 bytes)', async () => {
      const docId = largeChangeCase;
      // Create string > 8188 bytes to trigger CLOB handling in Dameng
      // VARCHAR_PREC in dmdb is 8188 bytes, strings longer than this are treated as CLOB
      const largeString = 'A'.repeat(10000);
      const objChanges = [
        {
          docid: docId,
          change: largeString,
          time: date,
          user: 'uid-large',
          useridoriginal: 'uid-large-orig'
        }
      ];

      await noRowsExistenceCheck(cfgTableChanges, docId);

      await baseConnector.insertChangesPromise(ctx, objChanges, docId, index, user);
      const result = await getRowsCountById(cfgTableChanges, docId);

      expect(result).toEqual(1);
    });

    describe('Get and delete changes', () => {
      const changesCount = 10;
      const objChanges = createChanges(changesCount, date);

      test('Get changes in range', async () => {
        const docId = changesCases.range;
        const additionalChangesCount = 5;
        const dayBefore = new Date();
        dayBefore.setDate(dayBefore.getDate() - 1);
        const limitedByDateChanges = createChanges(additionalChangesCount, dayBefore);
        const fullChanges = [...objChanges, ...limitedByDateChanges];

        await noRowsExistenceCheck(cfgTableChanges, docId);

        await baseConnector.insertChangesPromise(ctx, fullChanges, docId, index, user);

        const result = await baseConnector.getChangesPromise(ctx, docId, index, changesCount);
        expect(result.length).toEqual(changesCount);

        dayBefore.setSeconds(dayBefore.getSeconds() + 1);
        const resultByDate = await baseConnector.getChangesPromise(ctx, docId, index, changesCount + additionalChangesCount, dayBefore);
        expect(resultByDate.length).toEqual(additionalChangesCount);
      });

      test('Get changes index', async () => {
        const docId = changesCases.index;

        await noRowsExistenceCheck(cfgTableChanges, docId);

        await baseConnector.insertChangesPromise(ctx, objChanges, docId, index, user);

        const result = await baseConnector.getChangesIndexPromise(ctx, docId);

        // We created 10 changes rows, change_id: 0..9, changes index is MAX(change_id).
        const expected = [{change_id: 9}];
        expect(result).toEqual(expected);
      });

      test('Delete changes', async () => {
        const docId = changesCases.delete;

        await baseConnector.insertChangesPromise(ctx, objChanges, docId, index, user);

        // Deleting 6 rows.
        await baseConnector.deleteChangesPromise(ctx, docId, 4);

        const result = await getRowsCountById(cfgTableChanges, docId);

        // Rest rows.
        expect(result).toEqual(4);
      });
    });

    test('Get empty callbacks', async () => {
      const idCount = 5;
      const notNullCallbacks = idCount - 2;

      const resultBefore = await baseConnector.getEmptyCallbacks(ctx);

      // Adding non-empty callbacks.
      for (let i = 0; i < notNullCallbacks; i++) {
        const task = createTask(emptyCallbacksCase[i], 'some_callback');
        await insertIntoResultTable(date, task);
      }

      // Adding empty callbacks.
      for (let i = notNullCallbacks; i < idCount; i++) {
        const task = createTask(emptyCallbacksCase[i], '');
        await insertIntoResultTable(date, task);
      }

      // Adding same amount of changes with same tenant and id.
      const objChanges = createChanges(1, date);
      for (let i = 0; i < idCount; i++) {
        await baseConnector.insertChangesPromise(ctx, objChanges, emptyCallbacksCase[i], index, user);
      }

      const resultAfter = await baseConnector.getEmptyCallbacks(ctx);

      expect(resultAfter.length).toEqual(resultBefore.length + idCount - notNullCallbacks);
    });

    test('Get documents with changes', async () => {
      const objChanges = createChanges(1, date);

      const resultBeforeNewRows = await baseConnector.getDocumentsWithChanges(ctx);

      for (const id of documentsWithChangesCase) {
        const task = createTask(id);
        await Promise.all([baseConnector.insertChangesPromise(ctx, objChanges, id, index, user), insertIntoResultTable(date, task)]);
      }

      const resultAfterNewRows = await baseConnector.getDocumentsWithChanges(ctx);
      expect(resultAfterNewRows.length).toEqual(resultBeforeNewRows.length + documentsWithChangesCase.length);
    });

    test('Get expired', async () => {
      const maxCount = 100;
      const dayBefore = new Date();
      dayBefore.setDate(dayBefore.getDate() - 1);

      const resultBeforeNewRows = await baseConnector.getExpired(ctx, maxCount, 0);

      for (const id of getExpiredCase) {
        const task = createTask(id);
        await insertIntoResultTable(dayBefore, task);
      }

      // 3 rows were added.
      const resultAfterNewRows = await baseConnector.getExpired(ctx, maxCount + 3, 0);

      expect(resultAfterNewRows.length).toEqual(resultBeforeNewRows.length + getExpiredCase.length);
    });

    test('Get Count With Status', async () => {
      let countWithStatus;
      const unknownStatus = 99; //to avoid collision with running server
      const EXEC_TIMEOUT = 30000 + utils.getConvertionTimeout(undefined);
      countWithStatus = await baseConnector.getCountWithStatus(ctx, unknownStatus, EXEC_TIMEOUT);
      expect(countWithStatus).toEqual(0);
      for (const id of getCountWithStatusCase) {
        const task = createTask(id);
        task.status = unknownStatus;
        await insertIntoResultTable(date, task);
      }
      countWithStatus = await baseConnector.getCountWithStatus(ctx, unknownStatus, EXEC_TIMEOUT);
      expect(countWithStatus).toEqual(getCountWithStatusCase.length);
    });
  });

  describe('upsert() method', () => {
    test('New row inserted', async () => {
      const task = createTask(upsertCases.insert);

      await noRowsExistenceCheck(cfgTableResult, task.key);

      const result = await baseConnector.upsert(ctx, task);

      // isInsert should be true because of insert operation, insertId should be 1 by default.
      const expected = {isInsert: true, insertId: 1};
      expect(result).toEqual(expected);

      const insertedResult = await getRowsCountById(cfgTableResult, task.key);

      expect(insertedResult).toEqual(1);
    });

    test('Row updated', async () => {
      const task = createTask(upsertCases.update, '', 'some-url');

      await noRowsExistenceCheck(cfgTableResult, task.key);

      await baseConnector.upsert(ctx, task);

      // Changing baseurl to verify upsert() changing the row.
      task.baseurl = 'some-updated-url';
      const result = await baseConnector.upsert(ctx, task);

      // isInsert should be false because of update operation, insertId should be 2 by updating clause.
      const expected = {isInsert: false, insertId: 2};
      expect(result).toEqual(expected);

      const updatedRow = await executeSql(`SELECT id, baseurl FROM ${cfgTableResult} WHERE id = '${task.key}';`);

      const expectedUrlChanges = [{id: task.key, baseurl: 'some-updated-url'}];
      expect(updatedRow).toEqual(expectedUrlChanges);
    });
  });

  describe('Oracle NCLOB null handling', () => {
    const nullHandlingCase = 'baseConnector-oracle-nclob-null-handling';

    test('Empty callback is retrieved as empty string (not null)', async () => {
      const date = new Date();
      const task = createTask(nullHandlingCase, ''); // Empty callback

      await noRowsExistenceCheck(cfgTableResult, task.key);
      await insertIntoResultTable(date, task);

      // Retrieve the row and check callback field
      const result = await executeSql(`SELECT callback, baseurl FROM ${cfgTableResult} WHERE id = '${task.key}';`);

      expect(result.length).toEqual(1);
      // Oracle should normalize null NCLOB to empty string
      expect(result[0].callback).toEqual('');
      expect(result[0].baseurl).toEqual('');
      // Verify they are strings, not null
      expect(typeof result[0].callback).toEqual('string');
      expect(typeof result[0].baseurl).toEqual('string');
    });

    test('Null callback does not cause TypeError in getCallbackByUserIndex', async () => {
      const date = new Date();
      const task = createTask(nullHandlingCase + '-2', '');

      await insertIntoResultTable(date, task);

      const result = await executeSql(`SELECT callback FROM ${cfgTableResult} WHERE id = '${task.key}';`);

      // This should not throw TypeError
      const userCallback = new baseConnector.UserCallback();
      expect(() => {
        userCallback.getCallbackByUserIndex(ctx, result[0].callback, 1);
      }).not.toThrow();

      const callbackResult = userCallback.getCallbackByUserIndex(ctx, result[0].callback, 1);
      expect(callbackResult).toEqual('');
    });
  });

  describe('updateIf() method', () => {
    test('Update with NOT_EMPTY callback mask', async () => {
      const date = new Date();
      const taskWithCallback = createTask(updateIfCases.notEmpty, 'http://example.com/callback');
      const taskEmptyCallback = createTask(updateIfCases.emptyCallback, '');

      // Insert two rows: one with callback, one without
      await Promise.all([insertIntoResultTable(date, taskWithCallback), insertIntoResultTable(date, taskEmptyCallback)]);

      // Update mask: only update rows with non-empty callback and status=None
      const mask = new taskResult.TaskResultData();
      mask.tenant = ctx.tenant;
      mask.key = taskWithCallback.key;
      mask.status = commonDefines.FileStatus.None;
      mask.callback = 'NOT_EMPTY';

      // Update task: change status to SaveVersion
      const updateTask = new taskResult.TaskResultData();
      updateTask.status = commonDefines.FileStatus.SaveVersion;
      updateTask.statusInfo = constants.NO_ERROR;

      const result = await taskResult.updateIf(ctx, updateTask, mask);

      // Should update exactly 1 row (the one with callback)
      expect(result.affectedRows).toEqual(1);

      // Verify the row with callback was updated
      const updatedRow = await executeSql(`SELECT status FROM ${cfgTableResult} WHERE id = '${taskWithCallback.key}';`);
      expect(updatedRow[0].status).toEqual(commonDefines.FileStatus.SaveVersion);

      // Verify the row without callback was NOT updated
      const notUpdatedRow = await executeSql(`SELECT status FROM ${cfgTableResult} WHERE id = '${taskEmptyCallback.key}';`);
      expect(notUpdatedRow[0].status).toEqual(commonDefines.FileStatus.None);
    });
  });
});
