/*
 * Copyright (c) 2010, 2024 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 {aria, Calendar, CalendarComponentEventMap, CalendarComponentModel, DateRange, dates, icons, InitModelOf, JsonDateRange, Label, Popup, Range, scout, strings, Widget, WidgetPopup} from '../index';
import $ from 'jquery';

export class CalendarComponent extends Widget implements CalendarComponentModel {
  declare model: CalendarComponentModel;
  declare eventMap: CalendarComponentEventMap;
  declare self: CalendarComponent;
  declare parent: Calendar;

  fromDate: string;
  toDate: string;
  selected: boolean;
  fullDay: boolean;
  fullDayIndex: number;
  draggable: boolean;
  item: CalendarItem;
  stack: Record<string, { x?: number; w?: number }>;
  coveredDaysRange: DateRange;

  /** @internal */
  _$parts: JQuery[];

  constructor() {
    super();
    this.selected = false;
    this.fullDay = false;
    this.fullDayIndex = -1;
    this.item = null;
    this._$parts = [];
  }

  /**
   * If day of a month is smaller than 100px, the components get the class compact
   */
  static MONTH_COMPACT_THRESHOLD = 100;

  static DAY_OF_MONTH_HEIGHT = 30;
  static COMPONENT_HEIGHT = 24;
  static COMPONENT_VGAP = 2;

  protected override _init(model: InitModelOf<this>) {
    super._init(model);

    this._syncCoveredDaysRange(model.coveredDaysRange as JsonDateRange);
  }

  protected _syncCoveredDaysRange(coveredDaysRange: JsonDateRange) {
    if (coveredDaysRange) {
      this.coveredDaysRange = new DateRange(
        dates.parseJsonDate(coveredDaysRange.from),
        dates.parseJsonDate(coveredDaysRange.to));
    }
  }

  protected override _remove() {
    // remove $parts because they're not children of this.$container
    this._$parts.forEach($part => $part.remove());
    this._$parts = [];
    super._remove();
  }

  protected _startLoopDay(): Date {
    // start date is either beginning of the component or beginning of viewRange
    if (dates.compare(this.coveredDaysRange.from, this.parent.viewRange.from) > 0) {
      return this.coveredDaysRange.from;
    }
    return this.parent.viewRange.from;
  }

  protected override _render() {
    let partDay: Date, $day: JQuery, $part: JQuery;
    if (!this.coveredDaysRange) {
      // coveredDaysRange is not set on current CalendarComponent. Cannot show calendar component without from and to values.
      return;
    }

    // Calculate visible
    let resource = this.parent.findResourceForComponent(this);
    this.setVisible(resource.visible);

    if (!this.visible) {
      return;
    }

    let loopDay = this._startLoopDay();
    let item = this.item || {} as CalendarItem;

    let appointmentToDate: Date | string = dates.parseJsonDate(this.toDate);
    let appointmentFromDate = dates.parseJsonDate(this.fromDate);
    let coveredDaysRangeTo = this.coveredDaysRange.to;
    let calendarCssClass = resource ? resource.cssClass : null;

    if (!this.fullDay) {
      let truncToDate = dates.trunc(appointmentToDate);
      if (!dates.isSameDay(appointmentFromDate, appointmentToDate) && dates.compare(appointmentToDate, truncToDate) === 0) {
        appointmentToDate = dates.shiftTime(appointmentToDate, 0, 0, 0, -1);
        coveredDaysRangeTo = dates.shift(coveredDaysRangeTo, 0, 0, -1);
      }
    }
    appointmentToDate = dates.toJsonDate(appointmentToDate);

    let lastComponentDay = dates.shift(coveredDaysRangeTo, 0, 0, 1);

    if (dates.compare(loopDay, lastComponentDay) > 0) {
      // start day for the while loop is greater than the exit condition
      return;
    }

    while (!dates.isSameDay(loopDay, lastComponentDay)) {
      partDay = loopDay;
      loopDay = dates.shift(loopDay, 0, 0, 1); // increase day for loop

      // check if day is in visible view range
      if (dates.compare(partDay, this.parent.viewRange.to) > 0) {
        // break condition, partDay is now out of range.
        break;
      }

      if (this.fullDay && !this.parent.isMonth()) {
        $day = this._findDayInGrid(partDay, this.parent.$topGrid);
      } else {
        $day = this._findDayInGrid(partDay, this.parent.$grid);
      }
      if (!$day) {
        // next day, partDay not found in grid
        continue;
      }

      // Find corresponding calendar
      let $calendar = this._findResourceColumnInDay($day, resource.resourceId);
      if (!$calendar) {
        continue;
      }
      $part = $calendar.appendDiv('calendar-component');

      $part
        .addClass(item.cssClass)
        .addClass(calendarCssClass)
        .data('component', this)
        .data('partDay', partDay)
        .on('mouseenter', this._onMouseEnter.bind(this))
        .on('mouseleave', this._onMouseLeave.bind(this))
        .on('mousedown', this._onMouseDown.bind(this))
        .on('contextmenu', this._onContextMenu.bind(this));
      $part.appendDiv('calendar-component-leftcolorborder');
      let $partContent = $part.appendDiv('content');
      if (item.subjectIconId) {
        $partContent.appendIcon(this.item.subjectIconId);
      }
      $partContent.appendSpan('subject', item.subject);

      this._$parts.push($part);

      if (this.parent.isMonth()) {
        let width = $day.data('new-width') || $day.width(); // prefer width from layoutSize
        $part.addClass('component-month')
          .toggleClass('compact', width < CalendarComponent.MONTH_COMPACT_THRESHOLD);
      } else {
        if (this.fullDay) {
          // Full day tasks are rendered in the topGrid
          // Offset of initial task: CalendarComponent.DAY_OF_MONTH_HEIGHT
          // Offset of following tasks: (CalendarComponent.COMPONENT_HEIGHT + CalendarComponent.COMPONENT_VGAP) * preceding number of tasks
          this._arrangeTask(CalendarComponent.DAY_OF_MONTH_HEIGHT + (CalendarComponent.COMPONENT_HEIGHT + CalendarComponent.COMPONENT_VGAP) * Math.max(this.fullDayIndex, 0));
          $part.addClass('component-task');
        } else {
          let
            fromDate = dates.parseJsonDate(this.fromDate),
            toDate = dates.parseJsonDate(appointmentToDate),
            partFrom = this._getHours(this.fromDate),
            partTo = this._getHours(appointmentToDate);

          // position and height depending on start and end date
          $part.addClass('component-day');
          if (dates.isSameDay(dates.trunc(this.coveredDaysRange.from), dates.trunc(coveredDaysRangeTo))) {
            this._partPosition($part, partFrom, partTo);
          } else if (dates.isSameDay(partDay, fromDate)) {
            this._partPosition($part, partFrom, 25) // 25: indicate that it takes longer than that day
              .addClass('component-open-bottom');
          } else if (dates.isSameDay(partDay, toDate)) {
            // Start at zero: No need to indicate that it starts earlier since indicator needs no extra space
            this._partPosition($part, 0, partTo)
              .addClass('component-open-top');
          } else {

            this._partPosition($part, 0, 25) // 25: indicate that it takes longer than that day
              .addClass('component-open-top')
              .addClass('component-open-bottom');
          }
        }
      }
    }
  }

  protected _getHours(date: string): number {
    let d = dates.parseJsonDate(date);
    return d.getHours() + d.getMinutes() / 60;
  }

  getLengthInHoursDecimal(): number {
    let toTimestamp = dates.parseJsonDate(this.toDate);
    let fromTimestamp = dates.parseJsonDate(this.fromDate);
    return (toTimestamp.getTime() - fromTimestamp.getTime()) / (1000 * 60 * 60);
  }

  getResourceId(): string {
    if (!this.item || !this.parent.isLeafResource(this.item.resourceId)) {
      return this.parent.defaultResource.resourceId;
    }
    return this.item.resourceId;
  }

  protected _findDayInGrid(date: Date, $grid: JQuery): JQuery {
    return $grid.find('.calendar-day')
      .filter(function(i, elem) {
        return dates.isSameDay($(this).data('date'), date);
      }).eq(0);
  }

  protected _findResourceColumnInDay($day: JQuery, resouceId: string): JQuery {
    if (!this.parent.isDay()) {
      resouceId = this.parent.defaultResource.resourceId;
    }

    // Validate resourceId
    resouceId = this.parent.findResourceForId(resouceId).resourceId;

    return $day.find('.resource-column')
      .filter((index, element) => $(element).data('resourceId') === resouceId);
  }

  protected _isTask(): boolean {
    return !this.parent.isMonth() && this.fullDay;
  }

  protected _arrangeTask(taskOffset: number) {
    this._$parts.forEach($part => $part.css('top', taskOffset + 'px'));
  }

  protected _isDayPart(): boolean {
    return !this.parent.isMonth() && !this.fullDay;
  }

  protected _getHourRange(day: Date): Range {
    let hourRange = new Range(this._getHours(this.fromDate), this._getHours(this.toDate));
    let dateRange = new DateRange(dates.parseJsonDate(this.fromDate), dates.parseJsonDate(this.toDate));

    if (dates.isSameDay(day, dateRange.from) && dates.isSameDay(day, dateRange.to)) {
      return new Range(hourRange.from, hourRange.to);
    }
    if (dates.isSameDay(day, dateRange.from)) {
      return new Range(hourRange.from, 24);
    }
    if (dates.isSameDay(day, dateRange.to)) {
      return new Range(0, hourRange.to);
    }
    return new Range(0, 24);
  }

  getPartDayPosition(day: Date): Range {
    return this._getDisplayDayPosition(this._getHourRange(day));
  }

  protected _getDisplayDayPosition(range: Range): Range {
    // Doesn't support minutes yet...
    let preferredRange = new Range(this.parent._dayPosition(range.from, 0), this.parent._dayPosition(range.to, 0));
    // Fixed number of divisions...
    let minRangeSize = Math.round(100 * 100 / 24 / this.parent.numberOfHourDivisions) / 100; // Round to two digits
    if (preferredRange.size() < minRangeSize) {
      return new Range(preferredRange.from, preferredRange.from + minRangeSize);
    }
    return preferredRange;
  }

  protected _partPosition($part: JQuery, y1: number, y2: number): JQuery {
    // Compensate open bottom (height: square of 16px, rotated 45°, approx. 23px = sqrt(16^2 + 16^2)
    let compensateBottom = y2 === 25 ? 23 : 0;
    y2 = Math.min(24, y2);

    let range = new Range(y1, y2);
    let r = this._getDisplayDayPosition(range);

    // Convert to %, rounded to two decimal places
    compensateBottom = Math.round(100 * (100 / 1920 * compensateBottom)) / 100;

    return $part
      .css('top', r.from + '%')
      .css('height', r.to - r.from - compensateBottom + '%');
  }

  protected override _renderProperties() {
    super._renderProperties();
    this._renderSelected();
  }

  protected _renderSelected() {
    this._$parts.forEach($part => $part.toggleClass('comp-selected', this.selected));
  }

  setSelected(selected: boolean) {
    this.setProperty('selected', selected);
  }

  updateSelectedComponent($part: JQuery, updateScrollPosition: boolean) {
    this.parent._selectedComponentChanged(this, this.getResourceId(), $part.data('partDay') as Date, updateScrollPosition);
  }

  protected _onMouseEnter(event: JQuery.MouseEnterEvent) {
    this._$parts.forEach($part => $part.addClass('hover'));
  }

  protected _onMouseLeave(evenet: JQuery.MouseLeaveEvent) {
    this._$parts.forEach($part => $part.removeClass('hover'));
  }

  protected _onMouseDown(event: JQuery.MouseDownEvent) {
    this.applySelection($(event.delegateTarget), event.button === 0, event.originalEvent.clientY);
  }

  applySelection($part: JQuery, openPopup: boolean, popupY: number) {
    // don't show popup if dragging is in process
    if (this.parent._moveData && this.parent._moveData.moving) {
      return;
    }

    // don't show popup when range selection is in progress
    if (this.parent._rangeSelectionStarted) {
      return;
    }

    this.updateSelectedComponent($part, false);

    this.parent.setSelectedRange(null);

    if (openPopup) {
      let popup = scout.create((WidgetPopup<Label>), {
        parent: this.parent,
        $anchor: $part,
        closeOnAnchorMouseDown: true,
        closeOnMouseDownOutside: true,
        closeOnOtherPopupOpen: true,
        horizontalAlignment: Popup.Alignment.LEFT,
        verticalAlignment: Popup.Alignment.CENTER,
        trimWidth: false,
        trimHeight: true,
        horizontalSwitch: true,
        verticalSwitch: false,
        withArrow: true,
        cssClass: 'popup',
        scrollType: 'remove',
        location: {
          y: popupY
        },
        content: {
          objectType: Label,
          htmlEnabled: true,
          scrollable: true,
          cssClass: 'calendar-component-tooltip-content tooltip-content',
          value: this.description(true)
        }
      });
      popup.open();
      popup.$container.find('.app-link')
        .on('click', this._onAppLinkAction.bind(this));
    }
  }

  /** @internal */
  _onContextMenu(event: JQuery.ContextMenuEvent) {
    this.parent._showContextMenu(event, Calendar.MenuType.CalendarComponent);
  }

  protected _format(date: Date, pattern: string): string {
    return dates.format(date, this.session.locale, pattern);
  }

  description(linkAllowed: boolean): string {
    let range = null,
      $container = $('<div>'),
      fromDate = dates.parseJsonDate(this.fromDate),
      toDate = dates.parseJsonDate(this.toDate),
      descriptionAvailable = strings.hasText(this.item.description) || this.item.descriptionElements;

    let $header = $container.appendDiv('calendar-component-header');
    if (descriptionAvailable) {
      $header.addClass('with-description');
    }

    // subject
    if (strings.hasText(this.item.subject)) {
      if (strings.hasText(this.item.subjectLabel)) {
        $header.appendDiv('calendar-component-title-label', this.item.subjectLabel);
      }
      let $subject = $header.appendDiv('calendar-component-title', this.item.subject);
      if (linkAllowed && strings.hasText(this.item.subjectAppLink)) {
        $subject.addClass('app-link').attr('data-ref', this.item.subjectAppLink);
        aria.role($subject, 'link');
      }
    }

    // time-range
    if (this.fullDay) {
      // NOP
    } else if (dates.isSameDay(fromDate, toDate)) {
      range = this.session.text('ui.FromXToY', this._format(fromDate, 'HH:mm'), this._format(toDate, 'HH:mm'));
    } else {
      range = this.session.text('ui.FromXToY', this._format(fromDate, 'EEEE HH:mm '), this._format(toDate, 'EEEE HH:mm'));
    }

    if (strings.hasText(range)) {
      let $timeContainer = $header.appendDiv('calendar-component-intro');
      $timeContainer.appendIcon(icons.CLOCK);
      $timeContainer.appendSpan('', range);
    }

    if (descriptionAvailable) {
      let $description = $container.appendDiv('calendar-component-description-container');

      // description
      if (strings.hasText(this.item.description)) {
        $description.appendSpan('calendar-component-description').html(strings.nl2br(this.item.description));
      }

      if (this.item.descriptionElements) {
        let descriptionIconExists = false;
        for (let i = 0; i < this.item.descriptionElements.length; i++) {
          if (this.item.descriptionElements[i].iconId) {
            descriptionIconExists = true;
            break;
          }
        }
        for (let i = 0; i < this.item.descriptionElements.length; i++) {
          let descriptionElement = this.item.descriptionElements[i];
          let $descriptionElementContainer = $description.appendDiv('calendar-component-description-element');
          if (i === 0) {
            $descriptionElementContainer.addClass('first');
          }
          if (i === this.item.descriptionElements.length - 1) {
            $descriptionElementContainer.addClass('last');
          }
          if (strings.hasText(descriptionElement.iconId) || descriptionIconExists) {
            $descriptionElementContainer.appendIcon(descriptionElement.iconId);
          }
          let $text = $descriptionElementContainer.appendDiv('text').html(strings.nl2br(descriptionElement.text));
          if (linkAllowed && strings.hasText(descriptionElement.appLink)) {
            $text.addClass(' app-link').attr('data-ref', descriptionElement.appLink);
            aria.role($text, 'link');
          }
        }
      }
    }
    return $container[0].innerHTML;
  }

  triggerAppLinkAction(ref: string) {
    this.trigger('appLinkAction', {
      ref: ref
    });
  }

  _onAppLinkAction(event: JQuery.ClickEvent) {
    let $target = $(event.delegateTarget);
    let ref = $target.data('ref') as string;
    this.triggerAppLinkAction(ref);
  }
}

/**
 * See JsonCalendarItem.java
 */
export type CalendarItem = {
  exists: boolean;
  lastModified: number;
  itemId: any;
  owner: string;
  cssClass: string;
  resourceId: string;
  subject: string;
  description: string;
  recurrencePattern: {
    lastModified: number;
    regenerate: boolean;
    startTimeMinutes: number;
    endTimeMinutes: number;
    durationMinutes: number;
    firstDate: Date;
    lastDate: Date;
    occurrences: number;
    noEndDate: boolean;
    /**
     * @see RecurrencePattern.java TYPE* constants
     */
    type: number;
    interval: number;
    /**
     * @see RecurrencePattern.java INST_* constants
     */
    instance: number;
    dayOfWeekBits: number;
    dayOfMonth: number;
    monthOfYear: number;
  };
  subjectLabel: string;
  subjectAppLink: string;
  subjectIconId: string;
  descriptionElements: CalendarItemDescriptionElement[];
};

export type CalendarItemDescriptionElement = {
  text: string;
  iconId: string;
  appLink: string;
};
