/*
 * (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
 *
 */

'use strict';

const sqlDataBaseType = {
  mySql: 'mysql',
  mariaDB: 'mariadb',
  msSql: 'mssql',
  postgreSql: 'postgres',
  dameng: 'dameng',
  oracle: 'oracle'
};

const connectorUtilities = require('./connectorUtilities');
const utils = require('../../../Common/sources/utils');
const bottleneck = require('bottleneck');
const config = require('config');

const configSql = config.get('services.CoAuthoring.sql');
const cfgTableResult = configSql.get('tableResult');
const cfgTableChanges = configSql.get('tableChanges');
const maxPacketSize = configSql.get('max_allowed_packet'); // The default size for a query to the database is 1Mb - 1 (because it does not write 1048575, but writes 1048574)
const cfgBottleneckGetChanges = config.util.cloneDeep(config.get('bottleneck.getChanges'));
const dbType = configSql.get('type');

const reservoirMaximum = cfgBottleneckGetChanges.reservoirIncreaseMaximum || cfgBottleneckGetChanges.reservoirRefreshAmount;
const group = new bottleneck.Group(cfgBottleneckGetChanges);
const g_oCriticalSection = {};

let dbInstance;
switch (dbType) {
  case sqlDataBaseType.mySql:
  case sqlDataBaseType.mariaDB:
    dbInstance = require('./mysqlConnector');
    break;
  case sqlDataBaseType.msSql:
    dbInstance = require('./mssqlConnector');
    break;
  case sqlDataBaseType.dameng:
    dbInstance = require('./damengConnector');
    break;
  case sqlDataBaseType.oracle:
    dbInstance = require('./oracleConnector');
    break;
  default:
    dbInstance = require('./postgreConnector');
    break;
}

let isSupportFastInsert = !!dbInstance.insertChanges;
const addSqlParameter = dbInstance.addSqlParameter;

function getChangesSize(changes) {
  return changes.reduce((accumulator, currentValue) => accumulator + currentValue.change_data.length, 0);
}

function insertChangesPromiseCompatibility(ctx, objChanges, docId, index, user) {
  return new Promise((resolve, reject) => {
    _insertChangesCallback(ctx, 0, objChanges, docId, index, user, (error, result) => {
      if (error) {
        reject(error);
      } else {
        resolve(result);
      }
    });
  });
}

function insertChangesPromiseFast(ctx, objChanges, docId, index, user) {
  return new Promise((resolve, reject) => {
    dbInstance.insertChanges(ctx, cfgTableChanges, 0, objChanges, docId, index, user, (error, result, isSupported) => {
      isSupportFastInsert = isSupported;
      if (error) {
        if (!isSupportFastInsert) {
          resolve(insertChangesPromiseCompatibility(ctx, objChanges, docId, index, user));
        } else {
          reject(error);
        }
      } else {
        resolve(result);
      }
    });
  });
}

function insertChangesPromise(ctx, objChanges, docId, index, user) {
  if (isSupportFastInsert) {
    return insertChangesPromiseFast(ctx, objChanges, docId, index, user);
  } else {
    return insertChangesPromiseCompatibility(ctx, objChanges, docId, index, user);
  }
}

function _getDateTime2(oDate) {
  return oDate.toISOString().slice(0, 19).replace('T', ' ');
}

function _insertChangesCallback(ctx, startIndex, objChanges, docId, index, user, callback) {
  let sqlCommand = `INSERT INTO ${cfgTableChanges} VALUES`;
  let i = startIndex,
    lengthUtf8Current = sqlCommand.length,
    lengthUtf8Row = 0;
  const l = objChanges.length;
  const values = [];
  if (i === l) {
    return;
  }

  const indexBytes = 4;
  const timeBytes = 8;
  for (; i < l; ++i, ++index) {
    //49 - length of "($1001,... $1008),"
    //4 is max utf8 bytes per symbol
    lengthUtf8Row =
      49 +
      4 * (ctx.tenant.length + docId.length + user.id.length + user.idOriginal.length + user.username.length + objChanges[i].change.length) +
      indexBytes +
      timeBytes;
    if (lengthUtf8Row + lengthUtf8Current >= maxPacketSize && i > startIndex) {
      sqlCommand += ';';
      (function (tmpStart, tmpIndex) {
        dbInstance.sqlQuery(
          ctx,
          sqlCommand,
          () => {
            // do not remove lock, but we continue to add
            _insertChangesCallback(ctx, tmpStart, objChanges, docId, tmpIndex, user, callback);
          },
          undefined,
          undefined,
          values
        );
      })(i, index);
      return;
    }
    const p0 = addSqlParameter(ctx.tenant, values);
    const p1 = addSqlParameter(docId, values);
    const p2 = addSqlParameter(index, values);
    const p3 = addSqlParameter(user.id, values);
    const p4 = addSqlParameter(user.idOriginal, values);
    const p5 = addSqlParameter(user.username, values);
    const p6 = addSqlParameter(objChanges[i].change, values);
    const p7 = addSqlParameter(objChanges[i].time, values);
    if (i > startIndex) {
      sqlCommand += ',';
    }
    sqlCommand += `(${p0},${p1},${p2},${p3},${p4},${p5},${p6},${p7})`;
    lengthUtf8Current += lengthUtf8Row;
  }

  sqlCommand += ';';
  dbInstance.sqlQuery(ctx, sqlCommand, callback, undefined, undefined, values);
}

function deleteChangesCallback(ctx, docId, deleteIndex, callback) {
  let sqlCommand;
  const values = [];
  const p1 = addSqlParameter(ctx.tenant, values);
  const p2 = addSqlParameter(docId, values);
  if (null !== deleteIndex) {
    const sqlParam2 = addSqlParameter(deleteIndex, values);
    sqlCommand = `DELETE FROM ${cfgTableChanges} WHERE tenant=${p1} AND id=${p2} AND change_id >= ${sqlParam2};`;
  } else {
    sqlCommand = `DELETE FROM ${cfgTableChanges} WHERE tenant=${p1} AND id=${p2};`;
  }
  dbInstance.sqlQuery(ctx, sqlCommand, callback, undefined, undefined, values);
}

function deleteChangesPromise(ctx, docId, deleteIndex) {
  return new Promise((resolve, reject) => {
    deleteChangesCallback(ctx, docId, deleteIndex, (error, result) => {
      if (error) {
        reject(error);
      } else {
        resolve(result);
      }
    });
  });
}

function deleteChanges(ctx, docId, deleteIndex) {
  lockCriticalSection(docId, () => {
    _deleteChanges(ctx, docId, deleteIndex);
  });
}

function _deleteChanges(ctx, docId, deleteIndex) {
  deleteChangesCallback(ctx, docId, deleteIndex, () => {
    unLockCriticalSection(docId);
  });
}

function getChangesIndex(ctx, docId, callback) {
  const values = [];
  const p1 = addSqlParameter(ctx.tenant, values);
  const p2 = addSqlParameter(docId, values);
  const sqlCommand = `SELECT MAX(change_id) as change_id FROM ${cfgTableChanges} WHERE tenant=${p1} AND id=${p2};`;
  dbInstance.sqlQuery(ctx, sqlCommand, callback, undefined, undefined, values);
}

function getChangesIndexPromise(ctx, docId) {
  return new Promise((resolve, reject) => {
    getChangesIndex(ctx, docId, (error, result) => {
      if (error) {
        reject(error);
      } else {
        resolve(result);
      }
    });
  });
}

function getChangesPromise(ctx, docId, optStartIndex, optEndIndex, opt_time) {
  const limiter = group.key(`${ctx.tenant}\t${docId}\tchanges`);
  return limiter.schedule(() => {
    return new Promise((resolve, reject) => {
      const values = [];
      let sqlParam = addSqlParameter(ctx.tenant, values);
      let sqlWhere = `tenant=${sqlParam}`;
      sqlParam = addSqlParameter(docId, values);
      sqlWhere += ` AND id=${sqlParam}`;
      if (null != optStartIndex) {
        sqlParam = addSqlParameter(optStartIndex, values);
        sqlWhere += ` AND change_id>=${sqlParam}`;
      }
      if (null != optEndIndex) {
        sqlParam = addSqlParameter(optEndIndex, values);
        sqlWhere += ` AND change_id<${sqlParam}`;
      }
      if (null != opt_time) {
        if (!(opt_time instanceof Date)) {
          opt_time = new Date(opt_time);
        }
        sqlParam = addSqlParameter(opt_time, values);
        sqlWhere += ` AND change_date<=${sqlParam}`;
      }
      sqlWhere += ' ORDER BY change_id ASC';
      const sqlCommand = `SELECT * FROM ${cfgTableChanges} WHERE ${sqlWhere};`;

      dbInstance.sqlQuery(
        ctx,
        sqlCommand,
        (error, result) => {
          if (error) {
            reject(error);
          } else {
            if (reservoirMaximum > 0) {
              const size = Math.min(getChangesSize(result), reservoirMaximum);
              limiter.incrementReservoir(-size).then(cur => {
                ctx.logger.debug('getChangesPromise bottleneck reservoir cur=%s', cur);
                resolve(result);
              });
            } else {
              resolve(result);
            }
          }
        },
        undefined,
        undefined,
        values
      );
    });
  });
}

function getDocumentsWithChanges(ctx) {
  return new Promise((resolve, reject) => {
    const sqlCommand = `SELECT * FROM ${cfgTableResult} WHERE EXISTS(SELECT id FROM ${cfgTableChanges} WHERE tenant=${cfgTableResult}.tenant AND id = ${cfgTableResult}.id LIMIT 1);`;
    dbInstance.sqlQuery(
      ctx,
      sqlCommand,
      (error, result) => {
        if (error) {
          reject(error);
        } else {
          resolve(result);
        }
      },
      false,
      false
    );
  });
}

function getExpired(ctx, maxCount, expireSeconds) {
  return new Promise((resolve, reject) => {
    const values = [];
    const expireDate = new Date();
    utils.addSeconds(expireDate, -expireSeconds);
    const date = addSqlParameter(expireDate, values);
    const count = addSqlParameter(maxCount, values);
    const sqlCommand =
      `SELECT * FROM ${cfgTableResult} WHERE last_open_date <= ${date}` +
      ` AND NOT EXISTS(SELECT tenant, id FROM ${cfgTableChanges} WHERE ${cfgTableChanges}.tenant = ${cfgTableResult}.tenant AND ${cfgTableChanges}.id = ${cfgTableResult}.id LIMIT 1) LIMIT ${count};`;
    dbInstance.sqlQuery(
      ctx,
      sqlCommand,
      (error, result) => {
        if (error) {
          reject(error);
        } else {
          resolve(result);
        }
      },
      false,
      false,
      values
    );
  });
}
function getCountWithStatus(ctx, status, expireMs) {
  return new Promise((resolve, reject) => {
    const values = [];
    const expireDate = new Date(Date.now() - expireMs);
    const sqlStatus = addSqlParameter(status, values);
    const sqlDate = addSqlParameter(expireDate, values);
    const sqlCommand = `SELECT COUNT(id) AS count FROM ${cfgTableResult} WHERE status=${sqlStatus} AND last_open_date>${sqlDate};`;
    dbInstance.sqlQuery(
      ctx,
      sqlCommand,
      (error, result) => {
        if (error) {
          reject(error);
        } else {
          const res = Number(result[0].count);
          resolve(!isNaN(res) ? res : 0);
        }
      },
      false,
      false,
      values
    );
  });
}

function isLockCriticalSection(id) {
  return !!g_oCriticalSection[id];
}

// critical section
function lockCriticalSection(id, callback) {
  if (g_oCriticalSection[id]) {
    // wait
    g_oCriticalSection[id].push(callback);
    return;
  }
  // lock
  g_oCriticalSection[id] = [];
  g_oCriticalSection[id].push(callback);
  callback();
}

function unLockCriticalSection(id) {
  const arrCallbacks = g_oCriticalSection[id];
  arrCallbacks.shift();
  if (0 < arrCallbacks.length) {
    arrCallbacks[0]();
  } else {
    delete g_oCriticalSection[id];
  }
}

function healthCheck(ctx) {
  return new Promise((resolve, reject) => {
    //SELECT 1; usefull for H2, MySQL, Microsoft SQL Server, PostgreSQL, SQLite
    //http://stackoverflow.com/questions/3668506/efficient-sql-test-query-or-validation-query-that-will-work-across-all-or-most
    dbInstance.sqlQuery(ctx, 'SELECT 1;', (error, result) => {
      if (error) {
        reject(error);
      } else {
        resolve(result);
      }
    });
  });
}

function getEmptyCallbacks(ctx) {
  return new Promise((resolve, reject) => {
    const sqlCommand = `SELECT DISTINCT t1.tenant, t1.id FROM ${cfgTableChanges} t1 LEFT JOIN ${cfgTableResult} t2 ON t2.tenant = t1.tenant AND t2.id = t1.id WHERE t2.callback = '';`;
    dbInstance.sqlQuery(ctx, sqlCommand, (error, result) => {
      if (error) {
        reject(error);
      } else {
        resolve(result);
      }
    });
  });
}

function getTableColumns(ctx, tableName) {
  return new Promise((resolve, reject) => {
    const values = [];
    const sqlParam = addSqlParameter(tableName, values);
    const sqlCommand = `SELECT column_name as "column_name" FROM information_schema.COLUMNS WHERE TABLE_NAME = ${sqlParam};`;
    dbInstance.sqlQuery(
      ctx,
      sqlCommand,
      (error, result) => {
        if (error) {
          reject(error);
        } else {
          resolve(result);
        }
      },
      undefined,
      undefined,
      values
    );
  });
}

/**
 * Generate SQL condition to check if a field is not empty (fallback implementation)
 * Database-specific connectors can override this function
 * @param {string} fieldName - Name of the field to check
 * @returns {string} SQL condition string
 */
function getNotEmptyConditionFallback(fieldName) {
  // Default implementation for most databases: check both NULL and empty string
  return `${fieldName} IS NOT NULL AND ${fieldName} != ''`;
}

module.exports = {
  insertChangesPromise,
  deleteChangesPromise,
  deleteChanges,
  getChangesIndexPromise,
  getChangesPromise,
  isLockCriticalSection,
  getDocumentsWithChanges,
  getExpired,
  getCountWithStatus,
  healthCheck,
  getEmptyCallbacks,
  getTableColumns,
  getDateTime: _getDateTime2,
  ...connectorUtilities,
  ...dbInstance,
  // Use connector-specific implementation if available, otherwise use fallback
  getNotEmptyCondition: dbInstance.getNotEmptyCondition || getNotEmptyConditionFallback
};
