import {
  AfterViewInit,
  ChangeDetectorRef,
  Component,
  ElementRef,
  OnDestroy,
  OnInit,
  QueryList,
  ViewChild,
  ViewChildren,
  ViewEncapsulation
} from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router'
import Handsontable from 'handsontable'
import { Subject, Subscription } from 'rxjs'
import { sanitiseForSas } from '../shared/utils/sanitise'
import { SasStoreService } from '../services/sas-store.service'

type AOA = any[][]

import { HotTableComponent } from '@handsontable/angular-wrapper'
import { UploadFile } from '@sasjs/adapter'
import { isSpecialMissing } from '@sasjs/utils/input/validators'
import CellRange from 'handsontable/3rdparty/walkontable/src/cell/range'
import { CellValidationSource } from '../models/CellValidationSource'
import { FileUploader } from '../models/FileUploader.class'
import { FilterGroup, FilterQuery } from '../models/FilterQuery'
import { HotTableInterface } from '../models/HotTable.interface'
import { buildExportMenuItem } from '../shared/hot-export/hot-export.util'
import {
  $DataFormats,
  DSMeta,
  EditorsGetDataServiceResponse,
  Version
} from '../models/sas/editors-getdata.model'
import { DataFormat } from '../models/sas/common/DateFormat'
import { Approver, ExcelRule, QueryClause } from '../models/TableData'
import { QueryComponent } from '../query/query.component'
import { EventService } from '../services/event.service'
import { HelperService } from '../services/helper.service'
import { LoggerService } from '../services/logger.service'
import { SasService } from '../services/sas.service'
import { DcValidator } from '../shared/dc-validator/dc-validator'
import { Col } from '../shared/dc-validator/models/col.model'
import { DcValidation } from '../shared/dc-validator/models/dc-validation.model'
import { DQRule } from '../shared/dc-validator/models/dq-rules.model'
import { getHotDataSchema } from '../shared/dc-validator/utils/getHotDataSchema'
import { excelRound } from '../shared/dc-validator/utils/excelRound'
import { isEmpty } from '../shared/dc-validator/utils/isEmpty'
import { globals } from '../_globals'
import { UploadStaterComponent } from './components/upload-stater/upload-stater.component'
import {
  DynamicCellValidation,
  DynamicExtendedCellValidation
} from './models/dynamicExtendedCellValidation'
import { EditRecordInputFocusedEvent } from './models/edit-record/edit-record-events'
import { EditorRestrictions } from './models/editor-restrictions.model'
import { parseTableColumns } from './utils/grid.utils'
import {
  errorRenderer,
  noSpinnerRenderer,
  spinnerRenderer
} from './utils/renderers.utils'
import { LicenceService } from '../services/licence.service'
import { FileUploadEncoding } from '../models/FileUploadEncoding'
import { SpreadsheetService } from '../services/spreadsheet.service'
import { VaMessagingService, VaMessage } from '../services/va-messaging.service'
import { VaFilterService } from '../services/va-filter.service'
import { UploadFileResponse } from '../models/UploadFile'
import { RequestWrapperResponse } from '../models/request-wrapper/RequestWrapperResponse'
import { ParseResult } from '../models/ParseResult.interface'

@Component({
  selector: 'app-editor',
  templateUrl: './editor.component.html',
  styleUrls: ['./editor.component.scss'],
  host: {
    class: 'content-container'
  },
  encapsulation: ViewEncapsulation.None,
  standalone: false
})
export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
  @ViewChildren('uploadStater')
  uploadStaterCompList: QueryList<UploadStaterComponent> = new QueryList()
  @ViewChildren('queryFilter')
  queryFilterCompList: QueryList<QueryComponent> = new QueryList()
  @ViewChild(HotTableComponent, { static: false })
  hotTableComponent!: HotTableComponent
  @ViewChildren('fileUploadInput')
  fileUploadInputCompList: QueryList<ElementRef> = new QueryList()

  public static cnt = 0
  public static nonPkCnt = 0
  public static lastCell = 0
  private _tableSub: Subscription | undefined
  public message = ''
  public $dataFormats: $DataFormats | null = null
  public submit: boolean | undefined
  public cols: Col[] = []

  @ViewChild('ht', { static: true }) ht!: ElementRef

  /** Feature restrictions
   *
   * What can be restricted
   * - Add Row button
   * - Insert rows above/below
   * - Add record button
   * - Edit record button
   *
   * Types of limitations ordered by priority of enforcement (Restrictions upper on the list cannot be un-restricted by lower types)
   * - Restrict edit record feature with config in startupservice (comes from appService)
   * - Restrict `edit record feature` and `add row` if demo and demo limits set as such - since demo is limited to less rows, and buttons which adds rows triggers error
   * - Restrict `add record feature` and `add row` based on configuration of `Column Level Security`
   */
  restrictions: EditorRestrictions = {}

  datasetInfo = false
  dsmeta: DSMeta[] = []
  versions: Version[] = []
  dsNote = ''

  viewboxes = false

  Infinity = Infinity

  public hotInstance!: Handsontable
  public dcValidator: DcValidator | undefined

  public hotTableSettings: Handsontable.GridSettings = {}

  private updateHotTableSettings(): void {
    this.hotTableSettings = {
      colHeaders: this.hotTable.colHeaders,
      columns: this.hotTable.columns,
      height: this.hotTable.height,
      licenseKey: this.hotTable.licenseKey,
      readOnly: this.hotTable.readOnly,
      copyPaste: this.hotTable.copyPaste,
      contextMenu: true,
      className: 'htDark',
      theme: 'ht-theme-classic'
    }
  }

  public hotTable: HotTableInterface = {
    data: [],
    colHeaders: [],
    hidden: true,
    columns: [],
    height: 'calc(100vh - 160px)',
    licenseKey: undefined,
    readOnly: true,
    copyPaste: {
      copyColumnHeaders: true,
      copyColumnHeadersOnly: true
    },
    settings: {
      contextMenu: {
        items: {
          edit_row: {
            name: 'Edit row',
            hidden() {
              const hot: Handsontable.Core = this

              // Hide editing actions in read-only (view) mode.
              if (hot.getSettings().readOnly) return true

              const fullCellRange: CellRange[] | undefined =
                hot.getSelectedRange()

              if (!fullCellRange) return false

              const cellRange = fullCellRange[0]

              return cellRange.from.row !== cellRange.to.row
            },
            callback: (
              key: string,
              selection: any[],
              clickEvent: MouseEvent
            ) => {
              const firstSelection = selection[0]

              if (firstSelection.start.row === firstSelection.end.row) {
                this.editRecord(null, firstSelection.start.row)
              }
            }
          },
          row_above: {
            name: 'Insert Row above',
            hidden: () => this.hotTable.readOnly === true,
            callback: (
              key: string,
              selection: any[],
              clickEvent: MouseEvent
            ) => {
              const firstSelection = selection[0]
              const targetRow = firstSelection.start.row

              this.insertRowAtPosition(targetRow)
            }
          },
          row_below: {
            name: 'Insert Row below',
            hidden: () => this.hotTable.readOnly === true,
            callback: (
              key: string,
              selection: any[],
              clickEvent: MouseEvent
            ) => {
              const firstSelection = selection[0]
              const targetRow = firstSelection.start.row + 1

              this.insertRowAtPosition(targetRow)
            }
          },
          remove_row: {
            name: 'Ignore row',
            hidden: () => this.hotTable.readOnly === true
          },
          copy: {
            name: 'Copy without headers'
          },
          copy_with_column_headers: {
            name: 'Copy with headers'
          },
          copy_column_headers_only: {
            name: 'Copy headers only'
          },
          // Client-side export of the current grid view (or selection).
          // skipLeadingCols=1 drops the `Delete?` housekeeping column (col 0).
          export_file: buildExportMenuItem(() => this.libds || 'export', 1),
          sp1: {
            name: '---------',
            hidden: () => this.hotTable.readOnly === true
          },
          undo: {
            name: 'Undo',
            hidden: () => this.hotTable.readOnly === true
          },
          redo: {
            name: 'Redo',
            hidden: () => this.hotTable.readOnly === true
          }
        }
      }
    }
  }

  public hotCellsPropRow: number | null = null

  public filter = false
  public submitLoading = false
  public uploadLoading = false
  public rowsChanged: any = {
    rowsUpdated: 0,
    rowsDeleted: 0,
    rowsAdded: 0
  }
  public modifedRowsIndexes: number[] = []
  public queryErr = false
  public queryErrMessage: string | undefined
  public successEnable = false
  public libTab: string | undefined
  public queryFilter: any
  public _query: Subscription | undefined

  public whereString: string | undefined
  public clauses: any
  public nullVariables = false

  public tableId: string | undefined
  public pkFields: any = []

  public libds: string | undefined
  public filter_pk: string | undefined
  public table: any
  public filename = ''
  public selectedColumn: any
  public hotSelection: Array<number> | null | undefined
  public submitLimitNotice = false
  public badEdit = false
  public badEditCause: string | undefined
  public badEditTitle: string | undefined
  get embed() {
    return globals.embed
  }
  /** True when running as a SAS Visual Analytics data-driven content object. */
  get isVaEmbed() {
    return globals.embed === 'va'
  }
  /**
   * VA filter mode. Live = auto-apply each VA change; confirm = stage the change
   * and wait for the user to click Apply. Backed by globals (toggled by the
   * Auto-apply checkbox) so it persists across the editor reloads a filter apply
   * triggers. Defaults to 'live'.
   */
  get vaAutoApply(): boolean {
    return globals.vaApplyMode !== 'confirm'
  }
  /**
   * VA filter UI status (shown in both modes):
   *  - 'pending' : a VA change was received and not yet fetched (debouncing in
   *                live mode, or awaiting Apply in confirm mode);
   *  - 'loading' : the filter is being fetched (saveQuery), just before DC's
   *                native "Loading Table" takes over for the data;
   *  - 'idle'    : nothing pending.
   */
  public vaFilterStatus: 'idle' | 'pending' | 'loading' = 'idle'
  /** The staged clauses/signature for the pending VA filter. */
  private vaPendingClauses: QueryClause[] | null = null
  private vaPendingSignature = ''
  /** Removes the VA postMessage listener; set while subscribed. */
  private vaUnsubscribe?: () => void
  /** Guards against overlapping VA filter reloads. */
  private vaApplyingFilter = false
  /** Debounce timer coalescing a burst of VA messages into one apply. */
  private vaDebounceTimer?: ReturnType<typeof setTimeout>
  /**
   * Debounce window (ms) for VA messages. Sized so a burst of rapid control
   * clicks coalesces into a single apply (the last one) BEFORE any filter
   * reload. Note VA itself also throttles how often it posts, so this stacks
   * with that cadence.
   */
  private static readonly VA_DEBOUNCE_MS = 800
  public tableTrue: boolean | undefined
  public saveLoading = false
  public approvers: string[] = []
  public approver: any
  public readOnlyFields!: number
  public errValidation = false
  public dataObj: any
  public disableSubmit: boolean | undefined
  public pkNull = false
  public noPkNull = false
  public tableData: Array<any> = []
  public queryText = ''
  public queryTextSaved = ''
  public showApprovers = false
  public pkDups = false
  public validationDone = 0
  public duplicatePkIndexes: any = []
  public columnHeader: string[] = []
  public specInfo: { col: string; len: number; type: number }[] = []
  public tooLong = false
  public exceedCells: {
    col: string
    len: number
    val: string
  }[] = []
  public uploader: FileUploader = new FileUploader()
  public uploadUrl = ''
  public excelFileReady = false
  public uploadPreview = false
  public excelFileParsing = false
  public excelUploadState: string | null = null
  public data: AOA = []
  public headerArray: string[] = []
  public hotDataSchema: any = {}
  public headerShow: string[] = []
  public headerVisible = false
  public hasBaseDropZoneOver = false
  public hasAnotherDropZoneOver = false
  public headerPks: string[] = []
  public columnLevelSecurityFlag = false
  public dateTimeHeaders: string[] = []
  public timeHeaders: string[] = []
  public dateHeaders: string[] = []

  public xlRules: ExcelRule[] = []
  public encoding: FileUploadEncoding = 'UTF-8'

  // header column names
  headerColumns: Array<any> = []
  cellValidation: DcValidation[] = []
  // hot table data source
  dataSource!: any[]
  prevDataSource!: any[]
  dataSourceUnchanged!: any[]
  dataSourceBeforeSubmit!: any[]
  dataModified!: any[]

  public filePasswordSubject: Subject<string | undefined> = new Subject()
  public fileUnlockError = false

  public filePasswordModal = false
  public showUploadModal = false
  public discardSourceFile = false
  public manualFileEditModal = false

  public recordAction: string | null = null

  public currentEditRecord: any
  public currentEditRecordValidator: DcValidator | undefined
  public currentEditRecordLoadings: number[] = []
  public currentEditRecordErrors: number[] = []
  public currentEditRecordIndex = -1

  public generateEditRecordUrlLoading = false
  public generatedRecordUrl: string | null = null
  public addRecordUrl: string | null = null
  public recordNewOrPkModified = false
  public addRecordLoading = false
  public singleRowSelected = false
  public addingNewRow = false
  public getdataError = false
  public zeroFilterRows = false

  public tableFileDragOver = false

  /**
   * Hash/values table used for dynamic cell validation
   */
  public cellValidationSource: CellValidationSource[] = []
  public validationTableLimit = 100

  // Incremented on cancel/edit-exit so in-flight dynamic-validation
  // responses can detect they should drop their post-response work.
  private validationEpoch = 0
  // Cells currently showing the loading spinner renderer (keyed `r,c`),
  // so cancelBulkValidation can reset them.
  private pendingSpinnerCells = new Set<string>()

  // State for the bulk-validation progress banner (paste / autofill).
  public bulkValidation: {
    active: boolean
    done: number
    total: number
  } = { active: false, done: 0, total: 0 }

  // Confirm-modal state used to gate large paste validations.
  public confirmModal: {
    open: boolean
    title: string
    message: string
  } = { open: false, title: '', message: '' }
  private confirmModalResolver: ((v: boolean) => void) | null = null

  public disabledBasicDynamicCellValidationMap: {
    row: number
    col: number
    active: boolean
  }[] = []

  public licenceState = this.licenceService.licenceState

  private ariaObserver: MutationObserver | undefined
  private ariaCheckInterval: any | undefined

  constructor(
    private licenceService: LicenceService,
    private eventService: EventService,
    private loggerService: LoggerService,
    private sasStoreService: SasStoreService,
    private helperService: HelperService,
    private router: Router,
    private route: ActivatedRoute,
    private sasService: SasService,
    private cdf: ChangeDetectorRef,
    private spreadsheetService: SpreadsheetService,
    private vaMessaging: VaMessagingService,
    private vaFilter: VaFilterService
  ) {
    this.parseRestrictions()
    this.setRestrictions()
  }

  /**
   * Prepare feature restrictions based on licence key
   */
  private parseRestrictions() {
    this.restrictions.restrictAddRecord =
      this.licenceState.value.addRecord === false
    this.restrictions.restrictEditRecord =
      this.licenceState.value.editRecord === false
    this.restrictions.restrictFileUpload =
      this.licenceState.value.fileUpload === false
  }

  /**
   * Applying prepared restrictions
   * @param overrideRestrictions can be used to apply and override specific restrictions
   */
  private setRestrictions(overrideRestrictions?: EditorRestrictions) {
    if (overrideRestrictions) {
      this.restrictions = {
        ...this.restrictions,
        ...overrideRestrictions
      }
    }

    if (this.restrictions.removeEditRecordButton) {
      delete (this.hotTable?.settings?.contextMenu as any).items.edit_row
    }

    if (this.restrictions.restrictAddRow) {
      delete (this.hotTable?.settings?.contextMenu as any).items.row_above
      delete (this.hotTable?.settings?.contextMenu as any).items.row_below
      delete (this.hotTable?.settings?.contextMenu as any).items.remove_row
    }
  }

  /**
   * Disabling add row button based on wether rows limit is present
   */
  private checkRowLimit() {
    if (this.columnLevelSecurityFlag) return

    if (this.licenceState.value.editor_rows_allowed !== Infinity) {
      if (
        this.dataSource?.length >= this.licenceState.value.editor_rows_allowed
      ) {
        this.restrictions.restrictAddRow = true
      } else {
        this.restrictions.restrictAddRow = false
      }
    }
  }

  /**
   * Resetting filter variables
   */
  public resetFilter() {
    if (this.queryFilterCompList.first) {
      this.queryFilterCompList.first.resetFilter()
    }
  }

  /**
   * Openning file upload modal
   * If feature is locked, `feature locked` modal will be shown
   */
  public onShowUploadModal() {
    if (this.restrictions.restrictFileUpload) {
      this.eventService.showDemoLimitModal('File Upload')
      return
    }

    if (this.columnLevelSecurityFlag) {
      this.eventService.showInfoModal(
        'Information',
        'Upload feature is disabled while Column Level Security rules are active'
      )
      return
    }

    if (!this.uploadPreview) this.showUploadModal = true
  }

  /**
   * Called by FileDropDirective
   * @param e true if file is dragged over the drop zone
   */
  public fileOverBase(e: boolean): void {
    this.hasBaseDropZoneOver = e
  }

  public attachFile(event: any, dropped = false) {
    const file: File = dropped ? event[0] : event.target.files[0]

    this.excelUploadState = 'Loading'
    this.excelFileParsing = true
    this.excelFileReady = false

    this.filename = file.name

    this.spreadsheetService
      .parseExcelFile(
        {
          file: file,
          uploader: this.uploader,
          dcValidator: this.dcValidator!,
          headerPks: this.headerPks,
          headerArray: this.headerArray,
          headerShow: this.headerShow,
          timeHeaders: this.timeHeaders,
          dateHeaders: this.dateHeaders,
          dateTimeHeaders: this.dateTimeHeaders,
          xlRules: this.xlRules,
          encoding: this.encoding
        },
        (uploadState: string) => {
          this.appendUploadState(uploadState)
        },
        (tableFoundInfo: string) => {
          this.eventService.showInfoModal('Table Found', tableFoundInfo)
        }
      )
      .then(async (parseResult: ParseResult | undefined) => {
        if (parseResult) {
          this.excelFileReady = true

          this.uploader = parseResult.uploader

          if (parseResult.data && parseResult.headerShow) {
            // If data is returned it means we parsed excel file
            this.data = parseResult.data
            this.headerShow = parseResult.headerShow
            this.getPendingExcelPreview()
          } else {
            // otherwise it's csv file, and we send them directly
            await this.uploadParsedFiles()
          }
        }
      })
      .catch((error: string) => {
        this.eventService.showAbortModal(null, error, null)

        this.showUploadModal = false
        this.uploadPreview = false

        setTimeout(() => {
          this.filename = ''
        })
      })
      .finally(() => {
        this.excelFileParsing = false
      })
  }

  /**
   * Submits attached excel file that is in preview mode
   */
  public submitExcel() {
    if (this.licenceState.value.submit_rows_limit !== Infinity) {
      this.submitLimitNotice = true
      return
    }

    this.uploadParsedFiles()
  }

  /**
   * This method will run validations and upload all of the pending files
   * that are in the uploader queue.
   */
  public async uploadParsedFiles() {
    if (this.checkInvalid()) {
      this.eventService.showAbortModal(null, 'Invalid values are present.')
      return
    }

    this.validatePrimaryKeys()

    if (this.duplicatePkIndexes.length !== 0) {
      this.pkDups = true
      this.submit = false
      return
    } else {
      this.pkDups = false
    }

    this.uploadLoading = true
    const filesToUpload: UploadFile[] = []

    for (const file of this.uploader.queue) {
      filesToUpload.push({
        file: file,
        fileName: file.name
      })
    }

    await this.sasService
      .uploadFile(this.uploadUrl, filesToUpload, { table: this.libds })
      .then(
        (res: UploadFileResponse) => {
          if (typeof res.adapterResponse.sasjsAbort === 'undefined') {
            if (typeof res.adapterResponse.sasparams === 'undefined') {
              return
            } else {
              this.uploadLoading = false
              const params = res.adapterResponse.sasparams[0]
              this.successEnable = true
              this.tableId = params.DSID
              this.router.navigateByUrl('/stage/' + this.tableId)
            }
          } else {
            // handle succesfull response
            const abortRes = res.adapterResponse
            const abortMsg = abortRes.sasjsAbort[0].MSG
            const macMsg = abortRes.sasjsAbort[0].MAC

            this.uploadLoading = false
            this.filename = ''
            if (this.fileUploadInputCompList.first) {
              //clear the attached file to input
              this.fileUploadInputCompList.first.nativeElement.value = ''
            }
            this.uploader.queue = []
            this.eventService.showAbortModal('', abortMsg, {
              SYSWARNINGTEXT: abortRes.SYSWARNINGTEXT,
              SYSERRORTEXT: abortRes.SYSERRORTEXT,
              MAC: macMsg
            })
          }
        },
        (err: any) => {
          this.uploadLoading = false
          if (this.fileUploadInputCompList.first) {
            //clear the attached file to input
            this.fileUploadInputCompList.first.nativeElement.value = ''
          }
          this.uploader.queue = []
          this.eventService.catchResponseError(
            'file upload',
            err.adapterResponse
          )
        }
      )
  }

  /**
   * After excel file is attached and parsed, this function will display it's content in the HOT table in read only mode
   */
  public getPendingExcelPreview() {
    this.queryTextSaved = this.queryText
    this.queryText = ''

    this.excelUploadState = 'Parsing'

    this.toggleHotPlugin('contextMenu', false)

    const previewDatasource: any[] = []

    this.data.map((item) => {
      const itemObject: any = {}

      this.headerShow.map((header: any, index: number) => {
        itemObject[header] = item[index]
      })

      // If Delete? column is not set in the file, we set it to NO
      if (!itemObject['_____DELETE__THIS__RECORD_____'])
        itemObject['_____DELETE__THIS__RECORD_____'] = 'No'

      previewDatasource.push(itemObject)
    })

    this.dataSourceUnchanged = this.helperService.deepClone(this.dataSource)

    this.dataSource = previewDatasource
    this.hotTable.data = previewDatasource

    const hot = this.hotInstance

    this.excelUploadState = 'Validating-HOT'

    hot.updateSettings(
      {
        data: this.dataSource,
        maxRows: Infinity
      },
      false
    )
    hot.render()

    this.appendUploadState(`Validating rows`)

    hot.validateCells(() => {
      this.showUploadModal = false
      this.uploadPreview = true

      this.excelFileParsing = false
      this.excelUploadState = null
    })
  }

  /**
   * Drops the attached excel file
   * @param discardData wheter to discard data parsed from the file or to keep it in the table after dropping a attached excel file
   */
  public discardPendingExcel(discardData?: boolean) {
    this.hotInstance.updateSettings({
      maxRows: this.licenceState.value.editor_rows_allowed
    })

    if (discardData) this.cancelEdit()

    if (this.fileUploadInputCompList.first) {
      this.fileUploadInputCompList.first.nativeElement.value = ''
    }

    this.uploadPreview = false
    this.excelFileReady = false
    this.uploader.queue = []

    if (!isNaN(parseInt(this.router.url.split('/').pop() || ''))) {
      if (this.queryTextSaved.length > 0) {
        this.queryText = this.queryTextSaved
        this.queryTextSaved = ''
      }
    }
  }

  /**
   * Drops attached excel file, keeps it's data in the DC table
   * User can now edit the table and submit. Witout the file present.
   */
  public previewTableEditConfirm() {
    this.discardPendingExcel()
    this.convertToCorrectTypes(this.dataSource)
    this.editTable(true)
  }

  private appendUploadState(state: string, replaceLast = false) {
    this.cdf.detectChanges()

    if (this.uploadStaterCompList.first) {
      if (replaceLast) {
        this.uploadStaterCompList.first.replaceLastState(state)
      } else {
        this.uploadStaterCompList.first.appendState(state)
      }
    }
  }

  isColPk(col: string) {
    return this.headerPks.indexOf(col) > -1
  }

  isReadonlyCol(col: string | number) {
    const colRules = this.dcValidator?.getRule(col)

    return colRules?.readOnly
  }

  isColHeader(col: string) {
    return this.headerArray.indexOf(col.toUpperCase()) > -1
  }

  removeQuery() {
    this.sasStoreService.removeClause()
  }

  async sendClause() {
    this.submitLoading = true
    let nullVariableArr = []
    const emptyVariablesArr = []

    // to check number of empty clauses
    if (typeof this.clauses === 'undefined') {
      this.nullVariables = true
      this.submitLoading = false
      return
    } else {
      const query = this.clauses.queryObj

      if (query[0].elements.length < 1) {
        // Clear cached filtering data
        if (globals.rootParam === 'home' || globals.rootParam === 'editor') {
          globals.editor.filter.clauses = []
          globals.editor.filter.query = []
          globals.editor.filter.groupLogic = ''
        }

        // Reset filtering
        this.router.navigate(['/editor/' + this.libds], {
          queryParamsHandling: 'preserve'
        })

        return
      }

      for (let index = 0; index < query.length; index++) {
        const el = query[index].elements
        nullVariableArr = el.filter(function (item: any) {
          return item.variable === null
        })
        if (nullVariableArr.length) {
          emptyVariablesArr.push(el)
        }
      }
    }

    if (emptyVariablesArr.length) {
      this.nullVariables = true
      this.submitLoading = false
      return
    } else {
      try {
        if (this.clauses !== undefined && this.libds) {
          const filterQuery: FilterQuery = {
            groupLogic: this.clauses.groupLogic,
            filterGroups: []
          }
          this.clauses.queryObj.forEach((group: any) => {
            const filterGroup: FilterGroup = {
              filterClauses: []
            }
            group.elements.forEach((clause: any) => {
              filterGroup.filterClauses.push(
                this.helperService.deepClone(clause)
              )
            })
            filterGroup.clauseLogic = group.clauseLogic
            filterQuery.filterGroups.push(
              this.helperService.deepClone(filterGroup)
            )
          })

          const filterQueryClauseTable =
            this.sasStoreService.createFilterQueryTable(filterQuery)

          await this.sasStoreService
            .saveQuery(this.libds, filterQueryClauseTable)
            .then((res: any) => {
              const id = res.result[0].FILTER_RK
              const table = res.result[0].FILTER_TABLE

              this.queryFilter = { id: id, table: table }

              this.router
                .navigate(['/'], {
                  skipLocationChange: true,
                  queryParamsHandling: 'preserve'
                })
                .then(() =>
                  this.router.navigate(
                    [
                      '/editor/' +
                        this.queryFilter.table +
                        '/' +
                        this.queryFilter.id
                    ],
                    {
                      queryParamsHandling: 'preserve'
                    }
                  )
                )

              this.filter = false
            })
            .catch((err: any) => {
              this.submitLoading = false
            })
        }
      } catch (error: any) {
        this.queryErr = true
        this.submitLoading = false
        this.queryErrMessage = error
      }
    }
  }

  public openQb() {
    if (this.libds) {
      // this.libTab = this.libds;
      this.filter = true
      this.cdf.detectChanges()

      this.submitLoading = false
      this.sasStoreService.setQueryVariables(this.libds, this.cols)
    }
  }

  /**
   * Reads the current multi-column sort config defensively. The sorting plugin
   * may not be initialised yet (e.g. editTable() invoked right after load in VA
   * embed mode, before the grid finished setup), in which case getSortConfig()
   * throws on null internal state. There's no sort to preserve then, so [].
   */
  private getCurrentSortConfigs(): any[] {
    const hot = this.hotInstance
    if (!hot) return []
    try {
      const plugin = hot.getPlugin('multiColumnSorting')
      const cfg = plugin?.getSortConfig()
      return Array.isArray(cfg) ? cfg : cfg ? [cfg] : []
    } catch {
      return []
    }
  }

  editTable(previewEdit?: boolean, newRow?: boolean) {
    this.toggleHotPlugin('contextMenu', true)

    const hot = this.hotInstance
    if (!hot) return

    const columnSorting = hot.getPlugin('multiColumnSorting')
    const sortConfigs = this.getCurrentSortConfigs()

    setTimeout(() => {
      if (!previewEdit) {
        this.dataSourceUnchanged = this.helperService.deepClone(this.dataSource)
        if (newRow) {
          this.dataSourceUnchanged.pop()
        }
      }
      this.hotTable.readOnly = false
      this.hotTable.data = this.dataSource

      hot.updateSettings(
        {
          readOnly: this.hotTable.readOnly
        },
        false
      )

      hot.render()

      for (const sortConfig of sortConfigs) {
        columnSorting.sort(sortConfig)
      }

      this.reSetCellValidationValues()

      // Fix ARIA accessibility issues after table edit
      setTimeout(() => {
        this.fixAriaAccessibility()
      }, 100)
    }, 0)
  }

  convertToCorrectTypes(dataSource: any) {
    for (const row of dataSource) {
      for (const colKey in row) {
        const colSpecs = this.cols.find((x: any) => x.NAME === colKey)

        if (colSpecs) {
          if (
            row[colKey] !== '' &&
            colSpecs.TYPE === 'num' &&
            !colSpecs.DDTYPE.includes('TIME') &&
            !colSpecs.DDTYPE.includes('DATE')
          )
            row[colKey] = parseInt(row[colKey])
        }
      }
    }
  }

  cancelEdit() {
    this.cancelBulkValidation({ revert: false })

    // Keep the context menu enabled after leaving edit mode (view mode keeps
    // Copy/Export; editing items hide themselves when read-only).
    this.toggleHotPlugin('contextMenu', true)

    this.cellValidationSource = []

    // Clear custom validation styling
    this.clearDuplicateValidation()

    const hot = this.hotInstance
    const columnSorting = hot.getPlugin('multiColumnSorting')
    const columnSortConfig = columnSorting.getSortConfig()
    const sortConfigs = Array.isArray(columnSortConfig)
      ? columnSortConfig
      : [columnSortConfig]

    if (this.dataSourceUnchanged) {
      this.dataSource = this.helperService.deepClone(this.dataSourceUnchanged)
    }

    this.hotTable.data = this.dataSource
    this.hotTable.readOnly = true

    hot.updateSettings(
      {
        readOnly: this.hotTable.readOnly,
        data: this.dataSource
      },
      false
    )

    this.modifedRowsIndexes = []
    hot.validateCells()
    // this.editRecordListeners();
    for (const sortConfig of sortConfigs) {
      columnSorting.sort(sortConfig)
    }

    this.checkRowLimit()
  }

  /**
   * Stop the bulk-validation flow (paste / autofill): invalidate in-flight
   * responses via the epoch counter, reset spinner cells, hide the banner,
   * and (when triggered from the banner's Cancel button) undo the change.
   */
  public cancelBulkValidation(opts: { revert?: boolean } = {}) {
    const wasActive = this.bulkValidation.active

    // Invalidate any in-flight dynamicCellValidation responses. Note:
    // sasService.request has no abort signal, so the network request itself
    // keeps running — we only drop the response handling.
    this.validationEpoch++

    // Reset any cells still showing the loading spinner renderer.
    const hot = this.hotInstance
    if (hot && this.pendingSpinnerCells.size > 0) {
      for (const key of this.pendingSpinnerCells) {
        const [rStr, cStr] = key.split(',')
        const r = Number(rStr)
        const c = Number(cStr)
        hot.setCellMeta(r, c, 'renderer', noSpinnerRenderer)
      }
      this.pendingSpinnerCells.clear()
    }

    // Drop placeholder entries (values still empty — request was cancelled).
    this.cellValidationSource = this.cellValidationSource.filter(
      (entry) => !entry.pending || entry.values.length > 0
    )

    this.bulkValidation = {
      active: false,
      done: 0,
      total: 0
    }

    if (wasActive && opts.revert && hot) {
      this.undoLastChange(hot)
    }
    if (hot) hot.render()
  }

  private undoLastChange(hot: Handsontable): void {
    const plugin = hot.getPlugin('undoRedo') as unknown as
      | { isUndoAvailable(): boolean; undo(): void }
      | undefined
    if (plugin?.isUndoAvailable()) plugin.undo()
  }

  /**
   * Drive dynamic-source load + validation for cells that were bulk-filled
   * (paste or autofill). HARDSELECT_HOOK / SOFTSELECT_HOOK columns need a SAS
   * roundtrip; non-hook cells just need HOT's static validators. Caps backend
   * concurrency, uses an epoch so a cancelled run can't mutate later state,
   * and gates >3-cell runs behind a confirm modal.
   */
  private async runBulkValidation(
    hot: Handsontable,
    ranges: Array<{
      startRow: number
      startCol: number
      endRow: number
      endCol: number
    }>,
    source: 'paste' | 'autofill'
  ): Promise<void> {
    const rows = new Set<number>()
    const hookTargets: Array<{ r: number; c: number }> = []
    const hookCols = new Set<string>()
    for (const range of ranges) {
      for (let r = range.startRow; r <= range.endRow; r++) {
        for (let c = range.startCol; c <= range.endCol; c++) {
          rows.add(r)
          const colKey = hot.colToProp(c) as string
          if (this.dcValidator?.hasDqRules(colKey, ['HARDSELECT_HOOK'])) {
            hookCols.add(colKey)
            hookTargets.push({ r, c })
          }
        }
      }
    }

    // No hook columns → HOT's own setDataAtCell → validateChanges pass
    // (triggered by populateFromArray) already validates against the new
    // value and paints htInvalid. A second validateRows here would race:
    // it runs sync inside afterPaste/afterAutofill BEFORE applyChanges
    // writes the data, captures the stale old value, and overwrites
    // cellProperties.valid back to true — causing a 1-action lag.
    if (hookTargets.length === 0) return

    if (hookTargets.length === 1) {
      const { r, c } = hookTargets[0]
      await this.dynamicCellValidation(r, c)
      hot.validateRows([...rows], () => hot.render())
      return
    }

    if (hookTargets.length > 3) {
      const colsList = [...hookCols].join(', ')
      const ok = await this.showConfirmModal(
        `Confirm ${source} validation`,
        `You are about to trigger ${hookTargets.length} backend SAS request(s) for columns: ${colsList}. Do you wish to proceed?`
      )
      if (!ok) {
        this.undoLastChange(hot)
        return
      }
    }

    const epoch = this.validationEpoch
    this.bulkValidation = {
      active: true,
      done: 0,
      total: hookTargets.length
    }

    const CONCURRENCY = 2
    let idx = 0
    await Promise.all(
      Array.from({ length: CONCURRENCY }, async () => {
        while (idx < hookTargets.length) {
          if (epoch !== this.validationEpoch) return
          const { r, c } = hookTargets[idx++]
          await this.dynamicCellValidation(r, c, { skipRender: true })
          if (epoch === this.validationEpoch) {
            this.bulkValidation.done++
          }
        }
      })
    )

    if (epoch !== this.validationEpoch) return

    this.bulkValidation = {
      ...this.bulkValidation,
      active: false
    }
    hot.validateRows([...rows], () => hot.render())
  }

  private showConfirmModal(title: string, message: string): Promise<boolean> {
    this.confirmModal = { open: true, title, message }
    return new Promise<boolean>((resolve) => {
      this.confirmModalResolver = resolve
    })
  }

  public onConfirmModalResult(value: boolean) {
    const resolver = this.confirmModalResolver
    this.confirmModalResolver = null
    this.confirmModal = { ...this.confirmModal, open: false }
    if (resolver) resolver(value)
  }

  timesClicked = 0
  public hotClicked() {
    if (this.timesClicked === 1 && this.hotTable.readOnly) {
      this.editTable()
    }

    if (this.timesClicked === 0) {
      this.timesClicked++

      setTimeout(() => {
        this.timesClicked = 0
      }, 200)
    }
  }

  public cleanExceed() {
    this.exceedCells = []
  }

  public approversToggle() {
    this.showApprovers = !this.showApprovers
  }

  public addRow() {
    this.addingNewRow = true

    setTimeout(() => {
      const hot = this.hotInstance

      // Create a new empty row object with proper structure
      const newRow = this.createEmptyRow()

      // Add the new row to the data source
      this.dataSource.push(newRow)

      // Update the hot table with the new data
      hot.updateSettings({ data: this.dataSource }, false)

      // Select the newly added row
      hot.selectCell(this.dataSource.length - 1, 0)
      hot.render()

      this.addingNewRow = false

      this.reSetCellValidationValues()
    })
  }

  /**
   * Creates a new empty row object with proper structure.
   * Columns with NOTNULL DQ rules are pre-populated with their RULE_VALUE.
   */
  private createEmptyRow(): any {
    const newRow: any = {}
    this.cellValidation.forEach((rule: any) => {
      const dataKey = rule.data
      newRow[dataKey] = this.hotDataSchema.hasOwnProperty(dataKey)
        ? this.hotDataSchema[dataKey]
        : ''
    })
    newRow['noLinkOption'] = true
    return newRow
  }

  /**
   * Inserts a new row at the specified position and updates the table
   */
  private insertRowAtPosition(targetRow: number): void {
    const newRow = this.createEmptyRow()

    // Insert the new row at the target position
    this.dataSource.splice(targetRow, 0, newRow)

    // Update the hot table
    const hot = this.hotInstance
    hot.updateSettings({ data: this.dataSource }, false)
    hot.render()

    this.reSetCellValidationValues()
  }

  public cancelSubmit() {
    this.dataSource = this.helperService.deepClone(this.dataSourceBeforeSubmit)
    this.dataSourceBeforeSubmit = []
    this.hotTable.data = this.dataSource

    const hot = this.hotInstance
    hot.updateSettings(
      {
        data: this.dataSource,
        colHeaders: this.headerColumns,
        columns: this.cellValidation,
        modifyColWidth: function (width: number, col: number) {
          if (col === 0) {
            return 60
          }
          if (width > 500) return 500
          else return width
        }
      },
      false
    )

    hot.selectCell(0, 0)
    hot.render()
    hot.validateRows(this.modifedRowsIndexes)

    this.reSetCellValidationValues()
  }

  public getRowsSubmittingCount() {
    if (this.sasService.getSasjsConfig().debug) {
      this.loggerService.log(this.dataSource)
      this.loggerService.log(this.dataSourceUnchanged)
    }

    let rowsUpdated = 0
    let rowsDeleted = 0
    let rowsAdded = 0
    this.modifedRowsIndexes = []
    this.dataModified = []

    for (let i = 0; i < this.dataSource.length; i++) {
      const dataRow = this.helperService.deepClone(this.dataSource[i])

      if (dataRow._____DELETE__THIS__RECORD_____ === 'Yes') {
        this.dataModified.push(dataRow)
        rowsDeleted++
      } else {
        const dataRowUnchanged = this.dataSourceUnchanged.find((row: any) => {
          for (const pkCol of this.headerPks) {
            if (row[pkCol] !== dataRow[pkCol]) {
              return false
            }
          }

          return true
        })

        if (dataRowUnchanged) {
          if (JSON.stringify(dataRow) !== JSON.stringify(dataRowUnchanged)) {
            this.dataModified.push(dataRow)
            this.modifedRowsIndexes.push(i)
            rowsUpdated++
          }
        } else {
          this.dataModified.push(dataRow)
          this.modifedRowsIndexes.push(i)
          rowsAdded++
        }
      }
    }

    this.rowsChanged = {
      rowsUpdated,
      rowsDeleted,
      rowsAdded
    }
  }

  private clearDuplicateValidation() {
    const hot = this.hotInstance

    // Clear previous duplicate validation styling
    for (const rowIndex of this.duplicatePkIndexes) {
      for (let col = 1; col <= this.readOnlyFields; col++) {
        hot.removeCellMeta(rowIndex, col, 'valid')
        hot.removeCellMeta(rowIndex, col, 'dupKey')
        // Remove our custom class from cell metadata
        const cellMeta = hot.getCellMeta(rowIndex, col)
        if (cellMeta.className) {
          let cleanedClassName: string
          if (Array.isArray(cellMeta.className)) {
            cleanedClassName = cellMeta.className
              .filter((c) => c !== 'dc-invalid-cell')
              .join(' ')
          } else {
            cleanedClassName = cellMeta.className
              .replace('dc-invalid-cell', '')
              .trim()
          }
          hot.setCellMeta(rowIndex, col, 'className', cleanedClassName)
        }
      }
    }

    this.duplicatePkIndexes = []
    hot.render()
  }

  public validatePrimaryKeys() {
    const hot = this.hotInstance

    // Clear previous validation before applying new ones
    this.clearDuplicateValidation()

    // Get data from the data source instead of hot.getData() to ensure consistency
    const myTable = this.dataSource
    this.pkFields = []

    for (let index = 0; index < myTable.length; index++) {
      let pkRow = ''
      for (let ind = 1; ind < this.readOnlyFields + 1; ind++) {
        const colName = this.headerColumns[ind]
        const value = myTable[index][colName] || ''
        pkRow = pkRow + '|' + value
      }
      this.pkFields.push(pkRow)
    }

    const results: any = []
    const rows = this.dataSource.length

    // Only check for duplicates if we have data
    if (this.pkFields.length > 0) {
      for (let j = 0; j < this.pkFields.length; j++) {
        for (let i = 0; i < this.pkFields.length; i++) {
          if (
            this.pkFields[j] === this.pkFields[i] &&
            i !== j &&
            this.pkFields[j] !== '|'
          ) {
            results.push(i)
          }
        }
      }
    }

    // Clear any existing validation marks for all cells
    for (let row = 0; row < myTable.length; row++) {
      for (let col = 0; col < this.headerColumns.length; col++) {
        const cellMeta = hot.getCellMeta(row, col)
        if (cellMeta) {
          cellMeta.valid = true
          cellMeta.dupKey = false
        }
      }
    }

    // Mark duplicate cells as invalid
    for (let k = 0; k < results.length; k++) {
      for (let index = 1; index < this.readOnlyFields + 1; index++) {
        hot.setCellMeta(results[k], index, 'valid', false)
        hot.setCellMeta(results[k], index, 'dupKey', true)
        hot.setCellMeta(results[k], index, 'className', 'dc-invalid-cell')
      }
    }

    this.duplicatePkIndexes = [...new Set(results.sort())]
    hot.render()
  }

  /**
   * After any change or update to the hot datasource we lose cell validation values.
   * This function is called in those places, to update all cells with values if existing.
   * Note that was discussed:
   * Rows with same data does not have arrows until you click on them (arrows gets lost after addRow)
   * That is because this function resets the values for the found hashes, and if multiple rows have same data,
   * hash table contains only first row that is hashed, so others don't get arrow re-set
   *
   * @param specificRowForceValue re-set will apply force values only to this row. That is used in cases
   * when we don't want to re-set force values of every row in the table
   */
  public reSetCellValidationValues(
    setForcedValues = false,
    specificRowForceValue?: number
  ) {
    const hot = this.hotInstance

    for (const entry of this.cellValidationSource) {
      const colSource = entry.values.map(
        (el: DynamicCellValidation) => el.RAW_VALUE
      )

      hot.batch(() => {
        const cellMeta = hot.getCellMeta(entry.row, entry.col)
        const cellRule = this.dcValidator?.getRule(
          (cellMeta.data as string) || ''
        )
        let cellSource: string[] | number[] | undefined

        if (cellRule) {
          cellSource = this.dcValidator?.getDqDropdownSource(cellRule)
        }

        if (!cellSource) cellSource = []

        const combinedSource = [
          ...new Set([...cellSource, ...colSource])
        ] as string[]

        this.currentEditRecordValidator?.updateRule(entry.col, {
          source: combinedSource
        })

        hot.setCellMeta(entry.row, entry.col, 'source', combinedSource)

        if (entry.values.length > 0) {
          hot.setCellMeta(entry.row, entry.col, 'renderer', 'autocomplete')
          hot.setCellMeta(entry.row, entry.col, 'editor', 'autocomplete.custom')
          hot.setCellMeta(entry.row, entry.col, 'strict', entry.strict)
          hot.setCellMeta(entry.row, entry.col, 'filter', false)

          this.currentEditRecordValidator?.updateRule(entry.col, {
            renderer: 'autocomplete',
            editor: 'autocomplete.custom',
            strict: entry.strict,
            filter: false
          })
        }

        this.reSetExtendedCellValidationValues(
          entry,
          undefined,
          setForcedValues,
          specificRowForceValue
        )

        hot.render()
      })
    }
  }

  public reSetExtendedCellValidationValues(
    cellValidationEntry?: CellValidationSource,
    row?: number,
    setForcedValues = false,
    specificRowForceValue?: number
  ) {
    const hot = this.hotInstance

    if (cellValidationEntry) {
      if (!row) row = cellValidationEntry.row

      const extendedValuesObject =
        this.getExtendedValuesByCellValue(cellValidationEntry)

      this.setExtendedValuesToCells(
        cellValidationEntry,
        row,
        extendedValuesObject,
        setForcedValues,
        specificRowForceValue
      )

      return
    }

    for (const entry of this.cellValidationSource) {
      const extendedValuesObject = this.getExtendedValuesByCellValue(entry)

      this.setExtendedValuesToCells(
        entry,
        entry.row,
        extendedValuesObject,
        setForcedValues,
        specificRowForceValue
      )
    }
  }

  private setExtendedValuesToCells(
    cellValidationEntry: CellValidationSource,
    row: number,
    extendedValues: DynamicExtendedCellValidation[],
    setForcedValues = false,
    specificRowForceValue?: number
  ) {
    const hot = this.hotInstance

    const uniqueCells: any[] = []

    for (const element of extendedValues) {
      if (uniqueCells.indexOf(element.EXTRA_COL_NAME) < 0)
        uniqueCells.push(element.EXTRA_COL_NAME)
    }

    for (const cell of uniqueCells) {
      const valuesForCol = extendedValues.filter(
        (x) => x.EXTRA_COL_NAME === cell
      )
      let colSource: any = valuesForCol.map(
        (el: DynamicExtendedCellValidation) =>
          el.DISPLAY_TYPE === 'C' ? el.RAW_VALUE_CHAR : el.RAW_VALUE_NUM
      )
      const cellCol = hot.propToCol(cell) as number
      const dynamicValidationEl =
        this.disabledBasicDynamicCellValidationMap.find(
          (x) => x.row === row && x.col === cellCol
        )

      if (!dynamicValidationEl) {
        this.disabledBasicDynamicCellValidationMap.push({
          row,
          col: cellCol,
          active: false
        })
      }

      hot.setCellMeta(row, cellCol, 'renderer', 'autocomplete')
      hot.setCellMeta(row, cellCol, 'editor', 'autocomplete.custom')
      hot.setCellMeta(row, cellCol, 'strict', cellValidationEntry.strict)
      hot.setCellMeta(row, cellCol, 'filter', false)

      this.currentEditRecordValidator?.updateRule(cellCol, {
        renderer: 'autocomplete',
        editor: 'autocomplete.custom',
        strict: cellValidationEntry.strict,
        filter: false
      })

      const cellMeta = hot.getCellMeta(row, cellCol)

      const cellRule = this.dcValidator?.getRule(
        (cellMeta.data as string) || ''
      )
      let cellSource: string[] | number[] | undefined

      if (cellRule) {
        cellSource = this.dcValidator?.getDqDropdownSource(cellRule)
      }

      if (!cellSource) cellSource = []

      if (cellRule?.type === 'numeric') {
        cellSource = this.helperService.convertArrayValues(
          cellSource,
          'number'
        ) as number[]
        colSource = this.helperService.convertArrayValues(
          colSource,
          'number'
        ) as number[]
      } else {
        cellSource = this.helperService.convertArrayValues(
          cellSource,
          'string'
        ) as string[]
        colSource = this.helperService.convertArrayValues(
          colSource,
          'string'
        ) as string[]
      }

      const combinedSource = [...new Set([...cellSource, ...colSource])]

      hot.setCellMeta(row, cellCol, 'source', combinedSource)

      this.currentEditRecordValidator?.updateRule(cellCol, {
        source: combinedSource
      })

      if (setForcedValues) {
        if (specificRowForceValue && specificRowForceValue !== row) {
          return
        }

        const forceValue = valuesForCol.find(
          (x: DynamicExtendedCellValidation) => x.FORCED_VALUE === 1
        )

        if (forceValue) {
          // Adding timeout here makes forced values cell to re-validate itself
          setTimeout(() => {
            hot.setDataAtCell(
              row,
              cellCol,
              forceValue.DISPLAY_TYPE === 'C'
                ? forceValue.RAW_VALUE_CHAR
                : forceValue.RAW_VALUE_NUM,
              'force_cell_validation_value'
            )

            if (this.currentEditRecordIndex === row) {
              this.dataSource[this.currentEditRecordIndex][cell] =
                forceValue.DISPLAY_TYPE === 'C'
                  ? forceValue.RAW_VALUE_CHAR
                  : forceValue.RAW_VALUE_NUM
            }
          })
        }
      }
    }
  }

  /**
   * Parses values of extended cell validation for the given dynamic cell validation entry
   * @param cellValidationEntry stored dynamic cell validation entry from which to parse extended validation values
   * @param rowOverride if not provided, row that is used is row found in `cellValidationEntry`. This is needed when for example we change the w in hot
   * we need to get `cellValue` from that row and not from hashed cell validation source.
   * @returns extended values object
   */
  private getExtendedValuesByCellValue(
    cellValidationEntry: CellValidationSource,
    rowOverride?: number
  ): DynamicExtendedCellValidation[] {
    const hot = this.hotInstance

    const cellValue = hot.getDataAtCell(
      rowOverride ? rowOverride : cellValidationEntry.row,
      cellValidationEntry.col
    )
    const valueIndex = (
      cellValidationEntry.values.find((x) => x.RAW_VALUE === cellValue) || {}
    ).DISPLAY_INDEX

    return (
      cellValidationEntry.extended_values?.filter(
        (x) => x.DISPLAY_INDEX === valueIndex
      ) || []
    )
  }

  public checkSave() {
    this.getRowsSubmittingCount()

    if (
      this.rowsChanged.rowsAdded === 0 &&
      this.rowsChanged.rowsUpdated === 0 &&
      this.rowsChanged.rowsDeleted === 0
    ) {
      this.badEditTitle = 'No changes to submit'
      this.badEditCause = 'Please modify some values and try again.'
      this.badEdit = true
      return
    }

    const hot = this.hotInstance

    this.dataSourceBeforeSubmit = this.helperService.deepClone(this.dataSource)

    // Clean up the data source by removing noLinkOption property
    for (let i = 0; i < this.dataSource.length; i++) {
      delete this.dataSource[i].noLinkOption
    }

    // Remove any completely empty rows from the end
    while (this.dataSource.length > 0) {
      const lastRow = this.dataSource[this.dataSource.length - 1]
      const isEmpty = Object.keys(lastRow).every((key) => {
        if (key === '_____DELETE__THIS__RECORD_____') return true
        return !lastRow[key] || lastRow[key] === ''
      })

      if (isEmpty) {
        this.dataSource.pop()
      } else {
        break
      }
    }

    hot.updateSettings(
      {
        data: this.dataSource,
        colHeaders: this.headerColumns,
        columns: this.cellValidation,
        modifyColWidth: function (width: number, col: number) {
          if (width > 500) return 500
          else return width
        }
      },
      false
    )

    this.reSetCellValidationValues()

    EditorComponent.cnt = 0
    EditorComponent.nonPkCnt = 0

    this.validatePrimaryKeys()

    if (this.duplicatePkIndexes.length !== 0) {
      this.pkDups = true
      this.submit = false
      this.cancelSubmit()
      return
    } else {
      this.pkDups = false
    }

    hot.validateRows(this.modifedRowsIndexes, () => {
      if (this.checkInvalid()) {
        const abortMsg = 'Invalid Values are Present'

        this.eventService.showInfoModal('Validation error', abortMsg)

        return
      }

      this.submit = true
      this.validationDone = 1

      setTimeout(() => {
        const txt: any = document.getElementById('formFields_8')
        if (txt) txt.focus()
      }, 200)
    })
  }

  public async saveTable(data: any) {
    const hot = this.hotInstance
    const hotData = hot.getData()

    data = data.filter((dataRow: any) => {
      const elModified = this.dataModified.find((row) => {
        for (const pkCol of this.headerPks) {
          if (row[pkCol] !== dataRow[pkCol]) {
            return false
          }
        }

        return true
      })

      return !!elModified
    })

    data = data.map((row: any) => {
      const deleteColValue = row['_____DELETE__THIS__RECORD_____']

      delete row['_____DELETE__THIS__RECORD_____']
      row['_____DELETE__THIS__RECORD_____'] = deleteColValue

      // If cell is numeric and value is dot `.` we change it to `null`
      Object.keys(row).map((key: string) => {
        const colRule = this.dcValidator?.getRule(key)

        if (colRule?.type === 'numeric' && row[key] === '.') row[key] = null
      })

      return row
    })

    this.loggerService.log('Data submitted', data)

    if (this.checkInvalid()) {
      const abortMsg = 'Invalid Values are Present'
      this.eventService.showInfoModal('Validation error', abortMsg)
      this.cancelSubmit()
      this.submit = false
      return
    }

    this.validationDone = 0
    this.saveLoading = true

    if (
      EditorComponent.cnt < 1 &&
      this.duplicatePkIndexes.length === 0 &&
      EditorComponent.nonPkCnt < 1
    ) {
      this.saveLoading = true
      this.disableSubmit = false
      this.submit = true
      const updateParams: any = {}
      updateParams.ACTION = 'LOAD'
      this.message = sanitiseForSas(this.message.replace(/\n/g, '. '))
      updateParams.MESSAGE = this.message
      // updateParams.APPROVER = this.approver;
      updateParams.LIBDS = this.libds

      if (this.cols) {
        const submitData = data.slice(
          0,
          this.licenceState.value.submit_rows_limit
        )

        const success = await this.sasStoreService
          .updateTable(
            updateParams,
            submitData,
            'SASControlTable',
            'editors/stagedata',
            this.$dataFormats
          )
          .then((res: RequestWrapperResponse) => {
            if (typeof res.adapterResponse.sasparams !== 'undefined') {
              this.router.navigateByUrl(
                '/stage/' + res.adapterResponse.sasparams[0].DSID
              )

              return true
            }

            let error = `Submit request failed`

            if (res) {
              const errorText =
                typeof res === 'string' ? res : JSON.stringify(res)

              error += `\n${errorText}`
            }

            this.eventService.showAbortModal(
              'editors/stagedata',
              error,
              null,
              'Submit error'
            )
          })
          .catch((err: any) => {
            console.log('err', err)
            EditorComponent.cnt = 0
            EditorComponent.nonPkCnt = 0
            this.disableSubmit = true
            this.submit = false

            const errorText =
              typeof err.adapterRespnse === 'string'
                ? err.adapterRespnse
                : JSON.stringify(err.adapterRespnse)

            this.eventService.showAbortModal(
              'editors/stagedata',
              `Submit request failed\n${errorText}`,
              null,
              'Submit error'
            )

            return false
          })

        if (success) return //stop code execution if route redirected
      }
    }

    if (EditorComponent.cnt >= 1) {
      this.pkNull = true
      this.submit = true
    } else {
      this.submit = false
    }
    if (EditorComponent.nonPkCnt >= 1) {
      this.noPkNull = true
      this.submit = true
    } else {
      this.submit = false
    }

    this.cancelSubmit()
    EditorComponent.cnt = 0
    EditorComponent.nonPkCnt = 0
    this.disableSubmit = true
  }

  public validatorRuleSource(colName: string) {
    return this.dcValidator?.getRule(colName)
  }

  public checkInvalid() {
    // Use Angular wrapper to access Handsontable element instead of DOM queries
    if (!this.hotTableComponent || !this.hotTableComponent.hotInstance)
      return false

    const hotElement = this.hotTableComponent.hotInstance.rootElement
    if (!hotElement) return false

    // Check for standard Handsontable validation failures
    const standardInvalidCells = hotElement.querySelectorAll('.htInvalid')

    // Check for our custom duplicate primary key validation failures
    const customInvalidCells = hotElement.querySelectorAll('.dc-invalid-cell')

    return standardInvalidCells.length > 0 || customInvalidCells.length > 0
  }

  public goToEditor() {
    this.router.navigateByUrl('/')
  }

  closeRecordEdit(confirmButtonClicked?: boolean) {
    this.currentEditRecordIndex = -1
    this.currentEditRecord = undefined
    this.currentEditRecordValidator = undefined

    if (this.recordAction === 'ADD' && !confirmButtonClicked) {
      this.dataSource = this.helperService.deepClone(this.prevDataSource)

      const hot = this.hotInstance

      hot.updateSettings(
        {
          data: this.dataSource
        },
        false
      )
    }
  }

  confirmRecordEdit(close = true) {
    const closingRecordIndex = this.currentEditRecordIndex

    if (close) this.currentEditRecordIndex = -1

    this.columnHeader.map((colName: string) => {
      const value = this.currentEditRecord[colName]
      const isNum = this.$dataFormats?.vars[colName]?.type === 'num'
      const specialMissing = isSpecialMissing(value)

      if (isNum && !isNaN(value) && !specialMissing) {
        this.currentEditRecord[colName] = value * 1
      }
    })

    this.dataSource[closingRecordIndex] = this.currentEditRecord
    this.hotTable.data[closingRecordIndex] = this.currentEditRecord

    const hot = this.hotInstance

    hot.updateSettings(
      {
        data: this.dataSource
      },
      false
    )

    if (close) this.currentEditRecord = undefined
  }

  onNextRecord() {
    this.confirmRecordEdit(false)
    this.currentEditRecordIndex =
      this.currentEditRecordIndex >= this.dataSource.length - 1
        ? 0
        : this.currentEditRecordIndex + 1
    this.editRecord(null, this.currentEditRecordIndex)
  }

  onPreviousRecord() {
    this.confirmRecordEdit(false)
    this.currentEditRecordIndex =
      this.currentEditRecordIndex <= 0
        ? this.dataSource.length - 1
        : this.currentEditRecordIndex - 1
    this.editRecord(null, this.currentEditRecordIndex)
  }

  addRecordButtonClick() {
    if (this.restrictions.restrictAddRecord) {
      this.eventService.showDemoLimitModal('Add Record')
      return
    }

    this.addEditNewRecord()
  }

  addEditNewRecord() {
    this.addRecord()
    setTimeout(() => {
      this.editRecord(null, this.dataSource.length - 1, true)
    }, 1000)
  }

  addRecord() {
    this.addRow()
  }

  editRecord(item: Element | null, index?: number, newRecord?: boolean) {
    if (this.restrictions.restrictEditRecord) {
      this.eventService.showDemoLimitModal('Edit Record')
      return
    }

    if (index === undefined || index < 0) return

    if (this.restrictions.restrictEditRecord) {
      return
    }

    this.recordAction = newRecord ? 'ADD' : 'EDIT'

    if (this.hotTable.readOnly) {
      this.editTable(false, newRecord)
    }

    // Create copy of DC validator to be used in RECORD MODAL
    this.currentEditRecordValidator = this.helperService.deepClone(
      this.dcValidator
    )

    if (newRecord) {
      this.prevDataSource = this.helperService.deepClone(this.dataSource)
      this.prevDataSource.pop()
    } else {
      const currentEditRecordCellsMeta = this.helperService.deepClone(
        this.hotInstance.getCellMetaAtRow(
          index
        ) as Partial<Handsontable.CellProperties>[]
      )

      // Update that copy with current cells meta (dynamic validation data)
      for (const cellMeta of currentEditRecordCellsMeta) {
        if (cellMeta) {
          const data = cellMeta.prop?.toString() //------------
          delete cellMeta.prop // We convert to be able to update dcValidator rule by using CellProperties
          delete cellMeta.data //-----------------

          this.currentEditRecordValidator?.updateRule(cellMeta.col!, {
            ...cellMeta,
            data: data
          })
        }
      }
    }

    this.currentEditRecordIndex = index
    this.currentEditRecord = this.helperService.deepClone(
      this.dataSource[index]
    )
  }

  toggleHotPlugin(pluginName: string, enable: boolean) {
    const hot = this.hotInstance

    hot.batch(() => {
      const contextMenuPlugin = hot.getPlugin<any>(pluginName)

      if (!contextMenuPlugin) {
        console.warn(
          'Toggle Hot Plugin failed - Plugin named: ' +
            pluginName +
            ' - could not be found.'
        )
        return
      }

      setTimeout(() => {
        if (enable) {
          contextMenuPlugin.enablePlugin()
          return
        }

        contextMenuPlugin.disablePlugin()
      }, 100)

      hot.render()
    })
  }

  private dynamicCellValidationDisabled(row: number, col: number) {
    const rowColFound = this.disabledBasicDynamicCellValidationMap.find(
      (x) => x.row === row && x.col === col && !x.active
    )

    return !!rowColFound
  }

  /**
   * This function takes row and column numbers for the cel to be validated and pouplated with values.
   * It will send the row values without the current column to the sas.
   * Sas will return values and if length greater then zero cel becomes dropdown type and values from sas
   * put to source.
   * @param row handsontable row
   * @param column handsontable column
   */
  public async dynamicCellValidation(
    row: number,
    column: number,
    opts?: { skipRender?: boolean },
    retried = false
  ): Promise<void> {
    if (this.dynamicCellValidationDisabled(row, column))
      return Promise.resolve()

    const hot = this.hotInstance

    const cellMeta = hot.getCellMeta(row, column)

    if (cellMeta.readOnly) return Promise.resolve()

    const cellData = hot.getDataAtCell(row, column)
    const clickedRow = this.helperService.deepClone(this.dataSource[row])
    const clickedColumnKey = Object.keys(clickedRow)[column]
    const skipRender = !!opts?.skipRender
    const myEpoch = this.validationEpoch

    /**
     * We will hash the row (without current column) so later we check if hash is the same
     * we set the values relative to that hash
     * if not we fire the request.
     */
    const hashedRow = this.helperService.deleteKeysAndHash(
      clickedRow,
      [clickedColumnKey, 'noLinkOption'],
      false
    )

    const validationSourceIndex = this.cellValidationSource.findIndex(
      (entry: CellValidationSource) => entry.hash === hashedRow
    )

    /**
     * Set the values for found hash.
     */

    if (validationSourceIndex > -1) {
      // In-flight dedup: another call with the same hash is mid-request.
      // Wait for it then re-enter once so we walk the populated cache-hit
      // path instead of validating against the empty placeholder.
      const inFlight = this.cellValidationSource[validationSourceIndex].pending
      if (inFlight && !retried) {
        try {
          await inFlight
        } catch {
          /* swallowed — original caller handles */
        }
        if (myEpoch !== this.validationEpoch) return
        return this.dynamicCellValidation(row, column, opts, true)
      }
    }

    if (validationSourceIndex > -1) {
      let colSource = this.cellValidationSource[
        validationSourceIndex
      ].values.map((el) => el.RAW_VALUE)

      const cellHadSource =
        (hot.getCellMeta(row, column).source || []).length < 1
      const cellHasValue = cellData !== ' '

      hot.batch(() => {
        const cellMeta = hot.getCellMeta(row, column)

        const cellRule = this.dcValidator?.getRule(
          (cellMeta.data as string) || ''
        )
        let cellSource: string[] | number[] | undefined

        if (cellRule) {
          cellSource = this.dcValidator?.getDqDropdownSource(cellRule)
        }

        if (!cellSource) cellSource = []

        if (cellRule?.type === 'numeric') {
          cellSource = this.helperService.convertArrayValues(
            cellSource,
            'number'
          ) as number[]
          colSource = this.helperService.convertArrayValues(
            colSource,
            'number'
          ) as number[]
        } else {
          cellSource = this.helperService.convertArrayValues(
            cellSource,
            'string'
          ) as string[]
          colSource = this.helperService.convertArrayValues(
            colSource,
            'string'
          ) as string[]
        }

        const cellSourceCombined = [
          ...new Set([...cellSource, ...colSource])
        ] as string[]

        hot.setCellMeta(row, column, 'source', cellSourceCombined)

        this.currentEditRecordValidator?.updateRule(column, {
          source: cellSourceCombined
        })

        if (
          this.cellValidationSource[validationSourceIndex].values.length > 0
        ) {
          const strict = this.cellValidationSource[validationSourceIndex].strict

          hot.setCellMeta(row, column, 'renderer', 'autocomplete')
          hot.setCellMeta(row, column, 'editor', 'autocomplete.custom')
          hot.setCellMeta(row, column, 'strict', strict)
          hot.setCellMeta(row, column, 'filter', false)

          this.currentEditRecordValidator?.updateRule(column, {
            renderer: 'autocomplete',
            editor: 'autocomplete.custom',
            strict: strict,
            filter: false
          })
        }

        this.reSetExtendedCellValidationValues(
          this.cellValidationSource[validationSourceIndex],
          row,
          cellHadSource && cellHasValue
        )

        if (!skipRender) hot.render()
      })
    } else if (validationSourceIndex < 0) {
      /**
       * Send request to sas.
       */
      const data = {
        SASControlTable: [
          {
            libds: this.libds,
            variable_nm: clickedColumnKey
          }
        ],
        source_row: [clickedRow]
      }

      const validationHook = this.dcValidator
        ?.getDqDetails(clickedColumnKey)
        .find(
          (rule: DQRule) =>
            rule.RULE_TYPE === 'SOFTSELECT_HOOK' ||
            rule.RULE_TYPE === 'HARDSELECT_HOOK'
        )

      /**
       * Do the validation only if current column has validation hooks in place.
       */
      if (validationHook) {
        this.cellValidationSource.push({
          row: row,
          col: column,
          strict: validationHook.RULE_TYPE === 'HARDSELECT_HOOK',
          values: [],
          hash: hashedRow,
          count: this.cellValidationSource.length + 1
        })

        this.currentEditRecordLoadings.push(column)

        const spinnerKey = `${row},${column}`

        // Defer the spinner renderer so the click event finishes settling
        // HOT's focus catcher before we replace td.innerHTML. Without this
        // defer, clicking the cell loses focus immediately.
        // Skip the spinner entirely if SAS responds before this fires —
        // avoids the brief flicker users were seeing on fast responses.
        // Also skip during paste (skipRender) — the progress banner handles
        // visual feedback and cell spinners would just flicker.
        const spinnerTimeout: ReturnType<typeof setTimeout> | null = skipRender
          ? null
          : setTimeout(() => {
              hot.setCellMeta(row, column, 'renderer', spinnerRenderer)
              this.pendingSpinnerCells.add(spinnerKey)
              hot.render()
            }, 150)

        const pendingPromise = this.sasService
          .request('editors/getdynamiccolvals', data, undefined, {
            suppressSuccessAbortModal: true,
            suppressErrorAbortModal: true
          })
          .then(async (res: RequestWrapperResponse) => {
            if (spinnerTimeout) clearTimeout(spinnerTimeout)
            this.pendingSpinnerCells.delete(spinnerKey)

            // Cancelled mid-flight — drop the placeholder entry so future
            // calls don't see an empty cache hit, and skip all UI work.
            if (myEpoch !== this.validationEpoch) {
              const idx = this.cellValidationSource.findIndex(
                (e) => e.hash === hashedRow
              )
              if (idx > -1) this.cellValidationSource.splice(idx, 1)
              return
            }

            const colSource = res.adapterResponse.dynamic_values.map(
              (el: DynamicCellValidation) => el.RAW_VALUE
            )

            this.currentEditRecordLoadings.splice(
              this.currentEditRecordLoadings.indexOf(column),
              1
            )

            if (colSource.length > 0) {
              const validationSourceIndex = this.cellValidationSource.findIndex(
                (entry: CellValidationSource) => entry.hash === hashedRow
              )

              if (validationSourceIndex > -1) {
                this.cellValidationSource[validationSourceIndex] = {
                  ...this.cellValidationSource[validationSourceIndex],
                  row: row,
                  col: column,
                  values: res.adapterResponse.dynamic_values,
                  extended_values: res.adapterResponse.dynamic_extended_values,
                  pending: undefined
                }
              }

              /**
               * In the case that the original value is not included in the newly created cell dropdown
               * and validation type is HARDSELECT, the cell shoud be red
               */
              await new Promise<void>((resolve) =>
                setTimeout(() => {
                  this.reSetCellValidationValues(true, row)

                  if (!skipRender) {
                    hot.render()
                    hot.validateRows([row])
                  }
                  resolve()
                }, 100)
              )
            } else {
              if (!skipRender) {
                hot.setCellMeta(row, column, 'renderer', noSpinnerRenderer)
                hot.render()
              }
              const idx = this.cellValidationSource.findIndex(
                (e) => e.hash === hashedRow
              )
              if (idx > -1) this.cellValidationSource[idx].pending = undefined
            }

            /**
             * If hash table limit reached, remove the oldest element.
             * Oldest element is element with lowest `count` number.
             */
            if (this.cellValidationSource.length > this.validationTableLimit) {
              const oldestElement = this.cellValidationSource.reduce(
                (prev, curr) => (prev.count < curr.count ? prev : curr)
              )
              const oldestElementIndex =
                this.cellValidationSource.indexOf(oldestElement)

              this.cellValidationSource.splice(oldestElementIndex, 1)
            }
          })
          .catch((err: any) => {
            if (spinnerTimeout) clearTimeout(spinnerTimeout)
            this.pendingSpinnerCells.delete(spinnerKey)

            const currentRowHashIndex = this.cellValidationSource.findIndex(
              (x) => x.hash === hashedRow
            )

            this.cellValidationSource.splice(currentRowHashIndex, 1)

            if (myEpoch !== this.validationEpoch) return

            if (!skipRender) {
              hot.batch(() => {
                // Render error icon inside a cell
                hot.setCellMeta(row, column, 'renderer', errorRenderer)

                hot.render()
              })
            }

            //Stop edit record modal loading spinner
            this.currentEditRecordLoadings.splice(
              this.currentEditRecordLoadings.indexOf(column),
              1
            )

            //Show error on edit record modal
            this.currentEditRecordErrors.push(column)

            // After waiting time remove the error icon from cell and edit record modal field
            setTimeout(() => {
              if (!skipRender) {
                hot.setCellMeta(row, column, 'renderer', noSpinnerRenderer)
                hot.render()
              }

              //Remove error icon on the edit record modal field
              this.currentEditRecordErrors.splice(
                this.currentEditRecordErrors.indexOf(column),
                1
              )
            }, 3000)

            this.reSetCellValidationValues()

            this.loggerService.log('getdynamiccolvals error:', err)
          })

        const entryIdx = this.cellValidationSource.findIndex(
          (e) => e.hash === hashedRow
        )
        if (entryIdx > -1) {
          this.cellValidationSource[entryIdx].pending = pendingPromise
        }

        return pendingPromise
      }
    }

    return Promise.resolve()
  }

  checkEmptyRowWhenFilter() {
    this.zeroFilterRows = false

    if (
      typeof this.filter_pk !== 'undefined' &&
      this.hotTable.data.length === 1
    ) {
      if ([null, ''].includes(this.hotTable.data[0][this.headerPks[0]]))
        this.zeroFilterRows = true
    }
  }

  onRecordInputFocus(event: EditRecordInputFocusedEvent) {
    this.dynamicCellValidation(this.currentEditRecordIndex, event.colName)
  }

  executeDynamicCellValidationIfApplicable(colProp: any, col: any, row: any) {
    const hashedRow = this.helperService.deleteKeysAndHash(
      this.dataSource[row],
      [colProp, 'noLinkOption']
    )

    const cellValidation = this.cellValidationSource.find(
      (entry: CellValidationSource) =>
        entry.hash === hashedRow && col === entry.col
    )

    if (
      cellValidation &&
      cellValidation.extended_values &&
      cellValidation.extended_values.length > 0
    ) {
      const extendedValidationObject = this.getExtendedValuesByCellValue(
        cellValidation,
        row
      )

      this.setExtendedValuesToCells(
        cellValidation,
        row,
        extendedValidationObject,
        true
      )
    }
  }

  datasetInfoModalRowClicked(value: Version | DSMeta) {
    if ((<Version>value).LOAD_REF !== undefined) {
      // Type is Version
      const row = value as Version
      const url = `/stage/${row.LOAD_REF}`

      this.router.navigate([url])
    }
  }

  viewboxManager() {
    this.viewboxes = true
  }

  get totalRowsChanged() {
    return (
      this.rowsChanged.rowsUpdated +
      this.rowsChanged.rowsDeleted +
      this.rowsChanged.rowsAdded
    )
  }

  /**
   * Function checks if selected hot cell is solo cell selected
   * and if it is, set the `filter` property based on filter param.
   *
   * @param filter
   */
  private setCellFilter(filter: boolean) {
    const hotSelected = this.hotInstance.getSelected()
    if (!hotSelected) return
    const selection = hotSelected ? hotSelected[0] : hotSelected

    // When we open a dropdown we want filter disabled so value in cell
    // don't filter out items, since we want to see them all.
    // But when we start typing we want to be able to start filtering values
    // again
    if (selection) {
      const startRow = selection[0]
      const endRow = selection[2]
      const startCell = selection[1]
      const endCell = selection[3]

      if (startRow === endRow && startCell === endCell) {
        const cellMeta = this.hotInstance.getCellMeta(startRow, startCell)

        // If filter is not already set at the value in the param, set it
        if (cellMeta && cellMeta.filter === !filter) {
          this.hotInstance.setCellMeta(startRow, startCell, 'filter', filter)
        }
      }
    }
  }

  async ngOnInit() {
    // VA data-driven content: register the postMessage listener as early as
    // possible (like the SAS sample's load-time registration) so VA's initial
    // data message isn't missed during the editor's getdata load. The message
    // is buffered on the service and replayed once the grid is ready.
    if (this.isVaEmbed) this.subscribeToVaMessages()

    // Initialize hot table settings
    this.updateHotTableSettings()

    this.licenceService.hot_license_key.subscribe(
      (hot_license_key: string | undefined) => {
        this.hotTable.licenseKey = hot_license_key
        this.updateHotTableSettings() // Update settings when license key changes
      }
    )

    this._query = this.sasStoreService.query.subscribe((query: any) => {
      if (query.libds === this.libds) {
        this.whereString = query.string
        this.clauses = query.obj
        // this.libds = query.libds
      }
    })

    // recover lib and table parameters from url; filter pk is optional - if filter is applied
    const myParams: any = {}
    if (typeof this.route.snapshot.params['libMem'] !== 'undefined') {
      this.libds = this.route.snapshot.params['libMem']
      this.filter_pk = this.route.snapshot.params['filterId']

      if (this.route.snapshot.url[0].path === 'edit-record') {
        if (typeof this.filter_pk !== 'undefined') {
          this.recordAction = 'EDIT'
        } else {
          this.recordAction = 'ADD'
        }
      }

      myParams.LIBDS = this.libds
      if (typeof this.filter_pk !== 'undefined') {
        myParams.FILTER_RK = parseInt(this.filter_pk)
      }
      myParams.OUTDEST = 'WEB'

      if (this.libds) {
        globals.editor.library = this.libds.split('.')[0]
        globals.editor.table = this.libds.split('.')[1]
      }
    }

    // VA initial load: if VA's first message already carries a filter, apply it
    // BEFORE the first getdata so the initial fetch is filtered (no full-table
    // load + reload). Navigates away on success; this instance aborts its load.
    // Falls back to the normal unfiltered load on timeout/no-filter/failure.
    if (await this.maybeDeferInitialVaLoad()) return

    if (this.libds) {
      this.getdataError = false

      await this.sasStoreService
        .callService(myParams, 'SASControlTable', 'editors/getdata', this.libds)
        .then((res: EditorsGetDataServiceResponse) => {
          this.initSetup(res)
        })
        .catch((err: any) => {
          this.getdataError = true
          this.tableTrue = true
        })
    }
  }

  /**
   * VA mode, initial (unfiltered) route only: briefly wait for VA's buffered
   * first message and, if it already carries a filter, save that filter and
   * navigate to /editor/<table>/<FILTER_RK> so the very first getdata is
   * filtered. Returns true if it navigated (caller must abort its own load),
   * false to proceed with the normal unfiltered load.
   *
   * The filter is built directly from VA parameters (param label === DC column,
   * which VA↔DC matching already requires; numeric inferred from the VA param
   * dataType/value) because DC column metadata isn't available until getdata
   * has run. Any failure falls back to the normal load; the post-load replay
   * (with full metadata) reconciles to the same filter signature.
   */
  private async maybeDeferInitialVaLoad(): Promise<boolean> {
    if (!this.isVaEmbed) return false
    // Only the initial, unfiltered route — never when a FILTER_RK is present.
    if (typeof this.route.snapshot.params['filterId'] !== 'undefined')
      return false
    const libds = this.route.snapshot.params['libMem']
    if (!libds) return false

    // VA posts its first message before Angular boots, so it's already in the
    // external buffer by now — read it directly (no polling). If it hasn't
    // arrived yet, skip the optimisation and let the normal load + live message
    // apply the filter.
    const msg = this.vaMessaging.latestMessage()
    if (!msg) return false

    const clauses = this.vaFilter.buildInitialClauses(msg)
    if (clauses.length === 0) return false

    try {
      const res: any = await this.sasStoreService.saveQuery(libds, clauses)
      const id = res?.result?.[0]?.FILTER_RK
      const table = res?.result?.[0]?.FILTER_TABLE
      if (id === undefined || table === undefined) return false
      // Seed the cross-reload signature so the post-reload replay is a no-op.
      this.vaMessaging.filterSignature = this.vaFilter.signature(clauses)
      await this.reloadEditorRoute('/editor/' + table + '/' + id)
      return true
    } catch {
      return false
    }
  }

  ngAfterViewInit() {
    // Fix ARIA accessibility issues after table initialization
    setTimeout(() => {
      this.fixAriaAccessibility()
    }, 1000)

    // Set up event listener for hot table element
    // Double click to edit
    setTimeout(() => {
      if (this.hotTableComponent && this.hotTableComponent.hotInstance) {
        const hotElement = this.hotTableComponent.hotInstance.rootElement
        if (hotElement) {
          hotElement.addEventListener('mousedown', (event: MouseEvent) => {
            if (!this.uploadPreview) {
              this.hotClicked()
            }

            setTimeout(() => {
              const menuDebugItem: any =
                document.querySelector('.debug-switch-item') || undefined
              if (menuDebugItem) menuDebugItem.click()
            }, 100)
          })
        }
      }
    }, 100)
  }

  ngOnDestroy() {
    // Clean up the MutationObserver
    if (this.ariaObserver) {
      this.ariaObserver.disconnect()
      this.ariaObserver = undefined
    }

    // Clean up the interval
    if (this.ariaCheckInterval) {
      clearInterval(this.ariaCheckInterval)
      this.ariaCheckInterval = undefined
    }

    // Cancel any pending debounced VA apply
    if (this.vaDebounceTimer) {
      clearTimeout(this.vaDebounceTimer)
      this.vaDebounceTimer = undefined
    }

    // Remove the VA postMessage listener
    if (this.vaUnsubscribe) {
      this.vaUnsubscribe()
      this.vaUnsubscribe = undefined
    }
  }

  /**
   * Fixes ARIA accessibility issues in the Handsontable component
   * This addresses the accessibility report issues with treegrid and presentation roles
   */
  private fixAriaAccessibility() {
    // Use a more aggressive approach to find and fix all ARIA issues
    const fixAriaIssues = () => {
      // Specifically target Handsontable wrapper elements that are causing issues
      const hotWrappers = document.querySelectorAll(
        '.ht-wrapper, .wtHolder, [id^="ht_"]'
      )

      hotWrappers.forEach((wrapper) => {
        // Remove problematic ARIA attributes from Handsontable wrappers
        wrapper.removeAttribute('role')
        wrapper.removeAttribute('aria-rowcount')
        wrapper.removeAttribute('aria-colcount')
        wrapper.removeAttribute('aria-multiselectable')
      })

      // Find all elements with problematic ARIA roles in the entire document
      const allTreegridElements = document.querySelectorAll('[role="treegrid"]')
      const allPresentationElements = document.querySelectorAll(
        '[role="presentation"]'
      )

      // Fix treegrid role issues - remove them completely as they're causing problems
      allTreegridElements.forEach((element) => {
        element.removeAttribute('role')
        element.removeAttribute('aria-rowcount')
        element.removeAttribute('aria-colcount')
        element.removeAttribute('aria-multiselectable')
      })

      // Fix presentation role issues - remove them if they contain interactive elements
      allPresentationElements.forEach((element) => {
        const hasInteractiveChildren =
          element.querySelectorAll(
            'button, input, select, textarea, [tabindex], [onclick], [contenteditable]'
          ).length > 0
        if (hasInteractiveChildren) {
          element.removeAttribute('role')
        }
      })

      // Also fix any elements with aria-rowcount="-1" which is problematic
      const negativeRowCountElements = document.querySelectorAll(
        '[aria-rowcount="-1"]'
      )
      negativeRowCountElements.forEach((element) => {
        element.removeAttribute('aria-rowcount')
      })

      // Ensure proper table structure
      const tableElements = document.querySelectorAll('table')
      tableElements.forEach((table) => {
        if (!table.getAttribute('role')) {
          table.setAttribute('role', 'table')
        }

        // Ensure table headers have proper scope
        const headerCells = table.querySelectorAll('th')
        headerCells.forEach((th) => {
          if (!th.getAttribute('scope')) {
            th.setAttribute('scope', 'col')
          }
        })
      })

      // Add proper ARIA labels to interactive elements
      const interactiveElements = document.querySelectorAll(
        'button, input, select, textarea, [contenteditable]'
      )
      interactiveElements.forEach((element) => {
        if (
          !element.getAttribute('aria-label') &&
          !element.getAttribute('aria-labelledby')
        ) {
          const textContent = element.textContent?.trim()
          if (textContent) {
            element.setAttribute('aria-label', textContent)
          }
        }
      })
    }

    // Run the fix immediately
    fixAriaIssues()

    // Run it again after a short delay to catch any dynamically created elements
    setTimeout(fixAriaIssues, 100)
    setTimeout(fixAriaIssues, 500)
    setTimeout(fixAriaIssues, 1000)
    setTimeout(fixAriaIssues, 2000)

    // Set up a periodic check to ensure accessibility fixes are maintained
    if (!this.ariaCheckInterval) {
      this.ariaCheckInterval = setInterval(fixAriaIssues, 3000)
    }

    // Set up a MutationObserver to continuously monitor for new problematic elements
    if (!this.ariaObserver) {
      this.ariaObserver = new MutationObserver((mutations) => {
        let shouldFix = false
        mutations.forEach((mutation) => {
          if (
            mutation.type === 'attributes' &&
            (mutation.attributeName === 'role' ||
              mutation.attributeName === 'aria-rowcount')
          ) {
            shouldFix = true
          }
          if (mutation.type === 'childList') {
            mutation.addedNodes.forEach((node) => {
              if (node.nodeType === Node.ELEMENT_NODE) {
                const element = node as Element
                if (
                  element.hasAttribute('role') ||
                  element.hasAttribute('aria-rowcount')
                ) {
                  shouldFix = true
                }
              }
            })
          }
        })

        if (shouldFix) {
          setTimeout(fixAriaIssues, 50)
        }
      })

      // Start observing the entire document for changes
      this.ariaObserver.observe(document.body, {
        childList: true,
        subtree: true,
        attributes: true,
        attributeFilter: [
          'role',
          'aria-rowcount',
          'aria-colcount',
          'aria-multiselectable'
        ]
      })
    }
  }

  initSetup(response: EditorsGetDataServiceResponse, attempt = 0) {
    if (this.getdataError) return
    if (!response || !response.data) return

    this.hotInstance = this.hotTableComponent?.hotInstance!
    if (!this.hotInstance) {
      // Retry init, don't permanently abort
      if (attempt < 60) {
        setTimeout(() => this.initSetup(response, attempt + 1), 50)
      }
      return
    }

    this.cols = response.data.cols
    this.dsmeta = response.data.dsmeta
    this.versions = response.data.versions || []

    const notes = this.dsmeta.find((item) => item.NAME === 'NOTES')
    const longDesc = this.dsmeta.find((item) => item.NAME === 'DD_LONGDESC')
    const shortDesc = this.dsmeta.find((item) => item.NAME === 'DD_SHORTDESC')

    if (notes && notes.VALUE) {
      this.dsNote = notes.VALUE
    } else if (longDesc && longDesc.VALUE) {
      this.dsNote = longDesc.VALUE
    } else if (shortDesc && shortDesc.VALUE) {
      this.dsNote = shortDesc.VALUE
    } else {
      this.dsNote = ''
    }

    const hot = this.hotInstance

    const approvers: Approver[] = response.data.approvers

    if (this.cols) {
      this.headerArray = parseTableColumns(this.cols)
    }
    // Note: the above this.headerArray is being reassigned without being used.
    // So, above assignment does not make sense.

    approvers.forEach((item: Approver) => {
      this.approvers.push(item.PERSONNAME)
    })

    this.tableTrue = true
    this.libds = response.libds
    this.hotTable.data = response.data.sasdata

    this.headerColumns = response.data.sasparams[0].COLHEADERS.split(',')
    this.headerPks = response.data.sasparams[0].PK.split(' ')
    this.columnLevelSecurityFlag = !!response.data.sasparams[0].CLS_FLAG

    if (this.columnLevelSecurityFlag)
      this.setRestrictions({
        restrictAddRow: true,
        removeEditRecordButton: true,
        removeAddRecordButton: true
      })

    this.checkEmptyRowWhenFilter()

    if (this.headerColumns.indexOf('_____DELETE__THIS__RECORD_____') !== -1) {
      this.headerColumns[
        this.headerColumns.indexOf('_____DELETE__THIS__RECORD_____')
      ] = 'Delete?'
    }

    this.headerArray = this.headerColumns.slice(1)

    if (response.data.sasparams[0].DTVARS !== '') {
      this.dateHeaders = response.data.sasparams[0].DTVARS.split(' ')
    }
    if (response.data.sasparams[0].TMVARS !== '') {
      this.timeHeaders = response.data.sasparams[0].TMVARS.split(' ')
    }
    if (response.data.sasparams[0].DTTMVARS !== '') {
      this.dateTimeHeaders = response.data.sasparams[0].DTTMVARS.split(' ')
    }
    if (response.data.xl_rules.length > 0) {
      this.xlRules = this.helperService.deepClone(response.data.xl_rules)
    }

    this.dcValidator = new DcValidator(
      response.data.sasparams[0],
      response.data.$sasdata,
      this.cols,
      response.data.dqrules,
      response.data.dqdata
    )

    this.cellValidation = this.dcValidator.getRules()

    // to take datasource
    this.dataSource = response.data.sasdata
    this.$dataFormats = response.data.$sasdata

    // Note: this.headerColumns and this.columnHeader contains same data
    // need to resolve redundancy

    // default schema - includes NOTNULL defaults from DQ rules
    for (let i = 0; i < this.headerColumns.length; i++) {
      const colType = this.cellValidation[i].type

      this.hotDataSchema[this.cellValidation[i].data] = getHotDataSchema(
        colType,
        this.cellValidation[i],
        this.dcValidator?.getDqDetails()
      )
    }

    // this.addActionButtons();
    // this.addActionColumns();
    // now all validation params we have in this.cellValidation

    this.checkRowLimit()

    hot.updateSettings(
      {
        data: this.dataSource,
        colHeaders: this.headerColumns,
        columns: this.cellValidation,
        height: this.hotTable.height,
        formulas: this.hotTable.formulas,
        stretchH: 'all',
        readOnly: this.hotTable.readOnly,
        hiddenColumns: {
          indicators: true,
          columns: this.dcValidator.getHiddenColumns()
        },
        modifyColWidth: function (width: number, col: number) {
          if (col === 0) {
            return 60
          }
          if (width > 500) return 500
          else return width
        },
        copyPaste: this.hotTable.copyPaste,
        manualColumnFreeze: false, //https://handsontable.com/docs/7.0.3/demo-freezing.html
        // false due to https://forum.handsontable.com/t/gh-5112-column-freeze-sorting/3236
        multiColumnSorting: true, // https://handsontable.com/docs/7.0.0/demo-multicolumn-sorting.html
        manualColumnResize: true,
        filters: false,
        manualRowResize: true,
        viewportRowRenderingOffset: 50,
        // show a bar on the left to enable users to select an entire row
        rowHeaders: (index: number) => {
          return ' '
        },
        rowHeaderWidth: 15,
        rowHeights: 24,
        maxRows: this.licenceState.value.editor_rows_allowed || Infinity,
        invalidCellClassName: 'htInvalid',
        // Prevent automatic row creation
        autoWrapRow: false,
        autoWrapCol: false,
        // Ensure proper data binding
        bindRowsWithHeaders: false,
        dropdownMenu: {
          items: {
            make_read_only: {
              name: 'make_read_only'
            },
            alignment: {
              name: 'alignment'
            },
            sp1: {
              name: '---------'
            },
            info: {
              name: 'test info',
              renderer: (
                hot: Handsontable.Core,
                wrapper: HTMLElement,
                row: number,
                col: number,
                prop: string | number,
                itemValue: string
              ) => {
                const elem = document.createElement('span')
                let colInfo: DataFormat | undefined
                let textInfo = 'No info found'

                if (this.hotInstance) {
                  const hotSelected: [number, number, number, number][] =
                    this.hotInstance.getSelected() || []
                  const selectedCol: number = hotSelected
                    ? hotSelected[0][1]
                    : -1
                  const colName = this.hotInstance?.colToProp(selectedCol)
                  colInfo = this.$dataFormats?.vars[colName]

                  if (colInfo)
                    textInfo = `LABEL: ${colInfo?.label}<br>TYPE: ${colInfo?.type}<br>LENGTH: ${colInfo?.length}<br>FORMAT: ${colInfo?.format}`
                }

                elem.innerHTML = textInfo

                return elem
              }
            }
          }
        },
        // filters: true,
        dataSchema: this.hotDataSchema,
        contextMenu: this.hotTable.settings.contextMenu,
        //, '---------','freeze_column','unfreeze_column'],
        currentHeaderClassName: 'customH',
        afterGetColHeader: (col: number, th: any) => {
          const column = this.columnHeader[col]

          // header columns styling - primary keys
          const isPKCol = column && this.isColPk(column)
          const isReadonlyCol = column && this.isReadonlyCol(column)

          if (isPKCol) th.classList.add('primaryKeyHeaderStyle')
          if (isReadonlyCol && !isPKCol) th.classList.add('readonlyCell')

          // Remove header arrow from Delete column
          if (col === 0) {
            th.classList.add('firstColumnHeaderStyle')
          }

          // Dark mode
          th.classList.add(globals.handsontable.darkTableHeaderClass)
        },
        afterGetCellMeta: (
          row: number,
          col: number,
          cellProperties: Handsontable.CellProperties
        ) => {
          const isReadonlyCol = col && this.isReadonlyCol(col)

          // Check if this cell should be marked as invalid due to duplicate primary key values
          // Only applies to primary key columns (col 1 through readOnlyFields)
          const isDuplicateCell =
            this.duplicatePkIndexes.includes(row) &&
            col >= 1 &&
            col <= this.readOnlyFields

          // Handle existing CSS classes - Handsontable can provide className as string or array
          const existingClasses = cellProperties.className || ''
          let classes: string[]

          if (Array.isArray(existingClasses)) {
            // If already an array, create a copy
            classes = [...existingClasses]
          } else {
            // If string, split by spaces and filter out empty strings
            classes = existingClasses
              .split(' ')
              .filter((c: string) => c.length > 0)
          }

          // Add readonlyCell class for readonly columns to maintain original styling
          if (isReadonlyCol && !classes.includes('readonlyCell')) {
            classes.push('readonlyCell')
          }

          // Apply custom validation styling for duplicate primary key cells
          // Note: Uses 'dc-invalid-cell' instead of Handsontable's 'htInvalid' class
          // because Handsontable's internal validation system was removing 'htInvalid'
          // causing flickering. Our custom class persists reliably.
          if (isDuplicateCell) {
            if (!classes.includes('dc-invalid-cell')) {
              classes.push('dc-invalid-cell')
            }
            // Mark cell as invalid to prevent form submission
            cellProperties.valid = false
            // Custom flag to identify this as a duplicate key cell for cleanup
            cellProperties.dupKey = true
          }

          // Apply the combined CSS classes back to the cell
          if (classes.length > 0) {
            cellProperties.className = classes.join(' ')
          }
        }
      },
      false
    )

    this.hotTable.hidden = false
    // Keep the context menu enabled in view mode too so Copy/Export remain
    // available; editing items hide themselves when read-only.
    this.toggleHotPlugin('contextMenu', true)
    /**
     * This is needed if freeze column is enabled
     */
    // hot.getPlugin('manualColumnFreeze').freezeColumn(0);

    this.queryText = response.data.sasparams[0].FILTER_TEXT
    this.columnHeader = response.data.sasparams[0].COLHEADERS.split(',')
    // First column is always used to mark records for deletion
    this.columnHeader[0] = 'Delete?'
    this.readOnlyFields = response.data.sasparams[0].PKCNT

    hot.addHook(
      'afterSelection',
      (
        row: number,
        column: number,
        row2: number,
        column2: number,
        preventScrolling: any,
        selectionLayerLevel: any
      ) => {
        /**
         * This is needed if freeze column is enabled
         */
        // if (column === 0) {
        //   delete contextMenuToSet.items.unfreeze_column;
        // }

        if (
          row === row2 &&
          column === column2 &&
          this.hotTable.readOnly === false
        ) {
          this.dynamicCellValidation(row, column)
        }

        /**
         * This is needed if freeze column is enabled
         */
        // if (column === 0) {
        //   if (!this.firstColumnSelected) {
        //     hot.updateSettings({
        //       contextMenu: contextMenuToSet
        //     });
        //     this.firstColumnSelected = true;
        //   }
        // } else {
        //   if (this.firstColumnSelected) {
        //     hot.updateSettings({
        //       contextMenu: contextMenuToSet
        //     });
        //     this.firstColumnSelected = false;
        //   }
        // }
      }
    )

    hot.addHook('afterBeginEditing', () => {
      // When we open a dropdown we want filter disabled so value in cell
      // don't filter out items, since we want to see them all.
      this.setCellFilter(false)
    })

    hot.addHook('beforeKeyDown', () => {
      // When we start typing, we are enabling the filter since we want to find
      // values faster.
      this.setCellFilter(true)
    })

    // ROUND: round numeric values Excel-style before they are written.
    // Mutating `changes` in place (rather than setDataAtRowProp) avoids
    // re-entrancy and uniformly covers edit, paste and autofill.
    hot.addHook('beforeChange', (changes: any[]) => {
      if (!changes) return

      for (const change of changes) {
        if (!change) continue

        const [, prop, , newValue] = change
        const colName =
          typeof prop === 'string'
            ? prop
            : (hot.colToProp(prop as number) as string)

        const digits = this.dcValidator?.getRoundDigits(colName)
        if (digits === undefined) continue

        const num = Number(newValue)
        if (newValue !== null && newValue !== '' && !isNaN(num)) {
          change[3] = excelRound(num, digits)
        }
      }
    })

    hot.addHook('afterChange', (source: any, change: any) => {
      if (change === 'edit') {
        const hot = this.hotInstance

        const row = source[0][0]
        const colProp = source[0][1]
        const col = hot.propToCol(colProp) as number

        // On edit we enabled filter for this cell, now when editing is finished
        // We want filter to be disabled again, to be ready for next dropdown opening.
        const cellMeta = hot.getCellMeta(row, col)
        if (cellMeta && cellMeta.filter === false)
          hot.setCellMeta(row, col, 'filter', true)

        this.executeDynamicCellValidationIfApplicable(colProp, col, row)
      }
    })

    hot.addHook('afterPaste', async (_data: any, coords: any) => {
      // In read-only mode HOT discards the paste itself, so nothing to validate.
      if (this.hotTable.readOnly) return
      const ranges = (coords as any[]).map((r) => ({
        startRow: r.startRow,
        startCol: r.startCol,
        endRow: r.endRow,
        endCol: r.endCol
      }))
      await this.runBulkValidation(hot, ranges, 'paste')
    })

    hot.addHook(
      'afterAutofill',
      async (_fillData: any, _sourceRange: any, targetRange: any) => {
        if (this.hotTable.readOnly) return
        const { from, to } = targetRange
        await this.runBulkValidation(
          hot,
          [
            {
              startRow: Math.min(from.row, to.row),
              startCol: Math.min(from.col, to.col),
              endRow: Math.max(from.row, to.row),
              endCol: Math.max(from.col, to.col)
            }
          ],
          'autofill'
        )
      }
    )

    hot.addHook('afterRender', (isForced: boolean) => {
      this.eventService.dispatchEvent('resize')

      // Fix ARIA accessibility issues after each render
      this.fixAriaAccessibility()
    })

    // Add a more frequent accessibility fix hook
    hot.addHook('afterChange', () => {
      // Fix ARIA accessibility issues after any data change
      setTimeout(() => {
        this.fixAriaAccessibility()
      }, 50)
    })

    hot.addHook('afterCreateRow', (source: any, change: any) => {
      if (source > this.dataSource.length) {
        // don't scroll if row is not added to the end (bottom)
        const wtHolder = document.querySelector('.wtHolder')

        setTimeout(() => {
          if (wtHolder) wtHolder.scrollTop = wtHolder.scrollHeight
        })
      }
    })

    // Add hook to prevent unwanted row creation
    hot.addHook(
      'beforeCreateRow',
      (index: number, amount: number, source?: any) => {
        // Only allow row creation through the Add Row button or context menu
        if (
          !this.addingNewRow &&
          source !== 'ContextMenu.insert_row_above' &&
          source !== 'ContextMenu.insert_row_below'
        ) {
          return false
        }
      }
    )

    // Auto-populate NOTNULL default when validation fails due to empty value
    hot.addHook(
      'afterValidate',
      (isValid: boolean, value: any, row: number, prop: string | number) => {
        if (isValid || !isEmpty(value)) return

        const colName =
          typeof prop === 'string'
            ? prop
            : (hot.colToProp(prop as number) as string)

        const defaultValue = this.dcValidator?.getNotNullDefaultValue(colName)
        if (defaultValue === undefined) return

        // Auto-populate using setTimeout to avoid modifying during validation
        setTimeout(() => {
          if (isEmpty(hot.getDataAtRowProp(row, colName))) {
            hot.setDataAtRowProp(row, colName, defaultValue, 'autoPopulate')
          }
        }, 0)
      }
    )

    // Coerce numeric-column values from string → number so length-check
    // and downstream validators see the correct type on the first pass
    // (string "3.5" passes `Number(value) === value`, masking float-in-
    // short-num errors until the next edit).
    const coerceNumericRow = (
      row: any[],
      startCol: number,
      rowMaxLen?: number
    ): any[] =>
      row.map((value: any, index: number) => {
        if (rowMaxLen !== undefined && index >= rowMaxLen) return value
        const colName = this.columnHeader[startCol + index]
        const isColNum = this.$dataFormats?.vars[colName]?.type === 'num'
        const specialMissing = isSpecialMissing(value)
        if (isColNum && !isNaN(value) && !specialMissing) value = value * 1
        return value
      })

    hot.addHook('beforePaste', (data: any, cords: any) => {
      const startCol = cords[0].startCol
      for (let r = 0; r < data.length; r++) {
        data[r] = coerceNumericRow(data[r], startCol)
      }
    })

    hot.addHook(
      'beforeAutofill',
      (selectionData: any[][], sourceRange: any) => {
        const startCol = sourceRange.from.col
        return selectionData.map((row) => coerceNumericRow(row, startCol))
      }
    )

    hot.addHook('afterRemoveRow', () => {
      this.checkRowLimit()
    })

    hot.addHook('afterCreateRow', () => {
      this.checkRowLimit()
    })

    this.uploadUrl = 'services/editors/loadfile'

    if (this.recordAction !== null) {
      if (this.recordAction === 'ADD') {
        this.addRecord()
        this.editRecord(null, this.dataSource.length - 1, true)
      } else {
        if (this.dataSource.length === 1) {
          this.editRecord(null, 0)
        }
      }
    }

    if (response.data.query.length > 0) {
      if (
        (globals.rootParam === 'home' || globals.rootParam === 'editor') &&
        globals.editor.filter.clauses.length === 0
      ) {
        globals.editor.filter.query = this.helperService.deepClone(
          response.data.query
        )
        globals.editor.filter.libds = this.route.snapshot.params['libMem']
        this.sasStoreService.initializeGlobalFilterClause('editor', this.cols)
      }
    }

    hot.render()

    // Fix ARIA accessibility issues after table initialization
    setTimeout(() => {
      this.fixAriaAccessibility()
    }, 500)

    // SAS Visual Analytics data-driven content mode: open editable immediately,
    // re-apply any column visibility chosen by VA before this (filter) reload,
    // and start receiving VA messages over the postMessage interface.
    if (this.isVaEmbed) {
      this.editTable()
      if (this.vaMessaging.visibleColumns) {
        this.applyVaColumnVisibility(new Set(this.vaMessaging.visibleColumns))
        if (this.hotInstance) this.hotInstance.render()
      }
      // Listener is already registered early (ngOnInit) so VA's initial post is
      // not missed; this is a guarded no-op if so. Now that the grid is ready,
      // replay the buffered latest message to apply any filter/visibility that
      // arrived during load.
      this.subscribeToVaMessages()
      // Reconcile to the true latest message after a reload — covers a message
      // that arrived during the reload gap (caught by va-early, missed by the
      // app listener). Signature dedup makes this a no-op if nothing changed.
      this.handleVaMessage()
    }
  }

  /**
   * Subscribes to the SAS VA data-driven content postMessage channel. VA pushes
   * a fresh message on every selection / parameter change. Registered early (in
   * ngOnInit) so the initial message isn't missed; messages are buffered on the
   * service and applied via a debounce so a burst of control clicks collapses
   * into a single reload on the latest message.
   */
  private subscribeToVaMessages() {
    if (this.vaUnsubscribe) return
    this.vaUnsubscribe = this.vaMessaging.onData(() => this.handleVaMessage())
  }

  /**
   * Handles one VA message immediately (NOT debounced): updates column
   * visibility and decides the filter. The cheap work (visibility, building the
   * filter clauses, deciding pending) runs per message so the "pending"
   * indicator can show the instant VA posts; only the expensive APPLY (saveQuery
   * + reload) is debounced (live mode) via scheduleVaFilterApply.
   *
   * Filter data always comes from DC's getdata; VA only drives (a) column
   * visibility and (b) the active filter (its params -> DC clauses, applied by
   * reloading at /editor/<table>/<FILTER_RK>).
   */
  private handleVaMessage() {
    const msg = this.vaMessaging.latestMessage()
    if (!msg || !this.libds || !this.cellValidation) return

    // Grid not ready yet (cellValidation only has the delete column, or none):
    // the label->var map would be empty, so every param would fail to map and
    // produce an empty filter that wrongly CLEARS the active one — the infinite
    // reload loop. Bail; the load-complete replay re-runs once metadata is in.
    if (this.cellValidation.length <= 1) return

    const labelToVar = this.vaFilter.buildLabelToVarMap(
      this.cellValidation,
      this.columnHeader
    )

    // Columns present in the message -> show these, hide the rest. A message
    // with no data columns carries no column info, so leave visibility as-is
    // rather than hiding everything.
    const dataColumns = this.vaMessaging.dataColumns(msg)
    let visibilityChanged = false
    if (dataColumns.length > 0) {
      const matchedVars = new Set<string>()
      for (const { column } of dataColumns) {
        const key = (column.label ?? column.name ?? '')
          .toString()
          .trim()
          .toLowerCase()
        const varName = labelToVar.get(key)
        if (varName) matchedVars.add(varName)
      }
      this.vaMessaging.visibleColumns = Array.from(matchedVars)
      visibilityChanged = this.applyVaColumnVisibility(matchedVars)
    }
    if (visibilityChanged && this.hotInstance) this.hotInstance.render()

    this.updateVaFilterText(msg)

    const clauses = this.vaFilter.buildClauses(msg, labelToVar, this.cols)
    const signature = this.vaFilter.signature(clauses)

    if (signature === this.vaMessaging.filterSignature) {
      // Selection matches the applied filter — nothing pending. (Don't clear a
      // 'loading' status; that belongs to an apply already in flight.)
      if (this.vaFilterStatus === 'pending') this.vaFilterStatus = 'idle'
      this.vaPendingClauses = null
      return
    }

    // A real change arrived -> pending (shown in both modes, immediately).
    this.vaPendingClauses = clauses
    this.vaPendingSignature = signature
    this.vaFilterStatus = 'pending'

    // Live mode auto-advances after the debounce settles; confirm waits for the
    // Apply button. The pending indicator stays visible throughout the debounce.
    if (this.vaAutoApply) this.scheduleVaFilterApply()
  }

  /** Live mode: debounce a burst of VA changes into a single apply of the latest. */
  private scheduleVaFilterApply() {
    if (this.vaDebounceTimer) clearTimeout(this.vaDebounceTimer)
    this.vaDebounceTimer = setTimeout(() => {
      this.vaDebounceTimer = undefined
      this.applyPendingVaFilter()
    }, EditorComponent.VA_DEBOUNCE_MS)
  }

  /**
   * Saves the VA-derived filter through DC's validatefilter flow and reloads the
   * editor at /editor/<table>/<FILTER_RK> (preserving ?embed=va) so the filter
   * key is visible in the URL. Empty clauses clear the filter.
   */
  private async applyVaFilter(clauses: QueryClause[], signature: string) {
    // A previous filter is still saving/reloading: drop this call WITHOUT
    // advancing the signature. The reloaded editor's load-complete replays the
    // latest buffered message, which re-evaluates against the still-accurate
    // last-applied signature and applies whatever the newest selection is.
    if (this.vaApplyingFilter) return
    this.vaApplyingFilter = true
    // Now actually fetching: surface the "loading filter" status (until DC's
    // native "Loading Table" takes over after the reload navigates).
    this.vaFilterStatus = 'loading'
    // Record the signature only now that we're actually applying it.
    this.vaMessaging.filterSignature = signature
    try {
      if (clauses.length === 0) {
        // Already unfiltered (no FILTER_RK in route)? Do nothing — reloading
        // would be a pointless round-trip and re-trigger the drain/replay cycle.
        // Only reload to clear a filter that is currently applied.
        if (typeof this.filter_pk === 'undefined') {
          this.vaFilterStatus = 'idle'
          return
        }
        await this.reloadEditorRoute('/editor/' + this.libds)
        return
      }
      const res: any = await this.sasStoreService.saveQuery(
        this.libds!,
        clauses
      )
      const id = res?.result?.[0]?.FILTER_RK
      const table = res?.result?.[0]?.FILTER_TABLE
      if (id === undefined || table === undefined) {
        this.vaFilterStatus = 'idle'
        return
      }
      await this.reloadEditorRoute('/editor/' + table + '/' + id)
    } catch {
      // Keep the current view on failure; the next message can retry.
      this.vaFilterStatus = 'idle'
    } finally {
      this.vaApplyingFilter = false
    }
  }

  /** Apply the staged VA filter (the debounce in live mode, or the Apply button). */
  public applyPendingVaFilter(): void {
    if (!this.vaPendingClauses) return
    const clauses = this.vaPendingClauses
    const signature = this.vaPendingSignature
    this.vaPendingClauses = null
    void this.applyVaFilter(clauses, signature)
  }

  /**
   * Toggles between live (auto-apply) and confirm filter modes. The choice is
   * stored on globals so it survives editor reloads. Switching to live with a
   * change still pending applies it immediately.
   */
  public toggleVaAutoApply(): void {
    globals.vaApplyMode = this.vaAutoApply ? 'confirm' : 'live'
    if (this.vaAutoApply && this.vaFilterStatus === 'pending') {
      this.applyPendingVaFilter()
    }
  }

  /** Forces a full editor reload of `target`, preserving the embed query param. */
  private async reloadEditorRoute(target: string) {
    await this.router.navigate(['/'], {
      skipLocationChange: true,
      queryParamsHandling: 'preserve'
    })
    await this.router.navigate([target], { queryParamsHandling: 'preserve' })
  }

  /**
   * Shows the DC data columns present in the latest VA message and hides the
   * rest (primary-key and validator-hidden columns are always preserved). VA
   * parameters can add/remove columns between pushes.
   */
  private applyVaColumnVisibility(matchedVars: Set<string>): boolean {
    const hot = this.hotInstance
    if (!hot) return false
    const plugin: any = hot.getPlugin('hiddenColumns')
    if (!plugin) return false

    const validatorHidden: number[] = this.dcValidator?.getHiddenColumns() || []
    const toHide: number[] = []
    const toShow: number[] = []

    for (let i = 1; i < this.cellValidation.length; i++) {
      const varName = this.cellValidation[i]?.data
      if (!varName || this.isColPk(varName)) continue
      if (validatorHidden.includes(i)) continue
      if (matchedVars.has(varName)) {
        if (plugin.isHidden(i)) toShow.push(i)
      } else if (!plugin.isHidden(i)) {
        toHide.push(i)
      }
    }

    if (toShow.length) plugin.showColumns(toShow)
    if (toHide.length) plugin.hideColumns(toHide)
    return toShow.length > 0 || toHide.length > 0
  }

  /** Logs the active VA filter summary (kept out of the embedded grid UI). */
  private updateVaFilterText(msg: VaMessage) {
    const summary = (msg.parameters || [])
      .filter((p) => p && p.name !== undefined && p.value !== undefined)
      .map((p) => `${p.label ?? p.name}=${p.value}`)
      .join(', ')
    // eslint-disable-next-line no-console
    console.log('VA filters active:', summary)
  }
}
