import { map, switchMap, take, takeUntil, filter, tap, debounceTime } from "rxjs/operators";
import { MessageService } from "../services/message.service";
import { pipe, Observable, combineLatest, Subject, merge, BehaviorSubject, SchedulerLike, MonoTypeOperatorFunction, asyncScheduler, throwError, OperatorFunction } from "rxjs";
import { DialogResult, DialogCloseResult, DialogSettings, DialogService, DialogAction } from "@progress/kendo-angular-dialog";
import { Title } from '@angular/platform-browser';
import dayjs from 'dayjs/esm/index';
import utc from 'dayjs/esm/plugin/utc';
import timezone from 'dayjs/esm/plugin/timezone';
import isSameOrBefore from 'dayjs/esm/plugin/isSameOrBefore';
import isSameOrAfter from 'dayjs/esm/plugin/isSameOrAfter';
import { HttpClient } from "@angular/common/http";
import { CompositeFilterDescriptor, FilterDescriptor, SortDescriptor, State, toODataString } from '@progress/kendo-data-query';
import { ChangeDetectorRef, computed, ElementRef, OutputEmitterRef, QueryList } from "@angular/core";
import { TooltipDirective } from "@progress/kendo-angular-tooltip";
import { AbstractControl, FormArray, FormGroup, UntypedFormArray, UntypedFormGroup } from "@angular/forms";
import { formatNumber } from '@progress/kendo-intl';
import { saveAs } from "@progress/kendo-file-saver";
import { DateInputFormatPlaceholder } from '@progress/kendo-angular-dateinputs';
import * as allIcons from "@progress/kendo-svg-icons";
import { evaluate } from 'decimal-eval';
import { GridDataResult } from "@progress/kendo-angular-grid";

export const icons = allIcons;

dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(isSameOrBefore);
dayjs.extend(isSameOrAfter);

export const dayPlaceholder: DateInputFormatPlaceholder = { year: 'yyyy', month: 'm', day: 'd', hour: null, minute: null, second: null, millisecond: null };
export const monthPlaceholder: DateInputFormatPlaceholder = { year: 'yyyy', month: 'month', day: null, hour: null, minute: null, second: null, millisecond: null };
export const minDate: Date = new Date(1900, 0, 1);
export const maxDate: Date = new Date(3000, 0, 1);
export const tooltipDelay = 100;
export const currentDate = dayjs(new Date()).startOf('day');
export const bulletSymbol = '\u2022';
export const missingPrice = -99999;

//https://stackoverflow.com/questions/47893110/typescript-mapped-types-class-to-interface-without-methods
type NonFunctionPropertyNames<T> = {
  // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
  [K in keyof T]: T[K] extends Function ? never : K
}[keyof T];
type NonFunctionProperties<T> = Pick<T, NonFunctionPropertyNames<T>>;

//https://medium.com/@alvino.aj/type-safety-reactions-with-the-angular-form-builder-6de51e2338fc
//https://stackoverflow.com/questions/72507263/angular-14-strictly-typed-reactive-forms-how-to-type-formgroup-model-using-exi
export type FormModel<T> = FormGroup<{ [K in keyof NonFunctionProperties<T>]: AbstractControl<T[K]> }>;

export type SortOrder = "asc" | "desc";

export interface IdName {
  id: number;
  name: string;
}

export interface NameOnly {
  name: string;
}

export interface IdDate {
  id: number;
  date?: Date;
}

export interface IdNameActive {
  id: number;
  name: string;
  isActive: boolean;
}

export interface Pipe {
  pipeId: number;
  pipeName: string;
  pipeShort: string;
  isGasPipe: boolean;
  isCrudePipe: boolean;
  productIds: number[];
  utilityIds: number[];
}

export interface Point {
  pointId: number;
  pointName: string;
  pipeId: number;
  pipeName: string;
  utilityIds: number[];
  isGasPoint: boolean;
  isCrudePoint: boolean;
  counterpartyIds: number[];
}

export interface Meter {
  meterId: number;
  meterName: string;
  meterNum: number;
  zoneId: number;
  pipeId: number;
  pointId: number;
  utilityId: number;
  counterpartyId: number;
  meterNameAndNum: string;
  meterPointAndName: string;
}

export interface Zone {
  zoneId: number;
  zoneName: string;
  pipeId: number;
}

export interface Utility {
  utilityId: number;
  utilityName: string;
  counterpartyIds: number[];
}

export interface Entity {
  entityId: number;
  shortName: string;
  fullName: string;
  isActive: boolean;
  childIds: number[];
}

export interface DocItem {
  fileNameOriginal: string;
  fileNameOnDisk: string;
}

export interface DocItemWithInactive {
  fileNameOriginal: string;
  fileNameOnDisk: string;
  contractDocTypeId: number;
  inactiveDate: Date;
}

export interface GridScrollPosition {
  topPos: number;
  leftPos: number;
}

export enum RefreshType {
  SelfOnly,
  WithOthers
}

export enum SaveType {
  New = 1,
  Normal = 2
}

export enum AfterCompletedAction {
  DoNothing,
  RefreshDetail,
  CloseDetail
}

export interface FormArrayChange {
  rowIndex: number;
  form: UntypedFormGroup;
  control: AbstractControl;
  value: unknown;
  formFieldName: string;
}

export enum Product {
  NaturalGas = 1,
  LiquifiedNaturalGas = 2,
  CrudeOil = 3,
  Condensate = 4,
  Retrograde = 5,
  Ethane = 12,
  Propane = 13,
  IsoButane = 14,
  NormalButane = 15,
  NaturalGasoline = 16,
  YGrade = 17,
  UnspecifiedNgl = 18,
  Gasoline = 19,
  RBOBGasoline = 20,
  DieselULSD = 21,
  DieselLSD = 22,
  JetFuel = 23,
  HeatingOil = 24,
  Power = 26,
  Coal = 27,
  Biofuels = 28,
  LMECopper = 29,
  USCocoa = 30,
  UKCocoa = 31
}

export enum DealPurpose {
  General = 8,
  Trigger = 9,
  EFP = 10,
  PhysicalExchange = 11,
  PseudoDeal = 13,
  EFS = 14,
  Hedge = 15,
  Spec = 16,
  Storage = 17,
  DirectSaleProducerPaid = 19,
  DirectSaleInternalPaid = 20,
  PTR = 21,
  Cashout = 22,
  LU = 23,
  Imbalance = 24
}

String.prototype.insert = function (start, newSubStr) {
  return this.slice(0, start) + newSubStr + this.slice(start);
};

export const pressedKeys: { [key: string]: boolean } = {};
window.onkeyup = function (e: KeyboardEvent) {
  pressedKeys[e.key] = false;
}
window.onkeydown = function (e) {
  pressedKeys[e.key] = true;
}

export function isNullOrWhitespace(input: unknown) {
  if (typeof input === 'undefined' || input === null)
    return true;
  else if (typeof input === 'string')
    return input.replace(/\s/g, '').length < 1;
  else
    return false;
}

export function handleError(error: unknown, messageService: MessageService) {
  messageService.throw(error);
  return throwError(() => error);
}

/**
 * Filters data, especially when users type in a combobox
 * Returns Filtered data based on the string filterText of the behavior that pipes this
 * @param loading A loading indicator for the page that uses this data, e.g. detailLoading$
 * @param allData An array of collections each with their own data, e.g. requiredData$
 * @param arrayPropertyName The name of the collection of data that we want to filter within allData
 */
export const filterIdNames = <T>(loading: Observable<boolean>, allData: Observable<T | IdName[]>, arrayPropertyName: keyof T) => pipe(
  switchMap((filterText: string) => combineLatest([loading, allData]).pipe(
    map(([loading, allData]) => {
      if (loading) //if loading then the control has just appeared and we need to reset the filter text
        filterText = null;
      const idNames: IdName[] = (arrayPropertyName ? (allData as T)[arrayPropertyName] : allData) as IdName[];
      return !filterText ? idNames : idNames.filter(item => item.name?.toLowerCase().indexOf(filterText.toLowerCase()) !== -1)
    })
  ))
)

export const filterNames = <T>(loading: Observable<boolean>, allData: Observable<T | string[]>, arrayPropertyName: keyof T) => pipe(
  switchMap((filterText: string) => combineLatest([loading, allData]).pipe(
    map(([loading, allData]) => {
      if (loading) //if loading then the control has just appeared and we need to reset the filter text
        filterText = null;
      const names: string[] = (arrayPropertyName ? (allData as T)[arrayPropertyName] : allData) as string[];
      return !filterText ? names : names.filter(item => item?.toLowerCase().indexOf(filterText.toLowerCase()) !== -1)
    })
  ))
)

/**
 * Filters data, especially when users type in a combobox
 * filterPropertyName is the name of the property that needs to be searched
 * this overload is used when arrayPropertyName is null and allData is an array
 */
export function filterSpecials<T>(loading: Observable<boolean>, allData: Observable<T[]>, arrayPropertyName: null, filterPropertyName: keyof T): OperatorFunction<string, T[]>;
/**
 * Filters data, especially when users type in a combobox
 * filterPropertyName is the name of the property that needs to be searched
 * this overload is used when arrayPropertyName is the name of a property within allData to get the array
 */
export function filterSpecials<T, K extends keyof T>(loading: Observable<boolean>, allData: Observable<T>, arrayPropertyName: K, filterPropertyName: (T[K] extends Array<infer U> ? keyof U : never)): OperatorFunction<string, T[K]>;
export function filterSpecials<T, K extends keyof T>(loading: Observable<boolean>, allData: Observable<T | T[]>, arrayPropertyName: K | null, filterPropertyName: keyof T | (T[K] extends Array<infer U> ? keyof U : never)): OperatorFunction<string, T[] | T[K][]> {
  return pipe(
    switchMap((filterText: string) => combineLatest([loading, allData]).pipe(
      map(([loading, allData]) => {
        return filterLocalObject(filterText, loading, allData, arrayPropertyName, filterPropertyName);
      })
    ))
  );
}

export function filterLocalObject<T, K extends keyof T>(text: string, isLoading: boolean, allData: T | T[], arrayPropertyName: K | null, filterPropertyName: keyof T | (T[K] extends Array<infer U> ? keyof U : never)) {
  if (isLoading)
    text = null;

  if (arrayPropertyName !== null) {
    const data = (allData as T)[arrayPropertyName];
    const dataArray = Array.isArray(data) ? data : [data];
    return filterText(text, dataArray, filterPropertyName as keyof T[K]);
  } else {
    const data = allData;
    const dataArray = Array.isArray(data) ? data : [data];
    return filterText(text, dataArray, filterPropertyName as keyof T);
  }
}

export function filterText<T>(filterText: string, data: T[], filterPropertyName: keyof T) {
  const hasFilterText = !isNullOrWhitespace(filterText);
  if (hasFilterText && data) {
    data = data.filter((item: T) => {
      if (filterPropertyName)
        return (item[filterPropertyName] as string)?.toLowerCase().indexOf(filterText.toLowerCase()) !== -1;
      else
        return (item as string)?.toLowerCase().indexOf(filterText.toLowerCase()) !== -1;
    });
  }
  return data;
}

export function focusInputByClassName(className: string, highlightText: boolean = false) {
  setTimeout(function () {
    const selector = `
      input.${className},
      fast-textbox.${className} input,
      fast-numerictextbox.${className} kendo-numerictextbox input,
      fast-combobox.${className} kendo-combobox input[role=combobox],
      fast-textarea.${className} textarea,
      fast-datepicker.${className} kendo-datepicker input,
      fast-toggle.${className} kendo-switch,
      .k-editor.${className} .ProseMirror`;
    const elems = document.querySelectorAll(selector);
    if (elems.length > 0) {
      const htmlElem = elems[elems.length - 1] as HTMLElement
      htmlElem.focus();
      htmlElem.scrollLeft = 0;
      if (htmlElem instanceof HTMLInputElement) {
        if (highlightText)
          htmlElem.select();
        else
          htmlElem.setSelectionRange(0, 0);
      }
    }
  }, 100);
}

export function focusInputTarget() {
  focusInputByClassName('inputFocusTarget', false);
}

export function focusAndSelectInputTarget() {
  focusInputByClassName('inputFocusTarget', true);
}

export function clearSelection() {
  if (window.getSelection) { window.getSelection().removeAllRanges(); }
}

interface ErrorResponse {
  xhr?: XMLHttpRequest;
  status?: number;
  message?: string;
  error?: {
    errors?: { [key: string]: string };
    text?: string;
  };
  responseText?: string;
  responseJSON?: string;
}
export function getErrorMessage(error: ErrorResponse): string {
  if (error) {
    let errorMsg = '';

    if (error.xhr)
      error = error.xhr;

    if (error.status && error.status === 400) {
      if (error.message && typeof error.message === "string") {
        errorMsg += error.message + '\r\n';
        const badRequestMultiErrors = error?.error?.errors;
        const badRequestSingleError = error?.error;
        if (badRequestMultiErrors) {
          errorMsg += '\r\n'
          let property: keyof typeof badRequestMultiErrors;
          for (property in badRequestMultiErrors)
            errorMsg += `${property}: ${badRequestMultiErrors[property]}\r\n`;
        }
        else if (badRequestSingleError)
          errorMsg += badRequestSingleError;
      }
      else
        errorMsg = "Error 400, Bad Request";
    }
    else if (error.status && error.status === 401) {
      errorMsg = "Error 401, Unauthorized";
    }
    else if (error.status && error.status === 403)
      errorMsg = "Error 403, Forbidden.  Please contact an administrator to request access.";
    else {
      if (error.message && typeof error.message === "string")
        errorMsg += error.message + '\r\n\r\n';

      if (error.error && typeof error.error === "string")
        errorMsg += error.error + '\r\n\r\n';

      if (error.error?.text && typeof error.error.text === "string")
        errorMsg += error.error.text + '\r\n\r\n';

      if (error.responseText)
        errorMsg += JSON.parse(error.responseText) + '\r\n\r\n';

      if (error.responseJSON)
        errorMsg += error.responseJSON + '\r\n';
    }

    if (errorMsg === '')
      errorMsg = error.toString();

    if (errorMsg.indexOf('Violation of UNIQUE KEY constraint') !== -1)
      errorMsg = 'Save changes failed since a record with this data already exists.\r\n\r\n' + errorMsg;

    return errorMsg;
  } else {
    return null;
  }
}

function isVisible(elem: HTMLElement) {
  const style = getComputedStyle(elem);
  if (style.display === 'none') return false;
  if (style.visibility !== 'visible') return false;
  if (elem.offsetWidth + elem.offsetHeight + elem.getBoundingClientRect().height + elem.getBoundingClientRect().width === 0)
    return false;
  return true;
}

function getTopMostWindow() {
  let resultWindow: HTMLElement = null;
  const dialogWindow: HTMLElement = document.querySelector(".k-window.k-dialog");

  if (dialogWindow)
    resultWindow = dialogWindow
  else {
    let zIndexMax = 0;
    document.querySelectorAll<HTMLElement>('.k-window').forEach((window: HTMLElement) => {
      const zIndexCurrent = +getComputedStyle(window).zIndex;
      if (zIndexCurrent >= zIndexMax) {
        zIndexMax = zIndexCurrent;
        resultWindow = window;
      }
    })
  }

  return resultWindow;
};

function markDatePickerTouched(datePickerInputElem: HTMLElement) {
  //for some reason the kendo datepicker does not get marked as touched automatically when tabbing to it
  //perhaps this will be fixed in a future kendo version
  const datePickerElem = datePickerInputElem.closest('.k-datepicker');
  datePickerElem.classList.add('ng-touched');
}

function isDatePickerInput(elem: HTMLElement) {
  if (elem.parentElement.classList.contains('k-dateinput-wrap'))
    return true;
  else
    return false;
}

export function handleKeys(e: KeyboardEvent): void {
  if (e.key === 'Tab')
    e.preventDefault();

  //don't check isLoading() since sometimes we have parts of the screen loading and parts that are not but we still want to tab through fields
  if (e.key === 'Tab' || e.key === 'Enter' || e.key === 'ArrowUp' || e.key === 'ArrowDown') {
    const focusedElement = document.activeElement as HTMLElement;
    const parentElement = focusedElement.parentNode as HTMLElement;
    const parentToSearch = parentElement?.parentElement;
    const containsFastKeyClass = parentToSearch && (parentToSearch.classList.contains('fastKey') || parentToSearch.querySelector('.fastKey') !== null);

    if (containsFastKeyClass)
      handleFastKeys(e);
    else if (e.key === 'Tab' || e.key === 'Enter') {
      const isEnterOnTextArea = e.key === 'Enter' && focusedElement.classList.contains("k-textarea");
      const isEnterOnEditor = e.key === 'Enter' && focusedElement.classList.contains("ProseMirror"); //kendo editor for HTML content or complex text
      const isEnterOnButton = e.key === 'Enter' && focusedElement instanceof HTMLButtonElement;
      if (!isEnterOnEditor && !isEnterOnTextArea && !isEnterOnButton) {
        const direction = e.shiftKey ? Direction.Backward : Direction.Forward;
        e.preventDefault();
        GoToNextInput(direction);
      }
    }
  }
}

function handleFastKeys(e: KeyboardEvent): void {
  e.preventDefault();
  let direction: Direction;

  if (e.key === 'ArrowUp')
    direction = Direction.Up;
  else if (e.key === 'ArrowDown')
    direction = Direction.Down;
  else if (e.key === 'Enter' && e.shiftKey)
    direction = Direction.Up;
  else if (e.key === 'Enter')
    direction = Direction.Down;
  else if (e.key === 'Tab' && e.shiftKey)
    direction = Direction.Backward;
  else if (e.key === 'Tab')
    direction = Direction.Forward;

  GoToNextFastKeyInput(direction, document.activeElement);
}

export enum Direction {
  Up,
  Down,
  Forward,
  Backward
}

export function GoToNextInput(direction: Direction = Direction.Forward, multiplier: number = 1) {
  const topMostWindow: HTMLElement = getTopMostWindow();
  const hasWindow = topMostWindow !== null;
  const pageContent = document.querySelector("app-root .pageContent");
  const hasPageContent = pageContent !== null;
  const allInputsSelector = `
    input,
    fast-combobox kendo-combobox input[role=combobox]
    fast-toggle kendo-switch,
    fast-textarea textarea,
    fast-button button,
    .k-dialog-actions,
    .ProseMirror`;

  let allInputs: NodeListOf<HTMLElement>;
  if (hasWindow)
    allInputs = topMostWindow.querySelectorAll(allInputsSelector);
  else if (hasPageContent)
    allInputs = pageContent.querySelectorAll(allInputsSelector);
  else {
    const appRoot = document.querySelector("app-root");
    allInputs = appRoot.querySelectorAll(allInputsSelector);
  }
  const eligibleInputs: HTMLElement[] = [];
  allInputs.forEach((potentialInput: HTMLElement) => {
    if (isEligible(potentialInput))
      eligibleInputs.push(potentialInput);
  })

  if (eligibleInputs.length === 0)
    return;

  const indexModifier = (direction === Direction.Backward ? -1 : 1) * multiplier;
  let nextIndex = 0;
  let loopIndex = 0;
  eligibleInputs.forEach((input: HTMLElement) => {
    if (input === document.activeElement)
      nextIndex = loopIndex + indexModifier;
    loopIndex++;
  });
  if (nextIndex >= eligibleInputs.length)
    nextIndex = 0;
  else if (nextIndex < 0)
    nextIndex = eligibleInputs.length - 1;
  const nextInput = eligibleInputs[nextIndex];

  if (nextInput)
    focusAndSelect(nextInput);
}

function focusAndSelect(input: HTMLElement) {
  //add a delay before we transfer focus to the next input
  //it seems that the kendo combobox needs to be focused for a certain amount of time (after a new value is selected), in order for the display to update
  setTimeout(() => {
    input.focus();
    const isInput = input instanceof HTMLInputElement;
    const isDatePicker = isInput && isDatePickerInput(input);

    if (isDatePicker)
      markDatePickerTouched(input);
    else if (isInput)
      input.select();
  });
}

export function GoToNextFastKeyInput(direction: Direction, inputElement: Element) {
  const rowElem = inputElement.closest('.fastKeyRow');
  if (!rowElem)
    return;

  const isInputEligible = isEligible(inputElement as HTMLElement);
  if (!isInputEligible && (direction === Direction.Up || direction === Direction.Down))
    return;

  const allInputsSelector = 'input.fastKey, kendo-datepicker.fastKey input, kendo-combobox.fastKey input, kendo-numerictextbox.fastKey input, .fastKey kendo-numerictextboxinput, .fastKey.readOnly, .fastKey.readOnly, kendo-switch.fastKey, .button-none.fastKey, .button-tiny.fastKey, .button-sm.fastKey, .button-md.fastKey, .button-lg.fastKey';
  //.fastKey.readOnly selector is for rare cases where we have a mix of input and non-input fields in a column
  //we still need to consider the non-input fields for calculation purposes, but they won't be eligible

  const allInputs: NodeListOf<HTMLElement> = rowElem.parentElement.querySelectorAll(allInputsSelector);

  if (allInputs.length === 0)
    return;

  const colCount = rowElem.querySelectorAll<HTMLElement>('.fastKey').length;
  const rowCount = rowElem.parentElement.querySelectorAll<HTMLElement>('.fastKeyRow').length;
  let indexModifier = 0;

  if (direction === Direction.Forward)
    indexModifier = 1;
  else if (direction === Direction.Backward)
    indexModifier = -1;
  else if (direction === Direction.Up)
    indexModifier = -colCount;
  else if (direction === Direction.Down)
    indexModifier = colCount;

  let nextIndex = 0;
  let loopIndex = 0;
  let colIndexTop = 0;
  let colIndexBottom = 0;
  allInputs.forEach((input: HTMLElement) => {
    if (input === inputElement) {
      colIndexTop = loopIndex % colCount;
      colIndexBottom = (rowCount - 1) * colCount + colIndexTop;
      nextIndex = loopIndex + indexModifier;
    }
    loopIndex++;
  });

  if (direction === Direction.Forward || direction === Direction.Backward) {
    if (nextIndex >= allInputs.length)
      nextIndex = 0;
    else if (nextIndex < 0)
      nextIndex = allInputs.length - 1;
  }
  else if (direction === Direction.Up || direction === Direction.Down) {
    if (nextIndex > colIndexBottom)
      nextIndex = colIndexTop;
    else if (nextIndex < colIndexTop)
      nextIndex = colIndexBottom;
  }

  const nextInput = allInputs[nextIndex] as HTMLElement;
  const isElligible = isEligible(nextInput);

  if (nextInput && isElligible)
    focusAndSelect(nextInput);
  else if (nextInput)
    GoToNextFastKeyInput(direction, nextInput);
  else
    (inputElement as HTMLInputElement).blur();
}

function isEligible(input: HTMLElement): boolean {
  const doesNotHaveReadOnlyClass = !input.classList.contains("readonly") && !input.classList.contains("readOnly");
  const doesNotHaveReadOnlyAttr = !input.hasAttribute("readonly");
  const isNotDisabled = !input.hasAttribute("disabled");
  const isNotHidden = isVisible(input);
  const isEditor = input.classList.contains("ProseMirror"); //kendo editor for HTML content or complex text
  const isSwitch = input.classList.contains("k-switch-track");
  const hasTabIndex = input.tabIndex >= 0 || isEditor;
  const isNotReadonlyEditor = !(isEditor && input.parentElement.parentElement.classList.contains("k-readonly"));
  const isNotReadOnly = isNotReadonlyEditor && doesNotHaveReadOnlyClass && doesNotHaveReadOnlyAttr;
  if (isNotDisabled && (hasTabIndex || isSwitch) && isNotReadOnly && isNotHidden && doesNotHaveReadOnlyClass)
    return true;
  return false;
}

export function selectInputTextOnClick(e: MouseEvent): void {
  if (e.target instanceof HTMLInputElement) {
    const clickedInputElem = e.target as HTMLInputElement;
    const isDatePicker = isDatePickerInput(clickedInputElem);
    const isAlreadyFocused = document.activeElement === clickedInputElem;
    if (!isDatePicker && !isAlreadyFocused) {
      setTimeout(() => {
        clickedInputElem.select();
      })
    }
  }
}

const dateWords = ['day', 'date', 'month', 'time', 'cbd', 'saved', 'savedtime'];
const excludedWords = ['updatenotes', 'updatedby', 'monthly', 'savedby'];

/**
 * Converts properties with names that contain date-like words from string values to Date objects.
 * @param data The data object or array of objects to process.
 * @param temporaryExcludedWords Additional words to exclude from date conversion.
 */
export function convertToDates<T extends object>(
  data: T | T[],
  temporaryExcludedWords: string[] = []
): void {
  if (!data) return;

  const isArray = Array.isArray(data);
  if (isArray) {
    (data as T[]).forEach(item => convertToDates(item, temporaryExcludedWords));
    return;
  }

  const allExcludedWords = excludedWords.concat(temporaryExcludedWords);
  const objPropNames = Object.keys(data) as (keyof T)[];

  objPropNames.forEach(propName => {
    const value = data[propName];
    if (value && typeof value === 'object' && !(value instanceof Date)) {
      // Recursively process nested objects
      convertToDates(value as T, temporaryExcludedWords);
    } else if (typeof value === 'string') {
      const propNameLower = String(propName).toLowerCase();

      const hasExcludedWord = allExcludedWords.some(excludedWord =>
        propNameLower.includes(excludedWord.toLowerCase())
      );

      if (hasExcludedWord)
        return;

      const hasDateWord = dateWords.some(dateWord =>
        propNameLower.includes(dateWord.toLowerCase())
      );

      if (!hasDateWord)
        return;

      const parsedDate = dayjs(value).toDate();
      if (!isNaN(parsedDate.getTime())) // Ensure valid date
        data[propName] = parsedDate as T[keyof T];
    }
  });
}

export enum dialogAction {
  Yes,
  No,
  OK,
  Continue,
  Cancel,
  Save,
  DontSave,
  Closed
}

export function getDialogAction(dialogResult: DialogResult): dialogAction {
  if (dialogResult instanceof DialogCloseResult)
    return dialogAction.Cancel
  else {
    switch (dialogResult.text.toLowerCase()) {
      case 'distribute internally':
      case 'distribute externally':
      case 'send':
      case 'yes':
        return dialogAction.Yes;
      case 'no':
        return dialogAction.No;
      case 'ok':
        return dialogAction.OK;
      case 'continue':
        return dialogAction.Continue;
      case 'cancel':
        return dialogAction.Cancel;
      case 'save':
      case 'apply changes':
        return dialogAction.Save;
      case 'don\'t save':
      case 'discard changes':
        return dialogAction.DontSave;
      case 'closed':
        return dialogAction.Closed;
    }
  }
}

export function isPageContentInsidePopup(): boolean {
  const popupPageContent: HTMLElement = document.querySelector('.k-window .pageContent');
  if (popupPageContent)
    return true;
  else
    return false;
}

export function trySetTitle(titleService: Title, newTitle: string, hasDelay: boolean = true): void {
  //global bar sets hasDelay to false
  //everything else assumes hasDelay is true so that page titles gets set by global-bar first and then the page second
  if (!isPageContentInsidePopup()) {
    if (hasDelay)
      setTimeout(() => titleService.setTitle(newTitle), 100);
    else
      titleService.setTitle(newTitle);
  }
}

export function getPopupTopPosition(popupHeight: number) {
  //check if we have pageContent inside a popup window
  const popupPageContent: HTMLElement = document.querySelector('.pageContent');
  if (popupPageContent) {
    const pageHeight = popupPageContent.offsetHeight;
    return (pageHeight / 2) - (popupHeight / 2);
  }
  else
    return null;
}

export function getPopupLeftPosition(popupWidth: number, pageWidth?: number) {
  //check if we have pageContent inside a popup window
  const popupPageContent: HTMLElement = document.querySelector('.pageContent');
  if (popupPageContent) {
    pageWidth = pageWidth ?? popupPageContent.offsetWidth;
    return (pageWidth / 2) - (popupWidth / 2);
  }
  else
    return null;
}

export function downloadFile(http: HttpClient, url: string): Observable<{ fileBlob: Blob; fileName: string }> {
  return http.get(url, { observe: 'response', responseType: 'blob' }).pipe(
    map(result => {
      const fileBlob = result.body;
      let fileName = "Error.txt";
      if (result.body.type !== "text/plain") {
        const contentDisposition = result.headers.get('Content-Disposition');
        if (contentDisposition) {
          const fileNameRegex = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/; //https://stackoverflow.com/questions/23054475/javascript-regex-for-extracting-filename-from-content-disposition-header
          const matches = fileNameRegex.exec(contentDisposition);
          if (matches !== null && matches[1]) {
            fileName = matches[1].replace(/['"]/g, '');
          }
        }
      }

      return { fileBlob, fileName };
    })
  );
}

export function downloadFileWithBody(http: HttpClient, url: string, body: unknown): Observable<{ fileBlob: Blob; fileName: string }> {
  return http.put(url, body, { observe: 'response', responseType: 'blob' }).pipe(
    map(result => {
      const fileBlob = result.body;
      let fileName = "Error.txt";
      if (result.body.type !== "text/plain") {
        const contentDisposition = result.headers.get('Content-Disposition');
        if (contentDisposition) {
          const fileNameRegex = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/; //https://stackoverflow.com/questions/23054475/javascript-regex-for-extracting-filename-from-content-disposition-header
          const matches = fileNameRegex.exec(contentDisposition);
          if (matches !== null && matches[1]) {
            fileName = matches[1].replace(/['"]/g, '');
          }
        }
      }

      return { fileBlob, fileName };
    })
  );
}

let previousTableCellId: string = null;
export function showGridTooltip(e: MouseEvent, tooltipGrid: TooltipDirective): void {
  const element = e.target as HTMLElement;
  //searh upwards in the dom from element to find a parent that is a table th or td
  const td = element.closest('td');
  const th = element.closest('th');
  const tableCellElem = th ?? td;

  if (!tableCellElem) {
    tooltipGrid.hide();
    return;
  }

  const textElem = getTextElemFromTableCell(tableCellElem);
  if (!textElem) {
    tooltipGrid.hide();
    return;
  }

  const isTextClipped = isClipped(textElem, 10);
  if (!isTextClipped) {
    tooltipGrid.hide();
    return;
  }

  const parentRow = tableCellElem.parentElement as HTMLTableRowElement;
  const currentTableCellId = `cell-${parentRow.rowIndex}-${tableCellElem.cellIndex}`;
  if (previousTableCellId !== currentTableCellId) {
    tooltipGrid.hide();
    previousTableCellId = currentTableCellId;
  }
  setTimeout(() => {
    const isOpen = tooltipGrid?.popupRef != null;
    if (!isOpen)
      tooltipGrid.show(tableCellElem);
  });
}

export function getTableCellTooltipText(tableCellElem: HTMLTableCellElement): string[] {
  const textElem = getTextElemFromTableCell(tableCellElem);
  if (!textElem)
    return null;

  const text = textElem.nodeName === 'INPUT' ? (textElem as HTMLInputElement).value : textElem.innerText;
  if (!text)
    return null;

  const textArray = text.split(` ${bulletSymbol} `);
  return textArray;
}

export function getTextElemFromTableCell(tableCellElem: HTMLTableCellElement): HTMLElement {
  const gridInput = tableCellElem.querySelector('input');
  const gridDiv = tableCellElem.querySelector('div');
  const gridSpan = tableCellElem.querySelector('span');
  const isValidInput = gridInput && gridInput.type !== "checkbox";

  const textElem = (() => {
    if (isValidInput) return gridInput;
    if (gridDiv) return gridDiv;
    if (gridSpan) return gridSpan
    return tableCellElem;
  })();

  return textElem;
}

export function isClipped(e: HTMLElement, totalHorizontalPadding = 0) {
  const tolerance = -1.5;
  const text = e.nodeName === 'INPUT' ? (e as HTMLInputElement).value : e.innerText;
  const computedStyle = window.getComputedStyle(e);
  const textWidth = getTextWidth(text, computedStyle.font);
  const leftPadding = parseFloat(computedStyle.paddingLeft);
  const rightPadding = parseFloat(computedStyle.paddingLeft);
  if (totalHorizontalPadding === 0)
    totalHorizontalPadding = leftPadding + rightPadding;
  return (textWidth + tolerance) >= (e.clientWidth - totalHorizontalPadding);
}

let textWidthCanvas: HTMLCanvasElement;
export function getTextWidth(text: string, font: string) {
  // re-use canvas object for better performance
  const canvas = textWidthCanvas || (textWidthCanvas = document.createElement("canvas"));
  const context = canvas.getContext("2d");
  context.font = font;
  const metrics = context.measureText(text);
  return metrics.width;
}

export function round(value: number, decimals: number) {
  if (value) {
    const sign = Math.sign(value);
    const multiplier = Math.pow(10, decimals); //used to round to requested decimals
    value = Math.abs(value); //remove the sign and then add it back later, otherwise negative numbers don't get rounded correctly
    value = roundExtraFloatDecimals(value, decimals);
    value = value * multiplier;
    value = roundExtraFloatDecimals(value, decimals)
    value = Math.round(value) / multiplier;
    value = sign * value; //add back the sign
  }
  return value;
}

function roundExtraFloatDecimals(value: number, decimals: number) {
  //used to get rid of .999999 or .000001
  const preMultiplier = Math.pow(10, decimals + 8);
  value = Math.round(value * preMultiplier) / preMultiplier;
  return value;
}

export function parseWithDate(jsonString: string): unknown {
  const reDateDetect = /(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})/;  // startswith: 2015-04-29T22:06:55
  const resultObject = JSON.parse(jsonString, (key: string, value: unknown) => {
    if (typeof value == 'string' && (reDateDetect.exec(value))) {
      return dayjs(value).toDate();
    }
    return value;
  });
  return resultObject;
}

export function kendoFilterHasInvalidDate(filter: CompositeFilterDescriptor): boolean {
  let hasInvalidDate = false;
  if (filter?.filters) {
    filter.filters.forEach(item => {
      if ('field' in item && 'value' in item && item['value'] !== null && item['value'] !== undefined && item['value'] instanceof Date) {
        const date = dayjs(item['value']);
        const isValidDate = date.isValid() && date.isSameOrAfter(minDate) && date.isBefore(maxDate);
        hasInvalidDate = !isValidDate;
      }
      else if ('filters' in item && item['filters'].length > 0) {
        const subItems = item['filters'];
        subItems.forEach(subItem => {
          if ('field' in subItem && subItem['value'] !== null && subItem['value'] !== undefined && subItem['value'] instanceof Date) {
            const date = dayjs(subItem['value']);
            const isValidDate = date.isValid() && date.isSameOrAfter(minDate) && date.isBefore(maxDate);
            hasInvalidDate = !isValidDate;
          }
        });
      }
    });
  }
  return hasInvalidDate;
}

//ensure that months are converted to the first day of the month
export function fixKendoQueryFilter(filter: CompositeFilterDescriptor) {
  if (filter?.filters) {
    filter.filters.forEach(item => {
      if ('field' in item && (item['field'] as string).toLowerCase().indexOf('month') !== -1 && item['value'] !== null && item['value'] !== undefined)
        item['value'] = dayjs(item['value']).startOf('month').toDate();
      else if ('filters' in item && item['filters'].length > 0) {
        const subItems = item['filters'];
        subItems.forEach(subItem => {
          if ('field' in subItem && (subItem['field'] as string).toLowerCase().indexOf('month') !== -1 && subItem['value'] !== null && subItem['value'] !== undefined)
            subItem['value'] = dayjs(subItem['value']).startOf('month').toDate();
        });
      }
    });
  }
}

export function getDateConvertedToCentralTime(date: Date): Date {
  //sometimes the client time from a Kendo datepicker will be set to Eastern or some timezone other than the server (which is generally Central time)
  //the server then converts to Central timezone
  //if a time of 1/1/2020 12am Eastern time is converted to Central then on the server it might show up as 12/31/2019 11pm Central and can cause problems
  //this method ensures that the time is either already a central time or has the hour adjusted if it is a different time zone
  //theoretically we should be able to just use 'resultDate = dayjs(date).tz("America/Chicago", true).toDate();
  //but we use the workaround below due to a bug described here: https://github.com/moment/moment-timezone/issues/649
  let resultDate: Date;
  let resultDateStr: string;

  const selectedTimeZone = 'America/Chicago';
  const localTimeZone = dayjs.tz.guess();
  if (localTimeZone !== selectedTimeZone) {
    const dateStr = dayjs(date).format('YYYY-MM-DDTHH:mm:ss');
    resultDateStr = dayjs.tz(dateStr, selectedTimeZone).toJSON();
    resultDate = dayjs(resultDateStr).toDate();
  }
  else
    resultDate = date;

  return resultDate;
}

export class DateRange {
  monthRangeDisplayFormat = 'MMMM YYYY';
  dateRangeDisplayFormat = 'M/D/YYYY';
  saveFormat = 'YYYY/MM/DD';
  mFromDate: dayjs.Dayjs;
  mToDate: dayjs.Dayjs;

  constructor(fromDate: Date, toDate: Date, private dateStyle: string) {
    if (dateStyle === 'MonthRange') {
      this.mFromDate = dayjs(fromDate).startOf('month');
      this.mToDate = dayjs(toDate).endOf('month');
    } else {
      this.mFromDate = dayjs(fromDate).startOf('day');
      this.mToDate = dayjs(toDate).startOf('day');
    }
  }

  get fromDate(): Date {
    return this.mFromDate.toDate();
  }

  get toDate(): Date {
    return this.mToDate.toDate();
  }

  get displayText(): string {
    let result: string
    const displayFormat: string = this.dateStyle === 'MonthRange' ? this.monthRangeDisplayFormat : this.dateRangeDisplayFormat;

    if (this.dateStyle === 'DateRange' && this.mFromDate.isSame(this.mToDate))
      result = this.mFromDate.format(displayFormat);
    else if (this.dateStyle === 'MonthRange' && this.mFromDate.year() === this.mToDate.year() && this.mFromDate.month() === this.mToDate.month())
      result = this.mFromDate.format(displayFormat);
    else
      result = this.mFromDate.format(displayFormat) + ' to ' + this.mToDate.format(displayFormat)

    return result;
  }

  get stringValue(): string {
    let result: string;
    if (this.mFromDate.isSame(this.mToDate))
      result = this.mFromDate.format(this.saveFormat);
    else
      result = this.mFromDate.format(this.saveFormat) + '-' + this.mToDate.format(this.saveFormat);
    return result;
  }

  isSame(dateRangeToCompare: DateRange): boolean {
    const result = this.mFromDate.isSame(dateRangeToCompare.mFromDate) && this.mToDate.isSame(dateRangeToCompare.mToDate);
    return result;
  }
}

function isCompositeFilterDescriptor(filter: FilterDescriptor | CompositeFilterDescriptor): filter is CompositeFilterDescriptor {
  return (
    'filters' in filter &&
    Array.isArray(filter.filters) &&
    typeof filter.logic === 'string' &&
    (filter.logic === 'and' || filter.logic === 'or')
  );
}

/**
 * Flattens a CompositeFilterDescriptor or FilterDescriptor into an array of FilterDescriptor.
 * @param filter - The filter to flatten.
 * @returns An array of FilterDescriptor.
 */
export const flatten = (filter: FilterDescriptor | CompositeFilterDescriptor): FilterDescriptor[] => {
  if (isCompositeFilterDescriptor(filter)) {
    return filter.filters.reduce<FilterDescriptor[]>((acc, curr) => {
      if (isCompositeFilterDescriptor(curr)) {
        return acc.concat(flatten(curr));
      } else {
        acc.push(curr);
        return acc;
      }
    }, []);
  }
  // If the filter is a single FilterDescriptor, include it in the array
  return ('field' in filter) ? [filter] : [];
};

export function isFilterFieldRecursive(filter: CompositeFilterDescriptor | FilterDescriptor, fieldName: string): boolean {
  if ((filter as FilterDescriptor).field) {
    const nonCompositeFilter = filter as FilterDescriptor;
    if (nonCompositeFilter.field === fieldName)
      return true;
    if (nonCompositeFilter.field !== fieldName)
      return false;
  } else if ((filter as CompositeFilterDescriptor).filters) {
    const compositeFilter = filter as CompositeFilterDescriptor;
    if (compositeFilter.filters.some(x => isFilterFieldRecursive(x, fieldName)))
      return true;
    else
      return false;
  }
}

export function getGridContentElement(kendoGridEl: ElementRef): Element {
  const el = kendoGridEl?.nativeElement as HTMLElement;
  const gridContent = el?.getElementsByClassName("k-grid-content").item(0);
  return gridContent;
}

export function goToSavedGridScrollPos(kendoGridElOrEls: ElementRef | QueryList<ElementRef>, gridScrollPosition: GridScrollPosition): void {
  let attempts = 0;
  const interval = 100; // Check every 100 ms

  const checkAndScroll = setInterval(() => {
    const kendoGridEl = kendoGridElOrEls instanceof QueryList ? kendoGridElOrEls.get(0) : kendoGridElOrEls;
    const gridContent = getGridContentElement(kendoGridEl);
    attempts++;

    if (gridContent) {
      gridContent.scrollTo({ top: gridScrollPosition.topPos, left: gridScrollPosition.leftPos });
      clearInterval(checkAndScroll); // Stop checking if the element is found
    } else if (attempts * interval >= 2000) {
      clearInterval(checkAndScroll); // Stop checking after 2 seconds
    }
  }, interval);
}

export function saveGridScrollPos(kendoGridElOrEls: ElementRef | QueryList<ElementRef>, gridScrollPosition: GridScrollPosition): void {
  const kendoGridEl = kendoGridElOrEls instanceof QueryList ? kendoGridElOrEls.get(0) : kendoGridElOrEls;
  const gridContent = getGridContentElement(kendoGridEl);
  gridScrollPosition.topPos = gridContent ? gridContent.scrollTop : 0;
  gridScrollPosition.leftPos = gridContent ? gridContent.scrollLeft : 0;
}

export function getWinHorizontalCenter(windowWidth: number): number {
  const windowHorizontalCenter = (window.innerWidth / 2) - (windowWidth / 2);
  return windowHorizontalCenter;
}

export function idsToNamesJoin(ids: number[], idNames: { id: number; name: string }[]): string {
  if (!ids?.length)
    return null;

  ids = ids.filter(id => id !== null && id !== undefined);

  let joinedNames = null;
  if (ids) {
    const names = ids.map(id => {
      return idNames.find(idName => idName.id == id).name;
    });
    names.sort();
    joinedNames = names.join(` ${bulletSymbol} `);
  }

  return joinedNames;
}

export function getRandomInt() {
  return Math.floor(Math.random() * (999999999 - 900000000 + 1)) + 900000000;
}

/**
 * Handles the user's choice when they are about to leave a page with unsaved changes. All actions must use arrow function syntax to preserve the correct 'this' context.
 * @example ```typescript
 * save = (saveType: util.SaveType) => {
 * //code here
 * }
 * //or with the extraActionParamValue option configured:
  * save = (saveType: util.SaveType, anyParamName: anyTypeYouWant) => {
 * //code here
 * }
 * ```
 * @param detailFormOrForms The form or forms to check for changes.
 * @param dialogService The dialog service to use for displaying the confirmation dialog.
 * @param fnDontSaveAction The action to take when the user chooses not to save changes. Typically this is a close method.
 * @param fnSaveAction The action to take when the user chooses to save changes. Typically this is a save method.
 * @param options Additional options. {
 * - `fnCancelAction` The action to take when the user cancels the dialog. Defaults to doing nothing.
 * - `confirmType` The type of confirmation dialog to display. Defaults to 'save'. May be 'set' if the user is setting a local value instead of saving it.
 * - `extraActionParamValue ` Additional optional parameter, E.G. util.AfterCompletedAction, to pass from onDetailChanging to the action functions (fnDontSaveAction, fnSaveAction, fnCancelAction).
 * - `overrideIsDirty` Whether to override the dirty check and always show the dialog, regardless of whether the form is dirty. Defaults to false.
 * - `skipPristine` Whether to skip marking the form as pristine after the user makes a non-cancel choice. Defaults to false.
 * - }
 * @param options.cancelAction - The action to take when the user cancels the dialog. Defaults to doing nothing.
 * @param options.confirmType - The type of confirmation dialog to display. Defaults to 'save'. May be 'set' if the user is setting a local value instead of saving it.
 * @param options.extraActionParamValue - Additional optional parameter, E.G. util.AfterCompletedAction, to pass from onDetailChanging to the action functions (fnDontSaveAction, fnSaveAction, fnCancelAction).
 * @param options.overrideIsDirty - Whether to override the dirty check and always show the dialog, regardless of whether the form is dirty. Defaults to false.
 * @param options.skipPristine - Whether to skip marking the form as pristine after the user makes a non-cancel choice. Defaults to false.
 * @returns void
 * @example ```typescript
 * onDetailChanging(
 *  detailForm,
 *  dialogService,
 *  close,
 *  save,
 *  {
 *  cancelAction: cancel,
 *  confirmType: 'save',
 *  extraActionParamValue: util.AfterCompletedAction.RefreshDetail,
 *  overrideIsDirty: true,
 *  skipPristine: true
 *  });
 * ```
*/
export function onDetailChanging<actionParam>(
  detailFormOrForms: UntypedFormGroup | UntypedFormGroup[],
  dialogService: DialogService,
  fnDontSaveAction: (extraActionParamValue?: actionParam) => void,
  fnSaveAction: (saveType: SaveType, extraActionParamValue?: actionParam) => void,
  options?: {
    fnCancelAction?: (extraActionParamValue?: actionParam) => void;
    confirmType?: 'save' | 'set';
    extraActionParamValue?: actionParam;
    overrideIsDirty?: boolean;
    skipPristine?: boolean;
  }
): void {
  if (!options)
    options = {};

  if (!options.confirmType)
    options.confirmType = 'save';

  if (!options.skipPristine)
    options.skipPristine = false;

  if (!options.overrideIsDirty)
    options.overrideIsDirty = false;

  let detailForms: UntypedFormGroup[];

  if (detailFormOrForms instanceof UntypedFormGroup)
    detailForms = [detailFormOrForms];
  else if (detailFormOrForms instanceof FormArray)
    detailForms = detailFormOrForms.controls as UntypedFormGroup[];
  else
    detailForms = detailFormOrForms;

  const isParentOfFormsDirty = detailForms.length > 0 && detailForms[0].parent?.dirty;
  const isAnyFormDirty = detailForms.some(formGroup => {
    return formGroup.dirty;
  });

  if (!options.overrideIsDirty && !isAnyFormDirty && !isParentOfFormsDirty && fnDontSaveAction) {
    fnDontSaveAction(options.extraActionParamValue);
    return;
  }

  let content: string;
  let actions: DialogAction[];

  if (options.confirmType === 'save' || options.confirmType == null) {
    content = 'You have unsaved changes. What would you like to do?';
    actions = [
      { text: 'Save', cssClass: 'k-primary' },
      { text: 'Don\'t Save' },
      { text: 'Cancel' }
    ];
  } else if (options.confirmType === 'set') {
    content = 'You have changes there were not applied. What would you like to do?';
    actions = [
      { text: 'Apply Changes', cssClass: 'k-primary' },
      { text: 'Discard Changes' },
      { text: 'Cancel' }
    ];
  }

  const unsavedChangesSettings: DialogSettings = {
    title: 'Please confirm',
    content: content,
    actions: actions,
    cssClass: 'utilPrompt'
  }

  dialogService.open(unsavedChangesSettings).result.pipe(take(1)).subscribe(result => {
    const actionResult = getDialogAction(result);
    if (actionResult === dialogAction.Save && fnSaveAction)
      fnSaveAction(SaveType.Normal, options.extraActionParamValue);
    else if (actionResult === dialogAction.DontSave && fnDontSaveAction)
      fnDontSaveAction(options.extraActionParamValue);
    else if (options.fnCancelAction)
      options.fnCancelAction(options.extraActionParamValue)

    if (!options.skipPristine && actionResult !== dialogAction.Cancel && actionResult !== dialogAction.DontSave) {
      detailForms.forEach(formGroup => {
        formGroup.markAsPristine();
      });
    }
  });
}

type formArrayValueChangedFunc = (formArrayChange: FormArrayChange) => void;

export function watchForFormArrayChanges(formArrayOrItems: UntypedFormArray | UntypedFormGroup[], formFieldName: string, unsubscribeSubject$: Subject<unknown>, formArrayValueChangedMethod: formArrayValueChangedFunc, loading$: BehaviorSubject<boolean>) {
  // cleanup any prior subscriptions before re-establishing new ones
  unsubscribeSubject$.next(null);

  const formControls = formArrayOrItems instanceof UntypedFormArray ? formArrayOrItems.controls : formArrayOrItems;

  merge(...formControls.map((form: AbstractControl, index: number) => {
    const typedForm = form as UntypedFormGroup;
    const control = typedForm.get(formFieldName);
    return control.valueChanges.pipe(
      filter(() => {
        return !loading$.getValue();
      }),
      takeUntil(unsubscribeSubject$),
      map(value => {
        const change: FormArrayChange = { rowIndex: index, form: typedForm, control: control, value: value, formFieldName: formFieldName };
        return change;
      })
    );
  })).subscribe((change: FormArrayChange) => {
    formArrayValueChangedMethod(change);
  });
}

export function kendoFormatNumber(num: number, format: string) {
  return formatNumber(num, format);
}

export function formatDate(date: Date, format: string) {
  return dayjs(date).format(format);
}

export function isNumber(value: string | number): boolean {
  value = value?.toString().replace(',', '').trim();
  return ((value != null) &&
    (value !== '') &&
    !isNaN(Number(value.toString())));
}

export function reloadTreeView(isExhibitBReloading$: BehaviorSubject<boolean>, ref: ChangeDetectorRef) {
  isExhibitBReloading$.next(true);
  ref.detectChanges();
  isExhibitBReloading$.next(false);
  ref.detectChanges();
}

export function setCaretPosition(elemId: string, caretPos: number) {
  const elem = document.getElementById(elemId);

  if (elem != null && elem instanceof HTMLInputElement) {
    if (elem.selectionStart) {
      elem.focus();
      elem.setSelectionRange(caretPos, caretPos);
    }
    else
      elem.focus();
  }
}

function printHtml(html: string) {
  try {
    let iframe = document.getElementById("printingFrame") as HTMLIFrameElement;
    if (!iframe) {
      iframe = document.createElement('iframe');
      iframe.id = "printingFrame";
      iframe.name = "printingFrame";
      iframe.width = '0';
      iframe.height = '0';
      document.body.appendChild(iframe);
    }

    iframe.contentWindow.document.open();
    iframe.contentWindow.document.write('<!DOCTYPE html');
    iframe.contentWindow.document.write('<html><head>');
    iframe.contentWindow.document.write(
      '<style type="text/css">' +
      'body{font-family:Verdana, Arial;font-size:12px;}' +
      '@media all {.page-break { display: none; }}' +
      '@media print {.page-break { display: block; page-break-before: always; }}' +
      '</style>');
    iframe.contentWindow.document.write('</head><body>');
    iframe.contentWindow.document.write(html);
    iframe.contentWindow.document.write('</body></html>');
    iframe.contentWindow.document.close();

    const printingFrame = 'printingFrame' in window.frames && window.frames["printingFrame"] as Window;
    if (printingFrame) {
      printingFrame.focus();
      printingFrame.print();
    }
  } catch (ex) {
    console.error("Error printing: " + (ex as Error).message);
  }
}

function printValueOrEmpty(value: string) {
  if (isNullOrWhitespace(value))
    return '<span style="color:gray;font-size:0.9em">(empty)</span>';
  else
    return value.trim().replace(/\n/g, '<br>');
}

export function printData() {
  const componentName: string = 'app-price-index';
  const printLabelsHtmlArray: string[] = [];
  const printDataHtmlArray: string[] = [];
  const labelElems = document.querySelectorAll(`${componentName} .k-window .k-window-content label`);
  const labelColor = "gray";
  const labelFontSize = "0.9em";
  const labelStyle = `style="color:${labelColor};font-size:${labelFontSize};"`;

  const dataElemPrefix = `${componentName} .k-window `;
  const dataElemSuffix = ':not(.skipPrint)';
  const dataElemSelectorsArray: string[] = [];
  dataElemSelectorsArray.push(`${dataElemPrefix} fast-textbox${dataElemSuffix} input`);
  dataElemSelectorsArray.push(`${dataElemPrefix} fast-combobox${dataElemSuffix} input.k-input-inner`);
  dataElemSelectorsArray.push(`${dataElemPrefix} fast-textarea${dataElemSuffix} textarea`);
  dataElemSelectorsArray.push(`${dataElemPrefix} fast-multiselect${dataElemSuffix}`);

  const dataElemSelectorsStr = dataElemSelectorsArray.join(',');
  const dataElems = document.querySelectorAll(dataElemSelectorsStr);
  const dataColor = "#111111";
  const dataFontSize = "1.1em";
  const dataStyle = `style="color:${dataColor};font-size:${dataFontSize};"`;

  labelElems.forEach(elem => {
    if (elem instanceof HTMLLabelElement)
      printLabelsHtmlArray.push(`<span ${labelStyle}>` + elem.innerText + '</span><br>');
  });

  dataElems.forEach(elem => {
    if (elem instanceof HTMLInputElement)
      printDataHtmlArray.push(`<span ${dataStyle}>` + printValueOrEmpty(elem.value) + '</span><br><br>');
    else if (elem instanceof HTMLTextAreaElement)
      printDataHtmlArray.push(`<span ${dataStyle}>` + printValueOrEmpty(elem.value) + '</span><br><br>');
    else if (elem.nodeName === 'FAST-MULTISELECT') {
      const multiValueStrs: string[] = [];
      if (elem.childNodes.length > 0) {
        const children = elem.querySelectorAll('span.k-chip-label');
        children.forEach(child => {
          if (child instanceof HTMLSpanElement)
            multiValueStrs.push(child.innerText);
        });
      }
      const multiValueStr = multiValueStrs.join(` ${bulletSymbol} `);
      printDataHtmlArray.push(`<span ${dataStyle}>` + printValueOrEmpty(multiValueStr) + '</span><br><br>');
    }
  });

  //combine the labels and data arrays
  let printHtmlStr = '';
  for (let i = 0; i < printLabelsHtmlArray.length; i++) {
    printHtmlStr += printLabelsHtmlArray[i];
    if (printDataHtmlArray[i])
      printHtmlStr += printDataHtmlArray[i];
  }

  printHtml(printHtmlStr);
}

export function hideTooltips(tooltips: TooltipDirective[]) {
  tooltips.forEach(tooltip => {
    if (tooltip)
      tooltip.hide();
  });
}

export function deepCopy<T>(obj: T): T {
  // Handle the 3 simple types, and null or undefined
  if (obj == null || typeof obj != 'object') return obj;

  // Handle Date
  if (obj instanceof Date)
    return new Date(obj.getTime()) as T;

  // Handle Array
  if (Array.isArray(obj)) {
    const copy = [];
    for (let i = 0, len = obj.length; i < len; i++)
      copy[i] = deepCopy(obj[i]);
    return copy as T;
  }

  // Handle Object
  if (obj instanceof Object) {
    const copy: { [key: string]: unknown } = {};
    for (const attr in obj) {
      if (attr in obj)
        copy[attr] = deepCopy(obj[attr]);
    }
    return copy as T;
  }

  throw new Error("Unable to copy obj! Its type isn't supported.");
}

//only debounces after a specified amount of emissions
const debounceTimeAfter = <T>(
  amount: number,
  duration: number,
  scheduler: SchedulerLike = asyncScheduler
): MonoTypeOperatorFunction<T> => {
  return (source$: Observable<T>): Observable<T> => {
    return new Observable<T>(subscriber => {
      // keep track of iteration count until flow completes
      let iterationCount = 0;

      return source$
        .pipe(
          tap(value => {
            // increment iteration count
            iterationCount++;
            // emit value to subscriber when it is <= iteration amount
            if (iterationCount <= amount) {
              subscriber.next(value);
            }
          }),
          // debounce according to provided duration
          debounceTime(duration, scheduler),
          tap(value => {
            // emit subsequent values to subscriber
            if (iterationCount > amount) {
              subscriber.next(value);
            }
            // reset iteration count when debounce is completed
            iterationCount = 0;
          }),
        )
        .subscribe();
    });
  };
};

//only starts debouncing after the first emission and resets the behavior after the debounce time is over
export function fastDebounce(dueTime: number) {
  return debounceTimeAfter(1, dueTime);
}

export function getInnerMostChildElement(element: HTMLElement): HTMLElement {
  if (element.children.length > 0)
    return getInnerMostChildElement(element.children[0] as HTMLElement);
  else
    return element;
}

export function sortByProperties<T>(
  items: T[],
  sort: SortDescriptor[],
  ...idToNameReplacer: { records: Record<number, string>; propNames: string[] }[]
): T[] {
  const sortedItems = structuredClone(items);

  sortedItems.sort((a: T, b: T) => {
    for (const sortObj of sort) {
      const propName = sortObj.field as keyof T;
      let direction: 'asc' | 'desc';

      if (sortObj.dir === 'asc' || sortObj.dir === 'desc')
        direction = sortObj.dir;
      else
        throw new Error(`Invalid sortOrder: ${sortObj.dir}`);

      if (!Object.keys(a).includes(propName as string))
        throw new Error(`Invalid sortProperty: ${propName as string}`);

      let propAvalue: T[keyof T] | string = a[propName] ?? null;
      let propBvalue: T[keyof T] | string = b[propName] ?? null;

      if (idToNameReplacer.length > 0) {
        for (const replacer of idToNameReplacer) {
          if (replacer.propNames.includes(propName as string)) {
            //propName should be the name of an id property that holds a number, e.g. 'pipeId'
            propAvalue = replacer.records[a[propName] as number] ?? null;
            propBvalue = replacer.records[b[propName] as number] ?? null;
            break;
          }
        }
      }

      if (propAvalue === null || propAvalue < propBvalue)
        return direction === 'asc' ? -1 : 1;
      else if (propBvalue === null || propAvalue > propBvalue)
        return direction === 'asc' ? 1 : -1;
    }

    return 0;
  });

  return sortedItems;
}

export function convertDatesToDateOnlyStrings<T>(
  data: T,
  dateFields: (keyof T)[],
  recursive?: boolean
): T;

export function convertDatesToDateOnlyStrings<T>(
  data: T[],
  dateFields: (keyof T)[],
  recursive?: boolean
): T[];

/**
 * Converts the specified date fields to strings, for properties that are DateOnly type in C#.
 * Optionally, it can recursively convert date fields in nested objects.
 * @param data The data object or array of objects containing date fields.
 * @param dateFields An array of keys in data that should be converted if they are Date objects.
 * @param recursive Optional boolean indicating whether to recursively convert date fields in nested objects. Defaults to false.
 * @returns A new object or array of objects with specified date fields converted to date-only strings.
 */
export function convertDatesToDateOnlyStrings<T>(
  data: T | T[],
  dateFields: (keyof T)[],
  recursive: boolean = false
): T | T[] {
  // Helper function to process a single object
  const processItem = (item: T): T => {
    if (!(item instanceof Object)) return item; // Return non-objects as is

    // Create a shallow copy to avoid mutating the original object
    const newItem: T = { ...item };

    // Iterate over the specified date fields and convert them if they are Date instances
    dateFields.forEach((field) => {
      const value = newItem[field];
      if (value instanceof Date) {
        // Convert Date to 'YYYY-MM-DD' string
        newItem[field] = (value.toLocaleDateString("en-CA") as unknown) as T[typeof field];
      }
    });

    // If recursive is true, traverse nested objects and apply the conversion
    if (recursive) {
      (Object.keys(newItem) as Array<keyof T>).forEach((key) => {
        const prop = newItem[key];
        // Check if the property is an object (but not null or a Date)
        if (prop && typeof prop === 'object' && !(prop instanceof Date) && Array.isArray(prop)) {
          // Recursively process the nested object or array
          newItem[key] = convertDatesToDateOnlyStrings(prop, dateFields, true) as T[typeof key];
        }
      });
    }

    return newItem;
  };

  if (Array.isArray(data))
    return data.map(processItem) as T[];
  else
    return processItem(data);
}

export function getCalculatedValue(strValue: string, decimals: number): number {
  let returnVal: number = null;
  const invalidChars = [
    '%', //considered modulos in eval
    '^'  //considered bitwise xor in eval
  ];
  const hasInvalidChar = invalidChars.some(char => strValue.includes(char));
  if (strValue && !hasInvalidChar) {
    strValue = strValue.replace(/[$, ]/g, '');  //remove $, %, and spaces
    strValue = strValue.replace(/^\(([0-9]*\.?[0-9]*)\)$/, '-$1');  //convert accounting format (123) to -123

    if (isNumber(strValue)) {
      returnVal = round(parseFloat(strValue), decimals);
    } else {
      try {
        returnVal = round(parseFloat(evaluate(strValue)), decimals);
      }
      catch { /* empty */ }
    }
  }

  return returnVal;
}

/**
 * Checks if objects are equal by comparing their properties rather than their references.
 *
 * Useful for comparing interfaces or classes that have the same values
 */
export function isEqual<T>(a: T, b: T): boolean {
  if (typeof a === 'object' && typeof b === 'object') {
    return JSON.stringify(a) === JSON.stringify(b);
  }
  return a === b;
}

export function getOdataUrl(odataFunction: string, state: State, extraParamsQueryStr: string) {
  extraParamsQueryStr = extraParamsQueryStr !== null ? extraParamsQueryStr : '';
  const queryStr = `${toODataString(state, { utcDates: true })}${extraParamsQueryStr}&isExport=false&exportType=none&$count=true`;
  //set the separator based on whether the odataFunction has additional parameters
  const separator: string = odataFunction.includes('?') ? '&' : '?';
  const url = `odata/${odataFunction}${separator}${queryStr}`;
  return url;
}

export function getOdataExportUrl(odataFunction: string, state: State, extraParamsQueryStr: string) {
  extraParamsQueryStr = extraParamsQueryStr !== null ? extraParamsQueryStr : '';
  const newState = { ...state }; //clones state
  newState.skip = null;
  newState.take = null;
  const queryStr = `${toODataString(newState)}${extraParamsQueryStr}&isExport=true&$count=true`;
  //set the separator based on whether the odataFunction has additional parameters
  const separator: string = odataFunction.includes('?') ? '&' : '?';
  const url = `odata/${odataFunction}${separator}${queryStr}`;
  return url;
}

interface ODataResponse<T> {
  value: T[];
  '@odata.count': string | number;
}

export function getOdata(response: unknown, skipDateConversion: boolean = false): GridDataResult {
  const odataResponse = response as ODataResponse<GridDataResult>;
  const data = odataResponse.value;
  const total = parseInt(odataResponse['@odata.count'].toString(), 10);
  if (!skipDateConversion)
    convertToDates(data);
  const newResponse = ({ data, total } as GridDataResult)
  return newResponse;
}

export function openOrSaveFile(fileBlob: Blob, fileNameOriginal: string) {
  const fileName: string = fileBlob.type === "text/plain" ? "Error.txt" : fileNameOriginal;
  if (fileName.split('.').pop() == 'pdf') {
    const fileUrl = URL.createObjectURL(fileBlob);
    window.open("pdfjs/web/viewer.html?file=&fileUrl=" + encodeURIComponent(fileUrl) + "&displayName=" + fileName);
  }
  else
    saveAs(fileBlob, fileName);
}

export function saveFile(fileBlob: Blob, fileNameOriginal: string) {
  const fileName: string = fileBlob.type === "text/plain" ? "Error.txt" : fileNameOriginal;
  saveAs(fileBlob, fileName);
}

export function uid() {
  return crypto.randomUUID();
}

export function hasOutputListener(output: OutputEmitterRef<void>) {
  return computed(() => {
    const outputAsRecord = output as unknown as Record<string, unknown>;
    const listeners = outputAsRecord.listeners;
    const hasAnyListener = Array.isArray(listeners) && listeners.length > 0
    return hasAnyListener;
  });
}

export function isStringArray(value: unknown): value is string[] {
  return (
    Array.isArray(value) &&
    (value.length === 0 || typeof value[0] === "string")
  );
}

export function isNumberArray(value: unknown): value is number[] {
  return (
    Array.isArray(value) &&
    (value.length === 0 || typeof value[0] === "number")
  );
}

export function isObjectArray<T>(value: unknown): value is T[] {
  return (
    Array.isArray(value) &&
    (value.length === 0 || typeof value[0] === "object")
  );
}

// lightweight mobile detection with caching
let isMobile: boolean | undefined;
export function isMobileDevice(): boolean {
  if (isMobile === undefined) {
    const win = document?.defaultView as (Window & { ontouchstart?: unknown }) | null;
    const hasTouchEvent = !!(win && ('ontouchstart' in win));
    const hasMaxTouch = typeof navigator !== 'undefined' && (navigator as Navigator & { maxTouchPoints?: number }).maxTouchPoints > 0;
    isMobile = hasTouchEvent || hasMaxTouch;
  }
  return isMobile;
}

/**
 * Reads a cookie by name from the document's cookies.
 * @param name The name of the cookie to retrieve.
 * @returns The cookie's value, or null if not found.
 */
export function getCookie(name: string): string | null {
  const nameEQ = name + '=';
  const ca = document.cookie.split(';');
  for (let i = 0; i < ca.length; i++) {
    let c = ca[i];
    while (c.charAt(0) === ' ') c = c.substring(1, c.length); // Remove leading spaces
    if (c.indexOf(nameEQ) === 0) {
      return c.substring(nameEQ.length, c.length);
    }
  }
  return null;
}

/**
 * Safely decodes a URL-encoded string (e.g. "%40" -> "@").
 *
 * Returns the original value if it's null/undefined or if decodeURIComponent throws.
 *
 * @param value - The value to decode (may be null or undefined).
 * @returns The decoded string or the original value if decoding fails.
 *
 * @example
 * const decoded = safeDecode('test%40example.com'); // "test@example.com"
 */
export function safeDecode(value: string | null | undefined) {
  if (!value) return value;
  try { return decodeURIComponent(value); }
  catch { return value; }
}
