/**
 * Copyright (c) HashiCorp, Inc.
 * SPDX-License-Identifier: MPL-2.0
 */

// @ts-check

import Component from '@glimmer/component';
import { action, computed } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import { inject as service } from '@ember/service';
import { trimPath } from '../helpers/trim-path';
import { copy } from 'ember-copy';
import EmberObject, { set } from '@ember/object';
// eslint-disable-next-line no-unused-vars
import MutableArray from '@ember/array/mutable';
import { A } from '@ember/array';
import { stringifyObject } from 'nomad-ui/helpers/stringify-object';
import notifyConflict from 'nomad-ui/utils/notify-conflict';
import isEqual from 'lodash.isequal';

const EMPTY_KV = {
  key: '',
  value: '',
  warnings: EmberObject.create(),
};

// Capture characters that are not _, letters, or numbers using Unicode.
const invalidKeyCharactersRegex = new RegExp(/[^_\p{Letter}\p{Number}]/gu);

export default class VariableFormComponent extends Component {
  @service notifications;
  @service router;
  @service store;
  @service can;

  @tracked variableNamespace = null;
  @tracked namespaceOptions = null;
  @tracked hasConflict = false;

  /**
   * @typedef {Object} conflictingVariable
   * @property {string} ModifyTime
   * @property {Object} Items
   */

  /**
   * @type {conflictingVariable}
   */
  @tracked conflictingVariable = null;

  @tracked path = '';
  constructor() {
    super(...arguments);
    set(this, 'path', this.args.model.path);
    this.addExitHandler();
  }

  @action
  setNamespace(namespace) {
    this.variableNamespace = namespace;
  }

  @action
  setNamespaceOptions(options) {
    this.namespaceOptions = options;

    // Set first namespace option
    if (options.length) {
      this.variableNamespace = this.args.model.namespace;
    }
  }

  get shouldDisableSave() {
    const disallowedPath =
      this.path?.startsWith('nomad/') &&
      !(
        this.path?.startsWith('nomad/jobs') ||
        (this.path?.startsWith('nomad/job-templates') &&
          trimPath([this.path]) !== 'nomad/job-templates')
      );
    return !!this.JSONError || !this.path || disallowedPath;
  }

  get isJobTemplateVariable() {
    return this.path?.startsWith('nomad/job-templates/');
  }

  get jobTemplateName() {
    return this.path.split('nomad/job-templates/').slice(-1);
  }

  /**
   * @type {MutableArray<{key: string, value: string, warnings: EmberObject}>}
   */
  keyValues = A([]);

  /**
   * @type {string}
   */
  JSONItems = '{}';

  @action
  establishKeyValues() {
    const keyValues = copy(this.args.model?.keyValues || [])?.map((kv) => {
      return {
        key: kv.key,
        value: kv.value,
        warnings: EmberObject.create(),
      };
    });

    /**
     * Appends a row to the end of the Items list if you're editing an existing variable.
     * This will allow it to auto-focus and make all other rows deletable
     */
    if (!this.args.model?.isNew) {
      keyValues.pushObject(copy(EMPTY_KV));
    }
    set(this, 'keyValues', keyValues);

    this.JSONItems = stringifyObject([
      this.keyValues.reduce((acc, { key, value }) => {
        acc[key] = value;
        return acc;
      }, {}),
    ]);
  }

  /**
   * @typedef {Object} DuplicatePathWarning
   * @property {string} path
   */

  /**
   * @type {DuplicatePathWarning}
   */
  get duplicatePathWarning() {
    const existingVariables = this.args.existingVariables || [];
    const pathValue = trimPath([this.path]);
    let existingVariable = existingVariables
      .without(this.args.model)
      .find(
        (v) =>
          v.path === pathValue &&
          (v.namespace === this.variableNamespace || !this.variableNamespace)
      );
    if (existingVariable) {
      return {
        path: existingVariable.path,
      };
    } else {
      return null;
    }
  }

  @action
  validateKey(entry, e) {
    const value = e.target.value;
    // Only letters, numbers, and _ are allowed in keys
    const invalidChars = value.match(invalidKeyCharactersRegex);
    if (invalidChars) {
      const invalidCharsOuput = [...new Set(invalidChars)]
        .sort()
        .map((c) => `'${c}'`);
      entry.warnings.set(
        'dottedKeyError',
        `${value} contains characters [${invalidCharsOuput}] that require the "index" function for direct access in templates.`
      );
    } else {
      delete entry.warnings.dottedKeyError;
      entry.warnings.notifyPropertyChange('dottedKeyError');
    }

    // no duplicate keys
    const existingKeys = this.keyValues.map((kv) => kv.key);
    if (existingKeys.includes(value)) {
      entry.warnings.set('duplicateKeyError', 'Key already exists.');
    } else {
      delete entry.warnings.duplicateKeyError;
      entry.warnings.notifyPropertyChange('duplicateKeyError');
    }
  }

  @action appendRow() {
    // Clear our any entity errors
    let newRow = copy(EMPTY_KV);
    newRow.warnings = EmberObject.create();
    this.keyValues.pushObject(newRow);
  }

  @action deleteRow(row) {
    this.keyValues.removeObject(row);
  }

  @action refresh() {
    window.location.reload();
  }

  @action saveWithOverwrite(e) {
    set(this, 'conflictingVariable', null);
    this.save(e, true);
  }

  /**
   *
   * @param {KeyboardEvent} e
   */
  @action setModelPath(e) {
    set(this.args.model, 'path', e.target.value);
  }

  @action updateKeyValue(key, value) {
    if (this.keyValues.find((kv) => kv.key === key)) {
      this.keyValues.find((kv) => kv.key === key).value = value;
    } else {
      this.keyValues.pushObject({ key, value, warnings: EmberObject.create() });
    }
  }

  @action
  async save(e, overwrite = false) {
    if (e.type === 'submit') {
      e.preventDefault();
    }

    if (this.view === 'json') {
      this.translateAndValidateItems('table');
    }
    try {
      const nonEmptyItems = A(
        this.keyValues.filter((item) => item.key.trim() && item.value)
      );
      if (!nonEmptyItems.length) {
        throw new Error('Please provide at least one key/value pair.');
      } else {
        set(this, 'keyValues', nonEmptyItems);
      }

      if (this.args.model?.isNew) {
        if (this.namespaceOptions) {
          this.args.model.set('namespace', this.variableNamespace);
        } else {
          const [namespace] = this.store.peekAll('namespace').toArray();
          this.args.model.set('namespace', namespace.id);
        }
      }

      this.args.model.set('keyValues', this.keyValues);
      this.args.model.set('path', this.path);
      this.args.model.setAndTrimPath();
      await this.args.model.save({ adapterOptions: { overwrite } });

      this.notifications.add({
        title: 'Variable saved',
        message: `${this.path} successfully saved`,
        color: 'success',
      });

      if (
        this.can.can('read job', null, {
          namespace: this.variableNamespace || 'default',
        })
      ) {
        this.updateJobVariables(this.args.model.pathLinkedEntities.job);
      }

      this.removeExitHandler();
      this.router.transitionTo('variables.variable', this.args.model.id);
    } catch (error) {
      notifyConflict(this)(error);
      if (!this.hasConflict) {
        this.notifications.add({
          title: `Error saving ${this.path}`,
          message: error,
          color: 'critical',
          sticky: true,
        });
      } else {
        if (error.errors[0]?.detail) {
          set(this, 'conflictingVariable', error.errors[0].detail);
        }
        window.scrollTo(0, 0); // because the k/v list may be long, ensure the user is snapped to top to read error
      }
    }
  }

  /**
   * A job, its task groups, and tasks, all have a getter called pathLinkedVariable.
   * These are dependent on a variables list that may already be established. If a variable
   * is added or removed, this function will update job.variables[] list to reflect the change.
   * and force an update to the job's pathLinkedVariable getter.
   */
  async updateJobVariables(jobName) {
    if (!jobName) {
      return;
    }
    const fullJobId = JSON.stringify([
      jobName,
      this.variableNamespace || 'default',
    ]);
    let job = await this.store.findRecord('job', fullJobId, { reload: true });
    if (job) {
      job.variables.pushObject(this.args.model);
    }
  }

  //#region JSON Editing

  view = this.args.view;

  get isJSONView() {
    return this.args.view === 'json';
  }

  // Prevent duplicate onUpdate events when @view is set to its already-existing value,
  // which happens because parent's queryParams and toggle button both resolve independently.
  @action onViewChange([view]) {
    if (view !== this.view) {
      set(this, 'view', view);
      this.translateAndValidateItems(view);
    }
  }

  @action
  translateAndValidateItems(view) {
    // TODO: move the translation functions in serializers/variable.js to generic importable functions.
    if (view === 'json') {
      // Translate table to JSON
      set(
        this,
        'JSONItems',
        stringifyObject([
          this.keyValues
            .filter((item) => item.key.trim() && item.value) // remove empty items when translating to JSON
            .reduce((acc, { key, value }) => {
              acc[key] = value;
              return acc;
            }, {}),
        ])
      );

      // Give the user a foothold if they're transitioning an empty K/V form into JSON
      if (!Object.keys(JSON.parse(this.JSONItems)).length) {
        set(this, 'JSONItems', stringifyObject([{ '': '' }]));
      }
    } else if (view === 'table') {
      // Translate JSON to table
      set(
        this,
        'keyValues',
        A(
          Object.entries(JSON.parse(this.JSONItems)).map(([key, value]) => {
            return {
              key,
              value: typeof value === 'string' ? value : JSON.stringify(value),
              warnings: EmberObject.create(),
            };
          })
        )
      );

      // If the JSON object is empty at switch time, add an empty KV in to give the user a foothold
      if (!Object.keys(JSON.parse(this.JSONItems)).length) {
        this.appendRow();
      }
    }

    // Reset any error state, since the errorring json will not persist
    set(this, 'JSONError', null);
  }

  /**
   * @type {string}
   */
  @tracked JSONError = null;
  /**
   *
   * @param {string} value
   */
  @action updateCode(value, codemirror) {
    codemirror.performLint();
    try {
      const hasLintErrors = codemirror?.state.lint.marked?.length > 0;
      if (hasLintErrors || !JSON.parse(value)) {
        throw new Error('Invalid JSON');
      }

      // "myString" is valid JSON, but it's not a valid Variable.
      // Ditto for an array of objects. We expect a single object to be a Variable.
      const hasFormatErrors =
        JSON.parse(value) instanceof Array ||
        typeof JSON.parse(value) !== 'object';
      if (hasFormatErrors) {
        throw new Error('A Variable must be formatted as a single JSON object');
      }

      set(this, 'JSONError', null);
      set(this, 'JSONItems', value);
    } catch (error) {
      set(this, 'JSONError', error);
    }
  }
  //#endregion JSON Editing

  get shouldShowLinkedEntities() {
    return (
      this.args.model.pathLinkedEntities?.job ||
      this.args.model.pathLinkedEntities?.group ||
      this.args.model.pathLinkedEntities?.task ||
      trimPath([this.path]) === 'nomad/jobs'
    );
  }

  //#region Unsaved Changes Confirmation

  hasRemovedExitHandler = false;

  @computed(
    'args.model.{keyValues,path}',
    'keyValues.@each.{key,value}',
    'path'
  )
  get hasUserModifiedAttributes() {
    const compactedBasicKVs = this.keyValues
      .map((kv) => ({ key: kv.key, value: kv.value }))
      .filter((kv) => kv.key || kv.value);
    const compactedPassedKVs = this.args.model.keyValues.filter(
      (kv) => kv.key || kv.value
    );
    const unequal =
      !isEqual(compactedBasicKVs, compactedPassedKVs) ||
      !isEqual(this.path, this.args.model.path);
    return unequal;
  }

  addExitHandler() {
    this.router.on('routeWillChange', this, this.confirmExit);
  }

  removeExitHandler() {
    if (!this.hasRemovedExitHandler) {
      this.router.off('routeWillChange', this, this.confirmExit);
      this.hasRemovedExitHandler = true;
    }
  }

  confirmExit(transition) {
    if (transition.isAborted || transition.queryParamsOnly) return;

    if (this.hasUserModifiedAttributes) {
      if (
        !confirm(
          'Your variable has unsaved changes. Are you sure you want to leave?'
        )
      ) {
        transition.abort();
      } else {
        this.removeExitHandler();
      }
    }
  }

  willDestroy() {
    super.willDestroy(...arguments);
    this.removeExitHandler();
  }

  //#endregion Unsaved Changes Confirmation
}
