/*
 * Copyright (c) 2010, 2025 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 {
  ajax, AjaxCall, AjaxError, App, arrays, BackgroundJobPollingStatus, config, ConfigProperties, dates, ErrorHandler, InitModelOf, JsonErrorResponse, LogLevel, MainConfigProperties, objects, PropertyEventEmitter, scout, Session, TopicDo,
  UiNotificationDo, UiNotificationPollerEventMap, UiNotificationResponse, UiNotificationSystem
} from '../index';
import $ from 'jquery';

export class UiNotificationPoller extends PropertyEventEmitter {
  declare eventMap: UiNotificationPollerEventMap;
  declare self: UiNotificationPoller;

  /**
   * Configures in milliseconds the time to wait after an error occurs, before the polling will be retried.
   */
  static RESPONSE_ERROR_RETRY_INTERVAL = 30_000;

  /**
   * Configures in milliseconds the time to wait after a connection error occurs, before the polling will be retried.
   */
  static CONNECTION_ERROR_RETRY_INTERVALS = [300, 500, 1000, 5000];

  /**
   * The number of notifications per topic that should be kept until the topic is unsubscribed.
   */
  static HISTORY_COUNT = 10;

  static DEFAULT_BACKEND_TIMEOUT = 60_000;
  static BACKEND_TIMEOUT_OFFSET = 15_000;

  /**
   * Configures in milliseconds how long the connection is allowed to stay open before it will be aborted.
   * This is more like a last resort timeout, the server will release the connection earlier (see scout.uinotification.waitTimeout).
   */
  requestTimeout: number;
  status: BackgroundJobPollingStatus;
  /**
   * Stores the received notifications per topic and per cluster node but not more than {@link UiNotificationPoller.HISTORY_COUNT}.
   */
  notifications: Map<string, Map<string, UiNotificationDo[]>>;
  url: string;
  system: UiNotificationSystem;
  protected _call: AjaxCall;

  constructor() {
    super();
    this.requestTimeout = UiNotificationPoller.DEFAULT_BACKEND_TIMEOUT + UiNotificationPoller.BACKEND_TIMEOUT_OFFSET;
    this.status = BackgroundJobPollingStatus.STOPPED;
    this.notifications = new Map();
  }

  protected override _init(model: InitModelOf<this>) {
    super._init(model);
    let timeoutPropertyKey: keyof MainConfigProperties = 'scout.uinotification.waitTimeout';
    let system = this.system.name as keyof ConfigProperties;
    let backendTimeout = scout.nvl(config.get(timeoutPropertyKey, system)?.value, config.get(timeoutPropertyKey)?.value, UiNotificationPoller.DEFAULT_BACKEND_TIMEOUT);
    this.requestTimeout = backendTimeout + UiNotificationPoller.BACKEND_TIMEOUT_OFFSET;
  }

  setTopics(topics: string[]) {
    // Create a new map but keep existing notifications per topic
    this.notifications = new Map<string, Map<string, UiNotificationDo[]>>(topics.map(topic => [topic, this.notifications.get(topic) || new Map()]));
  }

  get topics(): string[] {
    return Array.from(this.notifications.keys());
  }

  get topicsWithLastNotifications(): TopicDo[] {
    // Create an array of TopicDOs containing the last notification of each topic per node
    return Array.from(this.notifications.entries())
      .map(([name, notificationsByNode]) => {
        let lastNotifications = Array.from(notificationsByNode.values()).map(notifications => {
          let lastNotification = arrays.last(notifications);
          return {
            id: lastNotification.id,
            creationTime: lastNotification.creationTime,
            nodeId: lastNotification.nodeId
          } as UiNotificationDo;
        });
        return {name, lastNotifications: lastNotifications.length === 0 ? undefined : lastNotifications};
      });
  }

  restart() {
    this.stop();
    this.start();
  }

  start() {
    if (this.status === BackgroundJobPollingStatus.RUNNING) {
      return;
    }
    this.poll();
  }

  stop() {
    if (this.status === BackgroundJobPollingStatus.STOPPED) {
      return;
    }
    this._call?.abort();
    this.setStatus(BackgroundJobPollingStatus.STOPPED);
  }

  poll() {
    this._poll();
    this.setStatus(BackgroundJobPollingStatus.RUNNING);
  }

  protected _schedulePoll(timeout?: number) {
    if (this.status === BackgroundJobPollingStatus.STOPPED) {
      return;
    }
    setTimeout(() => {
      if (this.status === BackgroundJobPollingStatus.STOPPED) {
        return;
      }
      this.poll();
    }, scout.nvl(timeout, 0));
  }

  protected _poll() {
    this._call?.abort(); // abort in case there is already a call running
    this._call = ajax.createCallJson({
      url: this.url,
      timeout: this.requestTimeout,
      converters: {
        'text json': data => objects.parseJson(data, dates.parseJsonDateMapper('creationTime'))
      },
      data: objects.stringifyJson({
        topics: this.topicsWithLastNotifications
      }, dates.stringifyJsonDateMapper())
    }, {
      maxRetries: -1, // unlimited retries on connection errors
      retryIntervals: UiNotificationPoller.CONNECTION_ERROR_RETRY_INTERVALS
    });
    this._call.call()
      .then((response: UiNotificationResponse) => this._onSuccess(response))
      .catch(error => this._onError(error));
  }

  protected _onSuccess(response: UiNotificationResponse) {
    if (response.error) {
      this._onSuccessError(response.error);
      return;
    }
    if (this.status === BackgroundJobPollingStatus.STOPPED) {
      // Don't do anything if poller was stopped in the meantime -> discard notifications
      // In case the poller will be started again, the discarded notifications will be sent again by the server
      return;
    }
    let notifications = response.notifications || [];
    $.log.isInfoEnabled() && $.log.info(`${notifications.length} UI notification(s) received.`);
    notifications = notifications.filter(notification => {
      let {topic, id, nodeId} = notification;
      if (!this.notifications.has(topic)) {
        // Ignore topics that have been unsubscribed in the meantime
        return false;
      }

      // Add to notification history and drop the oldest ones
      let topicNotifications = this.notifications.get(topic);
      let nodeNotifications = objects.getOrSetIfAbsent(topicNotifications, nodeId, () => []);
      if (nodeNotifications.some(existingNotification => existingNotification.id === id)) {
        // Notification already known, ignore it
        $.log.isInfoEnabled() && $.log.info(`UI notification with id '${id}' is already known, dropping it.`);
        return false;
      }
      nodeNotifications.push(notification);
      nodeNotifications = nodeNotifications.sort((n1, n2) => n1.creationTime.getTime() - n2.creationTime.getTime());
      if (nodeNotifications.length > UiNotificationPoller.HISTORY_COUNT) {
        nodeNotifications.splice(0, 1);
      }

      if (notification.subscriptionStart) {
        $.log.isInfoEnabled() && $.log.info(`UI notification with id ${id} marks subscription start.`);
        this.trigger('subscriptionStart', {notification});
        // Just a marker notification -> discard it
        return false;
      }
      return true;
    });

    if (notifications.length) {
      $.log.isInfoEnabled() && $.log.info(`Dispatching UI notifications with ids ${notifications.map(n => n.id)}.`);
      this.trigger('notifications', {notifications});
    }

    this._schedulePoll();
  }

  protected _onSuccessError(error: JsonErrorResponse) {
    if (error.code === Session.JsonResponseError.SESSION_TIMEOUT) {
      $.log.isInfoEnabled() && $.log.info('Stopping ui notification poller due to session timeout');
      this.stop();
    } else {
      // Log every other error, even though they should actually never happen
      scout.create(ErrorHandler, {displayError: false}).handle(error);
    }
  }

  protected _onError(error: AjaxError) {
    if (this._call.pendingCall || this._call.callTimeoutId) {
      // Poller has probably been aborted but already been restarted or scheduled for a retry (callTimeoutId is set) -> ignore error and don't reschedule poll
      return;
    }
    if (error.textStatus === 'abort' || this._call.aborted) {
      // Don't report errors if polling was aborted.
      // Checking the aborted flag is necessary because textStatus may be wrong if AjaxCall was aborted between two retries (textStatus is only set to aborted if the actual request was aborted).
      // This ensures no error is reported even if poller was stopped between two retries.
      if (this.status === BackgroundJobPollingStatus.STOPPED) {
        return;
      }
      this.setStatus(BackgroundJobPollingStatus.FAILURE);

      // If poller is supposed to run, reschedule it
      this._schedulePoll(UiNotificationPoller.RESPONSE_ERROR_RETRY_INTERVAL);
      return;
    }
    this.setStatus(BackgroundJobPollingStatus.FAILURE);

    if (scout.isOneOf(error.jqXHR.status, 401, 403)) {
      // Stop polling on session timeout
      $.log.isInfoEnabled() && $.log.info(`Stopping ui notification poller because operation is not permitted (${error.jqXHR.status})`);
      this.trigger('error', {error});
      this.stop();
      return;
    }

    this.trigger('error', {error});

    App.get().errorHandler.analyzeError(error).then(errorInfo => {
      scout.getSession()?.sendLogRequest(`UI notification poller failed, call will be retried in ${UiNotificationPoller.RESPONSE_ERROR_RETRY_INTERVAL} ms.\nError message:\n${errorInfo.log}\n()`, LogLevel.INFO);
    });

    this._schedulePoll(UiNotificationPoller.RESPONSE_ERROR_RETRY_INTERVAL);
  }

  setStatus(status: BackgroundJobPollingStatus) {
    const changed = this.setProperty('status', status);
    if (changed) {
      $.log.isInfoEnabled() && $.log.info('UI notification poller status changed: ' + status);
    }
  }
}
