/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2026, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////

import diffArray from 'diff-arrays-of-objects';
import _ from 'lodash';

import gettext from 'sources/gettext';
import { memoizeFn } from 'sources/utils';
import {
  minMaxValidator, numberValidator, integerValidator, emptyValidator,
  checkUniqueCol, isEmptyString
} from 'sources/validators';

import BaseUISchema from '../base_schema.ui';
import { isModeSupportedByField, isObjectEqual, isValueEqual } from '../common';


export const SCHEMA_STATE_ACTIONS = {
  INIT: 'init',
  SET_VALUE: 'set_value',
  ADD_ROW: 'add_row',
  DELETE_ROW: 'delete_row',
  MOVE_ROW: 'move_row',
  RERENDER: 'rerender',
  CLEAR_DEFERRED_QUEUE: 'clear_deferred_queue',
  DEFERRED_DEPCHANGE: 'deferred_depchange',
  BULK_UPDATE: 'bulk_update',
};

// Remove cid key added by prepareData
const cleanCid = (coll, keepCid=false) => (
  (!coll || keepCid) ? coll : coll.map(
    (o) => _.pickBy(o, (v, k) => (k !== 'cid'))
  )
);


export function getCollectionDiffInEditMode(
  field, origVal, sessVal, keepCid, parseChanges
) {
  let change = {};

  const id = field.id;
  const collIdAttr = field.schema.idAttribute;
  const origColl = _.get(origVal, id) || [];
  const sessColl = _.get(sessVal, id) || [];
  /*
   * Use 'diffArray' package to get the array diff and extract the
   * info. 'cid' attribute is used to identify the rows uniquely.
   */
  const changeDiff = diffArray(
    origColl, sessColl || [], 'cid', { compareFunction: isObjectEqual }
  );

  if(changeDiff.added.length > 0) {
    change['added'] = cleanCid(changeDiff.added, keepCid);
  }

  if(changeDiff.removed.length > 0) {
    change['deleted'] = cleanCid(changeDiff.removed.map((row) => {
      // Deleted records must be from the original data, not the newly added.
      return _.find(_.get(origVal, field.id), ['cid', row.cid]);
    }), keepCid);
  }

  if(changeDiff.updated.length > 0) {
    /*
     * There is a change in collection. Parse further deep to figure
     * out the exact details.
     */
    let changed = [];

    for(const changedRow of changeDiff.updated) {
      const rowIndxSess = _.findIndex(
        _.get(sessVal, id), (row) => (row.cid === changedRow.cid)
      );
      const rowIndxOrig = _.findIndex(
        _.get(origVal, id), (row) => (row.cid==changedRow.cid)
      );
      const finalChangedRow = parseChanges(
        field.schema, _.get(origVal, [id, rowIndxOrig]),
        _.get(sessVal, [id, rowIndxSess])
      );

      if(_.isEmpty(finalChangedRow)) {
        continue;
      }

      /*
       * If the 'id' attribute value is present, then only changed keys
       * can be passed. Otherwise, passing all the keys is useful.
       */
      const idAttrValue = _.get(sessVal, [id, rowIndxSess, collIdAttr]);

      if(_.isUndefined(idAttrValue)) {
        changed.push({ ...changedRow, ...finalChangedRow });
      } else {
        changed.push({ [collIdAttr]: idAttrValue, ...finalChangedRow });
      }
    }

    if(changed.length > 0) {
      change['changed'] = cleanCid(changed, keepCid);
    }
  }

  return change;
}

export function getSchemaDataDiff(
  topSchema, initData, sessData, mode, keepCid,
  stringify=false, includeSkipChange=true
) {
  const isEditMode = mode === 'edit';

  // This will be executed recursively as data can be nested.
  let parseChanges = (schema, origVal, sessVal) => {
    let levelChanges = {};
    parseChanges.depth =
      _.isUndefined(parseChanges.depth) ? 0 : (parseChanges.depth + 1);

    /* The comparator and setter */
    const attrChanged = (id, change, force=false) => {
      if(isValueEqual(_.get(origVal, id), _.get(sessVal, id)) && !force) {
        return;
      }

      change = change || _.get(sessVal, id);

      if(stringify && (_.isArray(change) || _.isObject(change))) {
        change = JSON.stringify(change);
      }

      /*
       * Null values are not passed in URL params, pass it as an empty string.
       * Nested values does not need this.
       */
      if(_.isNull(change) && parseChanges.depth === 0) {
        change = '';
      }

      levelChanges[id] = change;
    };

    schema.fields.forEach((field) => {
      // Never include data from the field in the changes, marked as
      // 'excluded'.
      if (field.exclude) return;
      /*
       * If skipChange is true, then field will not be considered for changed
       * data. This is helpful when 'Save' or 'Reset' should not be enabled on
       * this field change alone. No change in other behaviour.
       */
      if(field.skipChange && !includeSkipChange) return;

      /*
       * At this point the schema assignments like top may not have been done,
       * so - check if mode is supported by this field, or not.
       */
      if (!isModeSupportedByField(field, {mode})) return;

      if(
        typeof(field.type) === 'string' && field.type.startsWith('nested-')
      ) {
        /*
         * Even if its nested, state is on same hierarchical level.
         * Find the changes and merge.
         */
        levelChanges = {
          ...levelChanges,
          ...parseChanges(field.schema, origVal, sessVal),
        };
      } else if(isEditMode && !_.isEqual(
        _.get(origVal, field.id), _.get(sessVal, field.id)
      )) {
        /*
         * Check for changes only if in edit mode, otherwise - everything can
         * go through comparator
         */
        if(field.type === 'collection') {
          const change = getCollectionDiffInEditMode(
            field, origVal, sessVal, keepCid, parseChanges
          );

          if(Object.keys(change).length > 0) {
            attrChanged(field.id, change, true);
          }
        } else {
          attrChanged(field.id);
        }
      } else if(!isEditMode) {
        if(field.type === 'collection') {
          const origColl = _.get(origVal, field.id) || [];
          const sessColl = _.get(sessVal, field.id) || [];

          let changeDiff = diffArray(
            origColl, sessColl, 'cid', {compareFunction: isObjectEqual}
          );

          // Check the updated changes,when:
          // 1. These are the fixed rows.
          // 2. 'canReorder' flag is set to true.
          if((
            !_.isUndefined(field.fixedRows) && changeDiff.updated.length > 0
          ) || (
            _.isUndefined(field.fixedRows) && (
              changeDiff.added.length > 0 || changeDiff.removed.length > 0 ||
              changeDiff.updated.length > 0
            )
          ) || (
            field.canReorder && _.differenceBy(origColl, sessColl, 'cid')
          )) {
            attrChanged(
              field.id, cleanCid(_.get(sessVal, field.id), keepCid), true
            );
            return;
          }

          if(field.canReorder) {
            changeDiff = diffArray(origColl, sessColl);

            if(changeDiff.updated.length > 0) {
              attrChanged(
                field.id, cleanCid(_.get(sessVal, field.id), keepCid), true
              );
            }
          }
        } else {
          attrChanged(field.id);
        }
      }
    });

    parseChanges.depth--;
    return levelChanges;
  };

  let res =  parseChanges(topSchema, initData, sessData);

  return res;
}

export function validateCollectionSchema(
  field, sessData, accessPath, setError
) {
  const rows = sessData[field.id] || [];
  const currPath = accessPath.concat(field.id);

  if(rows.length > field.maxCount) {
    setError(currPath, gettext('Maximum %s \'%s\' allowed',field.maxCount, field.label));
    return true;
  }

  // Loop through data.
  for(const [rownum, row] of rows.entries()) {
    if(validateSchema(
      field.schema, row, setError, currPath.concat(rownum), field.label
    )) {
      return true;
    }
  }

  // Validate duplicate rows.
  const dupInd = checkUniqueCol(rows, field.uniqueCol);

  if(dupInd > 0) {
    const uniqueColNames = _.filter(
      field.schema.fields, (uf) => field.uniqueCol.indexOf(uf.id) > -1
    ).map((uf)=>uf.label).join(', ');

    if (isEmptyString(field.label)) {
      setError(currPath, gettext('%s must be unique.', uniqueColNames));
    } else {
      setError(
        currPath,
        gettext('%s in %s must be unique.', uniqueColNames, field.label)
      );
    }
    return true;
  }

  return false;
}

export function validateSchema(
  schema, sessData, setError, accessPath=[], collLabel=null
) {
  sessData = sessData || {};

  for(const field of schema.fields) {
    // Skip id validation
    if(schema.idAttribute === field.id) {
      continue;
    }
    // If the field is has nested schema, then validate the child schema.
    if(field.schema && (field.schema instanceof BaseUISchema)) {
      if (!field.schema.top) field.schema.top = schema;

      // A collection is an array.
      if(field.type === 'collection') {
        if (validateCollectionSchema(field, sessData, accessPath, setError))
          return true;
      }
      // A nested schema ? Recurse
      else if(validateSchema(field.schema, sessData, setError, accessPath)) {
        return true;
      }
    } else {
      // Normal field, default validations.
      const value = sessData[field.id];

      const fieldPath = accessPath.concat(field.id);

      const setErrorOnMessage = (message) => {
        if (message) {
          setError(fieldPath, message);
          return true;
        }
        return false;
      };

      if(field.noEmpty) {
        const label = (
          collLabel && gettext('%s in %s', field.label, collLabel)
        ) || field.noEmptyLabel || field.label;

        if (setErrorOnMessage(emptyValidator(label, value)))
          return true;
      }

      if(field.type === 'int') {
        if (setErrorOnMessage(
          integerValidator(field.label, value) ||
          minMaxValidator(field.label, value, field.min, field.max)
        ))
          return true;
      } else if(field.type === 'numeric') {
        if (setErrorOnMessage(
          numberValidator(field.label, value) ||
          minMaxValidator(field.label, value, field.min, field.max)
        ))
          return true;
      }
    }
  }

  return schema.validate(
    sessData, (id, message) => setError(accessPath.concat(id), message)
  );
}

export const getDepChange = (currPath, newState, oldState, action) => {
  if(action.depChange) {
    newState = action.depChange(currPath, newState, {
      type: action.type,
      path: action.path,
      value: action.value,
      oldState: _.cloneDeep(oldState),
      listener: action.listener,
    });
  }
  return newState;
};

// It will help us generating the flat path, and it will return the same
// object for the same path, which will help with the React componet rendering,
// as it uses `Object.is(...)` for the comparison of the arguments.
export const flatPathGenerator = (separator = '.' ) => {
  const flatPathMap = new Map;

  const setter = memoizeFn((path) => {
    const flatPath = path.join(separator);
    flatPathMap.set(flatPath, path);
    return flatPath;
  });

  const getter = (flatPath) => {
    return flatPathMap.get(flatPath);
  };

  return {
    flatPath: setter,
    path: getter,
    // Get the same object every time.
    cached: (path) => (getter(setter(path))),
  };
};
