import { $DataFormats } from 'src/app/models/sas/editors-getdata.model'
import { DQData, SASParam } from 'src/app/models/TableData'
import { DcValidator } from '../dc-validator'
import { Col } from '../models/col.model'
import { DQRule } from '../models/dq-rules.model'

describe('DC Validator', () => {
  it('should create an instance of validator with correct rules', () => {
    const sasparams: SASParam = example_sasparams
    const cols: Col[] = example_cols
    const dqRules: DQRule[] = example_dqRules
    const dqData: DQData[] = example_dqData
    const $dataFormats: $DataFormats = example_dataformats

    const dcValidator: DcValidator = new DcValidator(
      sasparams,
      $dataFormats,
      cols,
      dqRules,
      dqData
    )

    // Check if COLS merged with FORMATS
    expect(cols[0].TYPE).toEqual('char')

    // Get all — one rule per cols[] entry, plus the injected
    // DELETE_RECORD_COLUMN_RULE (never present in cols[], see its own
    // doc comment)
    const validationRules = dcValidator.getRules()
    expect(validationRules).toHaveSize(example_cols.length + 1)

    // Get col with notnull validation
    const someNumRule = dcValidator.getRule('SOME_NUM')
    // Check allowEmpty - it is notnull rule so allowEmpty should be false
    expect(someNumRule?.allowEmpty).toBeFalse()

    // SAS sends semantic date/time/datetime column types; the frontend maps them
    // to the HOT 17 Intl cell-type names (see mapIntlCellTypes). The cell display
    // is the raw stored ISO string (no locale/format meta) — see
    // registerIntlCellTypes. The deprecated correctFormat + moment format strings
    // are no longer sent in the payload.
    const someDateRule = dcValidator.getRule('SOME_DATE')
    expect(someDateRule?.correctFormat).toBeUndefined()
    expect(someDateRule?.type).toBe('intl-date')

    const someDatetimeRule = dcValidator.getRule('SOME_DATETIME')
    expect(someDatetimeRule?.type).toBe('intl-datetime')
    expect(someDatetimeRule?.correctFormat).toBeUndefined()

    const someTimeRule = dcValidator.getRule('SOME_TIME')
    expect(someTimeRule?.type).toBe('intl-time')
    expect(someTimeRule?.correctFormat).toBeUndefined()

    // Get col with dropdown, check if merging sas data is correct
    const someDropdownRule = dcValidator.getRule('SOME_DROPDOWN')
    // Check  source
    expect(someDropdownRule?.source).toHaveSize(4)
    // Check description
    expect(someDropdownRule?.desc).toEqual('dropdown_desc')
    // Check length
    expect(someDropdownRule?.length).toEqual(128)
    // Check strict - it is softselect so strict should be false
    expect(someDropdownRule?.strict).toBeFalse()

    // Get col with dropdown with 'HARDSELECT' rule
    const someDropdownHardRule = dcValidator.getRule('SOME_DROPDOWN_HARD')
    // Check strict - it is hardselect so strict should be true
    expect(someDropdownHardRule?.strict).toBeTrue()

    // _____DELETE__THIS__RECORD_____ never has a cols[] entry (%mp_getcols
    // doesn't know about it — see DELETE_RECORD_COLUMN_RULE), but its rule
    // must still exist with its fixed dropdown shape, independent of cols
    const deleteRecordRule = dcValidator.getRule(
      '_____DELETE__THIS__RECORD_____'
    )
    expect(deleteRecordRule?.type).toEqual('dropdown')
    expect(deleteRecordRule?.source).toEqual(['No', 'Yes'])
  })

  it('should create an instance of validator and execute its functions', () => {
    const sasparams: SASParam = example_sasparams
    const cols: Col[] = example_cols
    const dqRules: DQRule[] = example_dqRules
    const dqData: DQData[] = example_dqData
    const $dataFormats: $DataFormats = example_dataformats

    const dcValidator: DcValidator = new DcValidator(
      sasparams,
      $dataFormats,
      cols,
      dqRules,
      dqData
    )

    // Get one
    const someNumRules = dcValidator.getRule('SOME_NUM')
    expect(someNumRules?.data).toEqual('SOME_NUM')

    // Update one
    dcValidator.updateRule('SOME_NUM', { desc: 'updated_desc' })
    const someNumUpdated = dcValidator.getRule('SOME_NUM')
    expect(someNumUpdated?.desc).toEqual('updated_desc')

    // Remove one
    dcValidator.removeRule('SOME_TIME')
    expect(dcValidator.getRule('SOME_TIME')).toBeUndefined()

    // Test data quality functions
    expect(dcValidator.getDqDetails()).toHaveSize(dqRules.length)
    expect(dcValidator.getDqDetails('non_existant')).toHaveSize(0)
    expect(dcValidator.getDqDetails('SOME_NUM')).toHaveSize(2)
    expect(dcValidator.isDqCol('SOME_NUM')).toBeTrue()
    expect(dcValidator.isDqCol('non_existant')).toBeFalse()
    expect(
      dcValidator.getDqDropdownSource(dcValidator.getRule('SOME_DROPDOWN')!)
    ).toEqual([
      'Option 1',
      'Option 2',
      'Option 3',
      'This is a long option.  This option is very long.  It is optional, though.'
    ])
  })

  it('should test hot validator', () => {
    const sasparams: SASParam = example_sasparams
    const cols: Col[] = example_cols
    const dqRules: DQRule[] = example_dqRules
    const dqData: DQData[] = example_dqData
    const $dataFormats: $DataFormats = example_dataformats

    const dcValidator: DcValidator = new DcValidator(
      sasparams,
      $dataFormats,
      cols,
      dqRules,
      dqData
    )

    // NOTNULL Validation
    const someNumRule = dcValidator.getRule('SOME_NUM')

    dcValidator.executeHotValidator(someNumRule!, 2, (valid: boolean) => {
      expect(valid).toBeTrue()
    })
    dcValidator.executeHotValidator(someNumRule!, null, (valid: boolean) => {
      expect(valid).toBeFalse()
    })
    dcValidator.executeHotValidator(someNumRule!, '', (valid: boolean) => {
      expect(valid).toBeFalse()
    })
    dcValidator.executeHotValidator(someNumRule!, 'ss', (valid: boolean) => {
      expect(valid).toBeFalse()
    })
    //Special missings
    dcValidator.executeHotValidator(someNumRule!, 's', (valid: boolean) => {
      expect(valid).toBeTrue()
    })
    dcValidator.executeHotValidator(someNumRule!, '.s', (valid: boolean) => {
      expect(valid).toBeTrue()
    })
    dcValidator.executeHotValidator(someNumRule!, '.', (valid: boolean) => {
      expect(valid).toBeTrue()
    })
    dcValidator.executeHotValidator(someNumRule!, '..', (valid: boolean) => {
      expect(valid).toBeFalse()
    })
    dcValidator.executeHotValidator(someNumRule!, '._', (valid: boolean) => {
      expect(valid).toBeTrue()
    })

    // MINVAL, MAXVAL Validation
    const shortNumRule = dcValidator.getRule('SOME_SHORTNUM')

    dcValidator.executeHotValidator(shortNumRule!, 2, (valid: boolean) => {
      expect(valid).toBeTrue()
    })
    dcValidator.executeHotValidator(shortNumRule!, 1, (valid: boolean) => {
      expect(valid).toBeFalse()
    })
    dcValidator.executeHotValidator(shortNumRule!, 4, (valid: boolean) => {
      expect(valid).toBeFalse()
    })
    dcValidator.executeHotValidator(shortNumRule!, 's', (valid: boolean) => {
      expect(valid).toBeFalse() // Special missings are lowest numbers, if any MINVAL is set, special missing is always lower
    })

    // CASE validation
    const someCharRule = dcValidator.getRule('SOME_CHAR')

    dcValidator.executeHotValidator(
      someCharRule!,
      'UPPER',
      (valid: boolean) => {
        expect(valid).toBeTrue()
      }
    )
    dcValidator.executeHotValidator(
      someCharRule!,
      'lower',
      (valid: boolean) => {
        expect(valid).toBeFalse()
      }
    )
    dcValidator.executeHotValidator(
      someCharRule!,
      undefined,
      (valid: boolean) => {
        expect(valid).toBeTrue()
      }
    )
    dcValidator.executeHotValidator(someCharRule!, null, (valid: boolean) => {
      expect(valid).toBeTrue()
    })

    const someCharLowRule = dcValidator.getRule('SOME_CHAR_LOW')

    dcValidator.executeHotValidator(
      someCharLowRule!,
      'lower',
      (valid: boolean) => {
        expect(valid).toBeTrue()
      }
    )

    dcValidator.executeHotValidator(
      someCharLowRule!,
      'UPPER',
      (valid: boolean) => {
        expect(valid).toBeFalse()
      }
    )

    const someCharAnyRule = dcValidator.getRule('SOME_CHAR_ANY')

    dcValidator.executeHotValidator(
      someCharAnyRule!,
      'any',
      (valid: boolean) => {
        expect(valid).toBeTrue()
      }
    )

    dcValidator.executeHotValidator(
      someCharAnyRule!,
      null,
      (valid: boolean) => {
        expect(valid).toBeTrue()
      }
    )

    dcValidator.executeHotValidator(
      someCharAnyRule!,
      undefined,
      (valid: boolean) => {
        expect(valid).toBeTrue()
      }
    )
  })

  it('should apply READONLY, HIDDEN, NUMBER_FORMAT and ROUND rules', () => {
    const dcValidator: DcValidator = new DcValidator(
      example_sasparams,
      example_dataformats,
      example_cols,
      example_dqRules,
      example_dqData
    )

    // READONLY -> column rendered read-only
    expect(dcValidator.getRule('SOME_BESTNUM')?.readOnly).toBeTrue()

    // NUMBER_FORMAT -> a function renderer is assigned and numbro numericFormat
    // is cleared so it can't override the Intl currency/percent rendering
    expect(typeof dcValidator.getRule('SOME_BESTNUM')?.renderer).toEqual(
      'function'
    )
    expect(dcValidator.getRule('SOME_BESTNUM')?.numericFormat).toBeUndefined()

    // HIDDEN -> column index added to hidden columns
    const rules = dcValidator.getRules()
    const hiddenIndex = rules.findIndex(
      (rule) => rule.data === 'PRIMARY_KEY_FIELD'
    )
    expect(dcValidator.getHiddenColumns()).toContain(hiddenIndex)

    // ROUND -> num_digits returned for the column, undefined otherwise
    expect(dcValidator.getRoundDigits('SOME_SHORTNUM')).toEqual(2)
    expect(dcValidator.getRoundDigits('SOME_NUM')).toBeUndefined()
  })

  it('5 | orders rules by COLHEADERS when cols[] arrives in a different order', () => {
    // %mp_getcols/cols1 can serialize cols[] alphabetically by NAME rather
    // than in COLHEADERS order (confirmed against a real getdata.sas
    // response). getRules() is consumed positionally against headerColumns,
    // so rule order must follow COLHEADERS regardless of cols[]'s own order.
    const alphabeticalCols = [...example_cols].sort((a, b) =>
      a.NAME.localeCompare(b.NAME)
    )

    const dcValidator: DcValidator = new DcValidator(
      example_sasparams,
      example_dataformats,
      alphabeticalCols,
      example_dqRules,
      example_dqData
    )

    expect(dcValidator.getRules().map((rule) => rule.data)).toEqual(
      example_sasparams.COLHEADERS.split(',')
    )
  })

  it("6 | keeps rules aligned with COLHEADERS when the PK is not the source table's first column", () => {
    // Regression test for the real-world misalignment: getdata.sas hoists the
    // delete column and PK to the front of the *output* dataset ("order
    // delete var and pk fields at start of table"), so COLHEADERS order and
    // cols[].VARNUM order (which describes the *source* table) diverge
    // whenever the PK isn't already the source's first column. Worst case (as
    // seen in the wild): the PK is the source's LAST column, so pairing rules
    // to headers by VARNUM shifts every single column by one.
    const cols: Col[] = [
      // deliberately alphabetical, as the backend serializes it
      makeCol('PRIMARY_KEY_FIELD', 6), // the PK — last in the source table
      makeCol('SOME_CHAR', 1),
      makeCol('SOME_DATE', 2),
      makeCol('SOME_DROPDOWN', 3),
      makeCol('SOME_NUM', 4),
      makeCol('SOME_TIME', 5)
    ]
    const sasparams: SASParam = {
      ...example_sasparams,
      // PK hoisted to the front by getdata.sas, ahead of VARNUM 1..5
      COLHEADERS:
        '_____DELETE__THIS__RECORD_____,PRIMARY_KEY_FIELD,SOME_CHAR,' +
        'SOME_DATE,SOME_DROPDOWN,SOME_NUM,SOME_TIME',
      PK: 'PRIMARY_KEY_FIELD'
    } as SASParam

    const dcValidator: DcValidator = new DcValidator(
      sasparams,
      { vars: {} } as $DataFormats,
      cols,
      [],
      []
    )

    expect(dcValidator.getRules().map((rule) => rule.data)).toEqual(
      sasparams.COLHEADERS.split(',')
    )
  })

  it('7 | falls back to a text rule rather than dropping a column with an unparseable COLTYPE', () => {
    // A dropped rule would shorten getRules() and shift every later column
    // against headerColumns. getdata.sas LEFT JOINs COLTYPE in, so an
    // unmatched row can legitimately leave it unset.
    const cols: Col[] = [
      makeCol('PRIMARY_KEY_FIELD', 1),
      { ...makeCol('SOME_CHAR', 2), COLTYPE: undefined },
      { ...makeCol('SOME_NUM', 3), COLTYPE: 'not valid json' }
    ]
    const sasparams: SASParam = {
      ...example_sasparams,
      COLHEADERS:
        '_____DELETE__THIS__RECORD_____,PRIMARY_KEY_FIELD,SOME_CHAR,SOME_NUM',
      PK: 'PRIMARY_KEY_FIELD'
    } as SASParam

    const dcValidator: DcValidator = new DcValidator(
      sasparams,
      { vars: {} } as $DataFormats,
      cols,
      [],
      []
    )

    expect(dcValidator.getRules().map((rule) => rule.data)).toEqual(
      sasparams.COLHEADERS.split(',')
    )
  })

  it('8 | does not un-hide an unrelated column when a CLS EDIT column was never hidden', () => {
    // hiddenColumns.indexOf() returns -1 for a column that isn't hidden, and
    // splice(-1, 1) drops the LAST element instead of no-op'ing — so an EDIT
    // column used to silently un-hide a column hidden by a HIDDEN DQ rule.
    //
    // The EDIT column must not be the PK: setColumnLevelSecurity forces PK
    // columns to clsRule 'READ' before the EDIT branch is reached, which would
    // skip the splice entirely and leave this test passing against the bug.
    const cols: Col[] = [
      { ...makeCol('PK_COL', 1), CLS_RULE: 'READ' },
      { ...makeCol('SECRET_COL', 2), CLS_RULE: 'READ' },
      { ...makeCol('OPEN_COL', 3), CLS_RULE: 'EDIT' }
    ]
    const sasparams: SASParam = {
      ...example_sasparams,
      CLS_FLAG: 1,
      COLHEADERS: '_____DELETE__THIS__RECORD_____,PK_COL,SECRET_COL,OPEN_COL',
      PK: 'PK_COL'
    } as SASParam
    const dqRules: DQRule[] = [
      { BASE_COL: 'SECRET_COL', RULE_TYPE: 'HIDDEN', RULE_VALUE: '', X: 0 }
    ]

    const dcValidator: DcValidator = new DcValidator(
      sasparams,
      { vars: {} } as $DataFormats,
      cols,
      dqRules,
      []
    )

    // SECRET_COL is rules[2]; CLS_FLAG also hides the delete column (index 0).
    // OPEN_COL (rules[3]) is EDIT and was never hidden, so the splice guard is
    // what keeps SECRET_COL hidden here.
    expect(dcValidator.getHiddenColumns()).toContain(2)
  })

  describe('9 | ignores cell validation on rows marked for delete', () => {
    // An invalid cell shouldn't block submission if its row
    // is about to be deleted anyway. The PK is exempt from the exemption — it
    // identifies WHICH record to delete, and a blank/invalid PK on a delete
    // row would also slip past the separate uniqueness check (which skips
    // blank PKs).
    //
    // The validator is a plain `function` invoked with cellProperties as
    // `this` (row + instance), so a minimal fake stands in for a real HOT
    // instance here — only `instance.getDataAtRowProp` is read by the guard.
    const fakeCellProps = (markedForDelete: boolean): any => ({
      row: 0,
      editor: undefined,
      instance: {
        getDataAtRowProp: (_row: number, prop: string) =>
          prop === '_____DELETE__THIS__RECORD_____'
            ? markedForDelete
              ? 'Yes'
              : 'No'
            : undefined
      }
    })

    const buildValidator = () =>
      new DcValidator(
        example_sasparams,
        example_dataformats,
        example_cols,
        example_dqRules,
        example_dqData
      )

    it('exempts an invalid non-PK cell on a delete-marked row', () => {
      const dcValidator = buildValidator()
      const someNumRule = dcValidator.getRule('SOME_NUM')

      someNumRule!.validator!.call(
        fakeCellProps(true),
        'not a number',
        (valid: boolean) => {
          expect(valid).toBeTrue()
        }
      )
    })

    it('exempts a NOTNULL-empty non-PK cell on a delete-marked row', () => {
      const dcValidator = buildValidator()
      const someNumRule = dcValidator.getRule('SOME_NUM')

      someNumRule!.validator!.call(
        fakeCellProps(true),
        null,
        (valid: boolean) => {
          expect(valid).toBeTrue()
        }
      )
    })

    it('still rejects an invalid non-PK cell when the row is not marked for delete', () => {
      const dcValidator = buildValidator()
      const someNumRule = dcValidator.getRule('SOME_NUM')

      someNumRule!.validator!.call(
        fakeCellProps(false),
        'not a number',
        (valid: boolean) => {
          expect(valid).toBeFalse()
        }
      )
    })

    it('still accepts a valid non-PK cell when the row is not marked for delete', () => {
      const dcValidator = buildValidator()
      const someNumRule = dcValidator.getRule('SOME_NUM')

      someNumRule!.validator!.call(
        fakeCellProps(false),
        2,
        (valid: boolean) => {
          expect(valid).toBeTrue()
        }
      )
    })

    it('still rejects an invalid PK cell even when the row is marked for delete', () => {
      const dcValidator = buildValidator()
      const pkRule = dcValidator.getRule('PRIMARY_KEY_FIELD')

      pkRule!.validator!.call(
        fakeCellProps(true),
        'not a number',
        (valid: boolean) => {
          expect(valid).toBeFalse()
        }
      )
    })

    it('treats the edit-record modal path (dummy HOT instance, no data) as not marked for delete', () => {
      const dcValidator = buildValidator()
      const someNumRule = dcValidator.getRule('SOME_NUM')

      // executeHotValidator builds cellProperties against a real but
      // data-less Handsontable instance — getDataAtRowProp() there returns
      // undefined, never 'Yes', so the exemption never applies in the modal.
      dcValidator.executeHotValidator(someNumRule!, null, (valid: boolean) => {
        expect(valid).toBeFalse()
      })
    })
  })

  it('10 | blocks submission of a value that fails a HARDREGEX rule', () => {
    // SOME_CHAR already has its own CASE=UPCASE rule in example_dqRules —
    // 'AB1' passes that (it equals its own uppercase form) but fails a
    // letters-only HARDREGEX pattern, isolating HARDREGEX's own effect
    // rather than piggybacking on CASE rejecting the value too.
    const dcValidator: DcValidator = new DcValidator(
      example_sasparams,
      example_dataformats,
      example_cols,
      [
        ...example_dqRules,
        {
          BASE_COL: 'SOME_CHAR',
          RULE_TYPE: 'HARDREGEX',
          RULE_VALUE: '^[A-Z]+$',
          X: 0
        }
      ],
      example_dqData
    )
    const someCharRule = dcValidator.getRule('SOME_CHAR')

    dcValidator.executeHotValidator(someCharRule!, 'ABC', (valid: boolean) => {
      expect(valid).toBeTrue()
    })
    dcValidator.executeHotValidator(someCharRule!, 'AB1', (valid: boolean) => {
      expect(valid).toBeFalse()
    })
  })

  it('11 | wires a function renderer for a HARDREGEX-only rule too (for the REGEX: tooltip)', () => {
    // HARDREGEX blocking itself is covered by test 10 above - this isolates
    // the newer addition: even with no SOFTREGEX at all, a renderer must
    // still be wired so a failing cell gets a 'REGEX: <pattern>' title on
    // top of HOT's own red htInvalid, not just silence.
    const dcValidator: DcValidator = new DcValidator(
      example_sasparams,
      example_dataformats,
      example_cols,
      [
        ...example_dqRules,
        {
          BASE_COL: 'SOME_CHAR_ANY',
          RULE_TYPE: 'HARDREGEX',
          RULE_VALUE: '^[A-Z]+$',
          X: 0
        }
      ],
      example_dqData
    )
    const someCharAnyRule = dcValidator.getRule('SOME_CHAR_ANY')

    expect(typeof someCharAnyRule?.renderer).toEqual('function')
  })

  it('12 | wires a function renderer for a SOFTREGEX rule, without blocking submission', () => {
    // SOME_CHAR_ANY carries no other DQ rules in the shared fixture, so this
    // isolates SOFTREGEX's own wiring. The renderer's own pass/fail/delete-
    // suppression behaviour is covered by regex-warning-renderer.spec.ts —
    // this only proves setupValidations() assigns it and that, unlike
    // HARDREGEX, a non-matching value still submits.
    const dcValidator: DcValidator = new DcValidator(
      example_sasparams,
      example_dataformats,
      example_cols,
      [
        ...example_dqRules,
        {
          BASE_COL: 'SOME_CHAR_ANY',
          RULE_TYPE: 'SOFTREGEX',
          RULE_VALUE: '^[A-Z]+$',
          X: 0
        }
      ],
      example_dqData
    )
    const someCharAnyRule = dcValidator.getRule('SOME_CHAR_ANY')

    expect(typeof someCharAnyRule?.renderer).toEqual('function')

    dcValidator.executeHotValidator(
      someCharAnyRule!,
      'not uppercase',
      (valid: boolean) => {
        expect(valid).toBeTrue()
      }
    )
  })

  it('13 | wires a renderer for a dual-rule column, and HARDREGEX still blocks submission', () => {
    // Both rules on the same column: submission blocking is still governed
    // entirely by HARDREGEX/dqValidate (unchanged). A renderer is wired so
    // the cell gets a 'REGEX: <pattern>' title - its own hard-vs-soft
    // precedence and coloring are covered by regex-warning-renderer.spec.ts.
    const dcValidator: DcValidator = new DcValidator(
      example_sasparams,
      example_dataformats,
      example_cols,
      [
        ...example_dqRules,
        {
          BASE_COL: 'SOME_CHAR_ANY',
          RULE_TYPE: 'HARDREGEX',
          RULE_VALUE: '^[A-Z]+$',
          X: 0
        },
        {
          BASE_COL: 'SOME_CHAR_ANY',
          RULE_TYPE: 'SOFTREGEX',
          RULE_VALUE: '^[A-Z]+$',
          X: 0
        }
      ],
      example_dqData
    )
    const rule = dcValidator.getRule('SOME_CHAR_ANY')

    dcValidator.executeHotValidator(
      rule!,
      'not uppercase',
      (valid: boolean) => {
        expect(valid).toBeFalse()
      }
    )
    expect(typeof rule?.renderer).toEqual('function')
  })

  describe('14 | failsSoftRegex (edit-record modal support for SOFTREGEX)', () => {
    // SOFTREGEX never goes through dqValidate (see the wiring in
    // setupValidations), so the edit-record modal — which has no grid
    // renderer to hook into — calls this directly to show the same warning.
    const buildValidator = (dqRules: DQRule[]) =>
      new DcValidator(
        example_sasparams,
        example_dataformats,
        example_cols,
        dqRules,
        example_dqData
      )

    it('is true for a value that fails the pattern', () => {
      const dcValidator = buildValidator([
        {
          BASE_COL: 'SOME_CHAR_ANY',
          RULE_TYPE: 'SOFTREGEX',
          RULE_VALUE: '^[A-Z]+$',
          X: 0
        }
      ])

      expect(
        dcValidator.failsSoftRegex('SOME_CHAR_ANY', 'lowercase')
      ).toBeTrue()
    })

    it('is false for a value that matches the pattern', () => {
      const dcValidator = buildValidator([
        {
          BASE_COL: 'SOME_CHAR_ANY',
          RULE_TYPE: 'SOFTREGEX',
          RULE_VALUE: '^[A-Z]+$',
          X: 0
        }
      ])

      expect(
        dcValidator.failsSoftRegex('SOME_CHAR_ANY', 'UPPERCASE')
      ).toBeFalse()
    })

    it('handles a PRX-delimited pattern with a case-insensitive flag, as authored for prxparse', () => {
      const dcValidator = buildValidator([
        {
          BASE_COL: 'SOME_CHAR_ANY',
          RULE_TYPE: 'SOFTREGEX',
          RULE_VALUE: '/\\b(the|data)\\b/i',
          X: 0
        }
      ])

      expect(
        dcValidator.failsSoftRegex('SOME_CHAR_ANY', 'this is dummy data')
      ).toBeFalse()
      expect(
        dcValidator.failsSoftRegex('SOME_CHAR_ANY', 'THE WIND WAS BLOWING')
      ).toBeFalse()
      expect(
        dcValidator.failsSoftRegex('SOME_CHAR_ANY', 'nothing relevant here')
      ).toBeTrue()
    })

    it('is false for a column with no SOFTREGEX rule', () => {
      const dcValidator = buildValidator([])

      expect(
        dcValidator.failsSoftRegex('SOME_CHAR_ANY', 'anything')
      ).toBeFalse()
    })

    it('is false for blank/special-missing values, same exemption as HARDREGEX', () => {
      const dcValidator = buildValidator([
        {
          BASE_COL: 'SOME_CHAR_ANY',
          RULE_TYPE: 'SOFTREGEX',
          RULE_VALUE: '^[A-Z]+$',
          X: 0
        }
      ])

      expect(dcValidator.failsSoftRegex('SOME_CHAR_ANY', '')).toBeFalse()
      expect(dcValidator.failsSoftRegex('SOME_CHAR_ANY', '.a')).toBeFalse()
    })

    it('is false when a value fails both HARDREGEX and SOFTREGEX (precedence — no yellow on a red cell)', () => {
      const dcValidator = buildValidator([
        {
          BASE_COL: 'SOME_CHAR_ANY',
          RULE_TYPE: 'HARDREGEX',
          RULE_VALUE: '^[A-Z]{3}$',
          X: 0
        },
        {
          BASE_COL: 'SOME_CHAR_ANY',
          RULE_TYPE: 'SOFTREGEX',
          RULE_VALUE: '^.{5,10}$',
          X: 0
        }
      ])

      // 'ab' fails both HARDREGEX (not 3 uppercase letters) and SOFTREGEX
      // (too short) - HARDREGEX wins, so this must stay false rather than
      // report a SOFTREGEX warning on a value that's already blocked.
      expect(dcValidator.failsSoftRegex('SOME_CHAR_ANY', 'ab')).toBeFalse()
    })

    it('is true when a value passes HARDREGEX but fails SOFTREGEX', () => {
      const dcValidator = buildValidator([
        {
          BASE_COL: 'SOME_CHAR_ANY',
          RULE_TYPE: 'HARDREGEX',
          RULE_VALUE: '^[A-Z0-9]+$',
          X: 0
        },
        {
          BASE_COL: 'SOME_CHAR_ANY',
          RULE_TYPE: 'SOFTREGEX',
          RULE_VALUE: '^.{5,10}$',
          X: 0
        }
      ])

      // 'AB' passes HARDREGEX (uppercase/digits) but fails SOFTREGEX (too
      // short) -
      // evaluated independently once HARDREGEX passes.
      expect(dcValidator.failsSoftRegex('SOME_CHAR_ANY', 'AB')).toBeTrue()
    })
  })

  describe('15 | getRegexRuleValues (column-header info display)', () => {
    const buildValidator = (dqRules: DQRule[]) =>
      new DcValidator(
        example_sasparams,
        example_dataformats,
        example_cols,
        dqRules,
        example_dqData
      )

    it('returns only hardRegexValue for a column with just HARDREGEX', () => {
      const dcValidator = buildValidator([
        {
          BASE_COL: 'SOME_CHAR_ANY',
          RULE_TYPE: 'HARDREGEX',
          RULE_VALUE: '^[A-Z]+$',
          X: 0
        }
      ])

      expect(dcValidator.getRegexRuleValues('SOME_CHAR_ANY')).toEqual({
        hardRegexValue: '^[A-Z]+$',
        softRegexValue: undefined
      })
    })

    it('returns only softRegexValue for a column with just SOFTREGEX', () => {
      const dcValidator = buildValidator([
        {
          BASE_COL: 'SOME_CHAR_ANY',
          RULE_TYPE: 'SOFTREGEX',
          RULE_VALUE: '/\\b(the|data)\\b/i',
          X: 0
        }
      ])

      expect(dcValidator.getRegexRuleValues('SOME_CHAR_ANY')).toEqual({
        hardRegexValue: undefined,
        softRegexValue: '/\\b(the|data)\\b/i'
      })
    })

    it('returns both values for a column with both HARDREGEX and SOFTREGEX', () => {
      const dcValidator = buildValidator([
        {
          BASE_COL: 'SOME_CHAR_ANY',
          RULE_TYPE: 'HARDREGEX',
          RULE_VALUE: '^HARD$',
          X: 0
        },
        {
          BASE_COL: 'SOME_CHAR_ANY',
          RULE_TYPE: 'SOFTREGEX',
          RULE_VALUE: '^SOFT$',
          X: 0
        }
      ])

      expect(dcValidator.getRegexRuleValues('SOME_CHAR_ANY')).toEqual({
        hardRegexValue: '^HARD$',
        softRegexValue: '^SOFT$'
      })
    })

    it('returns both undefined for a column with no regex rule', () => {
      const dcValidator = buildValidator([])

      expect(dcValidator.getRegexRuleValues('SOME_CHAR_ANY')).toEqual({
        hardRegexValue: undefined,
        softRegexValue: undefined
      })
    })
  })
})

/** Minimal cols[] entry — only the fields rule ordering depends on. */
const makeCol = (name: string, varnum: number): Col =>
  ({
    NAME: name,
    VARNUM: varnum,
    LABEL: name,
    FMTNAME: '',
    DDTYPE: 'C',
    TYPE: '',
    CLS_RULE: 'READ',
    MEMLABEL: '',
    DESC: '',
    LONGDESC: '',
    COLTYPE: `{"data":"${name}"}`
  }) as Col

const example_dqData = [
  {
    BASE_COL: 'SOME_DROPDOWN',
    RULE_VALUE: 'SOME_DROPDOWN',
    RULE_DATA: 'Option 1',
    SELECTBOX_ORDER: 1
  },
  {
    BASE_COL: 'SOME_DROPDOWN',
    RULE_VALUE: 'SOME_DROPDOWN',
    RULE_DATA: 'Option 2',
    SELECTBOX_ORDER: 2
  },
  {
    BASE_COL: 'SOME_DROPDOWN',
    RULE_VALUE: 'SOME_DROPDOWN',
    RULE_DATA: 'Option 3',
    SELECTBOX_ORDER: 2
  },
  {
    BASE_COL: 'SOME_DROPDOWN',
    RULE_VALUE: 'SOME_DROPDOWN',
    RULE_DATA:
      'This is a long option.  This option is very long.  It is optional, though.',
    SELECTBOX_ORDER: 3
  }
]

const example_dqRules: any = [
  {
    BASE_COL: 'SOME_NUM',
    RULE_TYPE: 'NOTNULL',
    RULE_VALUE: ' ',
    X: 0
  },
  {
    BASE_COL: 'SOME_NUM',
    RULE_TYPE: 'HARDSELECT_HOOK',
    RULE_VALUE: 'services/validations/mpe_x_test.some_num',
    X: 0
  },
  {
    BASE_COL: 'SOME_SHORTNUM',
    RULE_TYPE: 'MINVAL',
    RULE_VALUE: '2',
    X: 0
  },
  {
    BASE_COL: 'SOME_DROPDOWN_HARD', // It contains both HARD and SOFTSELECT rules, HARDSELECT has the priority
    RULE_TYPE: 'HARDSELECT',
    RULE_VALUE: ' ',
    X: 0
  },
  {
    BASE_COL: 'SOME_DROPDOWN_HARD', // It contains both HARD and SOFTSELECT rules, HARDSELECT has the priority
    RULE_TYPE: 'SOFTSELECT',
    RULE_VALUE: ' ',
    X: 0
  },
  {
    BASE_COL: 'SOME_SHORTNUM',
    RULE_TYPE: 'MAXVAL',
    RULE_VALUE: '3',
    X: 0
  },
  {
    BASE_COL: 'SOME_CHAR',
    RULE_TYPE: 'CASE',
    RULE_VALUE: 'UPCASE',
    X: 0
  },
  {
    BASE_COL: 'SOME_CHAR_LOW',
    RULE_TYPE: 'CASE',
    RULE_VALUE: 'LOWCASE',
    X: 0
  },
  {
    BASE_COL: 'SOME_BESTNUM',
    RULE_TYPE: 'READONLY',
    RULE_VALUE: '7',
    X: 0
  },
  {
    BASE_COL: 'SOME_BESTNUM',
    RULE_TYPE: 'NUMBER_FORMAT',
    RULE_VALUE: '{"minimumFractionDigits":2}',
    X: 0
  },
  {
    BASE_COL: 'PRIMARY_KEY_FIELD',
    RULE_TYPE: 'HIDDEN',
    RULE_VALUE: '99',
    X: 0
  },
  {
    BASE_COL: 'SOME_SHORTNUM',
    RULE_TYPE: 'ROUND',
    RULE_VALUE: '2',
    X: 0
  }
]

// One row per column, each carrying its own COLTYPE JSON-object string — the
// vertical-array shape from issue #253's getdata.sas fix. Order: SOME_DROPDOWN
// then SOME_NUM first (the `cols[0].TYPE` assertion above is positional),
// remaining columns in the same order the old concatenated
// sasparams.COLTYPE used to list them. _____DELETE__THIS__RECORD_____ is
// deliberately absent — DcValidator injects its rule directly (see
// DELETE_RECORD_COLUMN_RULE), since %mp_getcols never has a row for it.
const example_cols = [
  {
    CLS_RULE: 'READ',
    DDTYPE: 'C',
    DESC: 'dropdown_desc',
    TYPE: '',
    FMTNAME: '',
    LABEL: 'SOME_DROPDOWN',
    LONGDESC: '',
    MEMLABEL: '',
    NAME: 'SOME_DROPDOWN',
    VARNUM: 3,
    COLTYPE: '{"data":"SOME_DROPDOWN"}'
  },
  {
    CLS_RULE: 'READ',
    DDTYPE: 'N',
    DESC: '',
    TYPE: '',
    FMTNAME: '',
    LABEL: 'SOME_NUM',
    LONGDESC: '',
    MEMLABEL: '',
    NAME: 'SOME_NUM',
    VARNUM: 4,
    COLTYPE: '{"data":"SOME_NUM","type":"numeric","format":"0"}'
  },
  {
    CLS_RULE: 'READ',
    DDTYPE: 'N',
    DESC: '',
    TYPE: '',
    FMTNAME: '',
    LABEL: 'PRIMARY_KEY_FIELD',
    LONGDESC: '',
    MEMLABEL: '',
    NAME: 'PRIMARY_KEY_FIELD',
    VARNUM: 1,
    COLTYPE: '{"data":"PRIMARY_KEY_FIELD","type":"numeric","format":"0"}'
  },
  {
    CLS_RULE: 'READ',
    DDTYPE: 'C',
    DESC: '',
    TYPE: '',
    FMTNAME: '',
    LABEL: 'SOME_CHAR',
    LONGDESC: '',
    MEMLABEL: '',
    NAME: 'SOME_CHAR',
    VARNUM: 2,
    COLTYPE: '{"data":"SOME_CHAR"}'
  },
  {
    CLS_RULE: 'READ',
    DDTYPE: 'C',
    DESC: '',
    TYPE: '',
    FMTNAME: '',
    LABEL: 'SOME_CHAR_LOW',
    LONGDESC: '',
    MEMLABEL: '',
    NAME: 'SOME_CHAR_LOW',
    VARNUM: 5,
    COLTYPE: '{"data":"SOME_CHAR_LOW"}'
  },
  {
    CLS_RULE: 'READ',
    DDTYPE: 'C',
    DESC: '',
    TYPE: '',
    FMTNAME: '',
    LABEL: 'SOME_CHAR_ANY',
    LONGDESC: '',
    MEMLABEL: '',
    NAME: 'SOME_CHAR_ANY',
    VARNUM: 6,
    COLTYPE: '{"data":"SOME_CHAR_ANY"}'
  },
  {
    CLS_RULE: 'READ',
    DDTYPE: 'C',
    DESC: '',
    TYPE: '',
    FMTNAME: '',
    LABEL: 'SOME_DROPDOWN_HARD',
    LONGDESC: '',
    MEMLABEL: '',
    NAME: 'SOME_DROPDOWN_HARD',
    VARNUM: 7,
    COLTYPE: '{"data":"SOME_DROPDOWN_HARD"}'
  },
  {
    CLS_RULE: 'READ',
    DDTYPE: 'DATE',
    DESC: '',
    TYPE: '',
    FMTNAME: '',
    LABEL: 'SOME_DATE',
    LONGDESC: '',
    MEMLABEL: '',
    NAME: 'SOME_DATE',
    VARNUM: 8,
    COLTYPE: '{"data":"SOME_DATE","type":"date"}'
  },
  {
    CLS_RULE: 'READ',
    DDTYPE: 'DATETIME',
    DESC: '',
    TYPE: '',
    FMTNAME: '',
    LABEL: 'SOME_DATETIME',
    LONGDESC: '',
    MEMLABEL: '',
    NAME: 'SOME_DATETIME',
    VARNUM: 9,
    COLTYPE: '{"data":"SOME_DATETIME","type":"datetime"}'
  },
  {
    CLS_RULE: 'READ',
    DDTYPE: 'TIME',
    DESC: '',
    TYPE: '',
    FMTNAME: '',
    LABEL: 'SOME_TIME',
    LONGDESC: '',
    MEMLABEL: '',
    NAME: 'SOME_TIME',
    VARNUM: 10,
    COLTYPE: '{"data":"SOME_TIME","type":"time"}'
  },
  {
    CLS_RULE: 'READ',
    DDTYPE: 'N',
    DESC: '',
    TYPE: '',
    FMTNAME: '',
    LABEL: 'SOME_SHORTNUM',
    LONGDESC: '',
    MEMLABEL: '',
    NAME: 'SOME_SHORTNUM',
    VARNUM: 11,
    COLTYPE: '{"data":"SOME_SHORTNUM","type":"numeric","format":"0"}'
  },
  {
    CLS_RULE: 'READ',
    DDTYPE: 'N',
    DESC: '',
    TYPE: '',
    FMTNAME: '',
    LABEL: 'SOME_BESTNUM',
    LONGDESC: '',
    MEMLABEL: '',
    NAME: 'SOME_BESTNUM',
    VARNUM: 12,
    COLTYPE: '{"data":"SOME_BESTNUM","type":"numeric","format":"0"}'
  }
]

// COLHEADERS mirrors a real getdata.sas response: the *output* dataset's
// variable list, i.e. the delete column and PK hoisted to the front, then the
// remaining source columns in VARNUM order. It drives rule order, so it must
// list every example_cols entry.
const example_sasparams = {
  CLS_FLAG: 0,
  COLHEADERS:
    '_____DELETE__THIS__RECORD_____,PRIMARY_KEY_FIELD,SOME_CHAR,SOME_DROPDOWN,' +
    'SOME_NUM,SOME_CHAR_LOW,SOME_CHAR_ANY,SOME_DROPDOWN_HARD,SOME_DATE,' +
    'SOME_DATETIME,SOME_TIME,SOME_SHORTNUM,SOME_BESTNUM',
  DTTMVARS: 'dttm vars',
  DTVARS: 'dt vars',
  FILTER_TEXT: 'filter text',
  LOADTYPE: 'load type',
  PK: 'PRIMARY_KEY_FIELD',
  PKCNT: 1,
  RK_FLAG: 0,
  TMVARS: 'string'
}

const example_dataformats = {
  vars: {
    _____DELETE__THIS__RECORD_____: {
      format: '$3.',
      label: '_____DELETE__THIS__RECORD_____',
      length: '3',
      type: 'char'
    },
    PRIMARY_KEY_FIELD: {
      format: 'best.',
      label: 'PRIMARY_KEY_FIELD',
      length: '8',
      type: 'num'
    },
    SOME_CHAR: {
      format: '$32767.',
      label: 'SOME_CHAR',
      length: '32767',
      type: 'char'
    },
    SOME_DROPDOWN: {
      format: '$128.',
      label: 'SOME_DROPDOWN',
      length: '128',
      type: 'char'
    },
    SOME_CHAR_LOW: {
      format: '$128.',
      label: 'SOME_CHAR_LOW',
      length: '128',
      type: 'char'
    },
    SOME_CHAR_ANY: {
      format: '$128.',
      label: 'SOME_CHAR_ANY',
      length: '128',
      type: 'char'
    },
    SOME_DROPDOWN_HARD: {
      format: '$128.',
      label: 'SOME_DROPDOWN_HARD',
      length: '128',
      type: 'char'
    },
    SOME_NUM: {
      format: 'best.',
      label: 'SOME_NUM',
      length: '8',
      type: 'num'
    },
    SOME_DATE: {
      format: '$200.',
      label: 'SOME_DATE',
      length: '200',
      type: 'char'
    },
    SOME_DATETIME: {
      format: '$200.',
      label: 'SOME_DATETIME',
      length: '200',
      type: 'char'
    },
    SOME_TIME: {
      format: '$200.',
      label: 'SOME_TIME',
      length: '200',
      type: 'char'
    },
    SOME_SHORTNUM: {
      format: 'best.',
      label: 'SOME_SHORTNUM',
      length: '4',
      type: 'num'
    },
    SOME_BESTNUM: {
      format: 'BEST.',
      label: 'SOME_BESTNUM',
      length: '8',
      type: 'num'
    }
  }
}
