import {
  CramArgumentError,
  CramBufferOverrunError,
  CramMalformedError,
} from '../../errors.ts'
import ByteArrayStopCodec from '../codecs/byteArrayStop.ts'
import ExternalCodec, {
  batchDecodeItf8,
  parseItf8,
} from '../codecs/external.ts'
import Constants from '../constants.ts'
import decodeRecord, { buildRFSchema } from './decodeRecord.ts'
import { dataSeriesTypes } from '../container/compressionScheme.ts'
import { type CramFileBlock } from '../file.ts'
import CramRecord, { defaultDecodeOptions } from '../record.ts'
import { getSectionParsers, isMappedSliceHeader } from '../sectionParsers.ts'
import { decodeUtf8, parseItem, sequenceMD5 } from '../util.ts'

import type { DecodeOptions } from '../record.ts'
import type {
  MappedSliceHeader,
  UnmappedSliceHeader,
} from '../sectionParsers.ts'
import type { BoundDecoders, BulkByteRawDecoder } from './decodeRecord.ts'
import type { Cursor, Cursors, PreDecodedIntBlock } from '../codecs/_base.ts'
import type { DataSeriesEncodingKey } from '../codecs/dataSeriesTypes.ts'
import type CramContainer from '../container/index.ts'
import type { CramEncoding } from '../encoding.ts'
import type CramFile from '../file.ts'

// shared zero-length sentinel returned by bound tag decoders when length=0
const EMPTY_BYTES = new Uint8Array(0)

export type SliceHeader = CramFileBlock & {
  parsedContent: MappedSliceHeader | UnmappedSliceHeader
}

interface RefRegion {
  id: number
  start: number
  end: number
  seq: string | null
}

/**
 * Try to estimate the template length from a bunch of interrelated
 * multi-segment reads.
 */
function calculateMultiSegmentMatedTemplateLength(
  allRecords: CramRecord[],
  _currentRecordNumber: number,
  thisRecord: CramRecord,
) {
  const matedRecords: CramRecord[] = [thisRecord]
  let cur = thisRecord
  while (cur.mateRecordNumber !== undefined && cur.mateRecordNumber >= 0) {
    const mateRecord = allRecords[cur.mateRecordNumber]
    if (!mateRecord) {
      throw new CramMalformedError(
        'intra-slice mate record not found, this file seems malformed',
      )
    }
    matedRecords.push(mateRecord)
    cur = mateRecord
  }

  let minStart = matedRecords[0]!.alignmentStart
  let maxEnd = minStart + matedRecords[0]!.readLength - 1
  for (let i = 1; i < matedRecords.length; i++) {
    const r = matedRecords[i]!
    if (r.alignmentStart < minStart) {
      minStart = r.alignmentStart
    }
    const end = r.alignmentStart + r.readLength - 1
    if (end > maxEnd) {
      maxEnd = end
    }
  }
  const estimatedTemplateLength = maxEnd - minStart + 1
  if (estimatedTemplateLength >= 0) {
    matedRecords.forEach(r => {
      if (r.templateLength !== undefined) {
        throw new CramMalformedError(
          'mate pair group has some members that have template lengths already, this file seems malformed',
        )
      }
      // sign per SAM spec: positive for leftmost, negative for rightmost
      r.templateLength =
        r.alignmentStart === minStart
          ? estimatedTemplateLength
          : -estimatedTemplateLength
    })
  }
}

/**
 * Attempt to calculate the `templateLength` for a pair of intra-slice paired
 * reads. Ported from htslib. Algorithm is imperfect.
 */
function calculateIntraSliceMatePairTemplateLength(
  thisRecord: CramRecord,
  mateRecord: CramRecord,
) {
  // this just estimates the template length by using the simple (non-gapped)
  // end coordinate of each read, because gapping in the alignment doesn't mean
  // the template is longer or shorter
  const start = Math.min(thisRecord.alignmentStart, mateRecord.alignmentStart)
  const end = Math.max(
    thisRecord.alignmentStart + thisRecord.readLength - 1,
    mateRecord.alignmentStart + mateRecord.readLength - 1,
  )
  const lengthEstimate = end - start + 1
  // sign per SAM spec: positive for leftmost, negative for rightmost
  thisRecord.templateLength =
    thisRecord.alignmentStart <= mateRecord.alignmentStart
      ? lengthEstimate
      : -lengthEstimate
  mateRecord.templateLength =
    mateRecord.alignmentStart <= thisRecord.alignmentStart
      ? lengthEstimate
      : -lengthEstimate
}

/**
 * establishes a mate-pair relationship between two records in the same slice.
 * CRAM compresses mate-pair relationships between records in the same slice
 * down into just one record having the index in the slice of its mate
 */
function associateIntraSliceMate(
  allRecords: CramRecord[],
  currentRecordNumber: number,
  thisRecord: CramRecord,
  mateRecord: CramRecord,
) {
  const complicatedMultiSegment = !!(
    mateRecord.mate ||
    (mateRecord.mateRecordNumber !== undefined &&
      mateRecord.mateRecordNumber !== currentRecordNumber)
  )

  // Deal with lossy read names — assign a synthetic name from uniqueId
  // so that paired records share the same name
  if (!thisRecord.readName) {
    const syntheticName = String(thisRecord.uniqueId)
    thisRecord._syntheticReadName = syntheticName
    mateRecord._syntheticReadName = syntheticName
  }

  thisRecord.mate = {
    sequenceId: mateRecord.sequenceId,
    alignmentStart: mateRecord.alignmentStart,
    uniqueId: mateRecord.uniqueId,
  }
  if (mateRecord.readName) {
    thisRecord.mate.readName = mateRecord.readName
  }

  // the mate record might have its own mate pointer, if this is some kind of
  // multi-segment (more than paired) scheme, so only relate that one back to this one
  // if it does not have any other relationship
  if (!mateRecord.mate && mateRecord.mateRecordNumber === undefined) {
    mateRecord.mate = {
      sequenceId: thisRecord.sequenceId,
      alignmentStart: thisRecord.alignmentStart,
      uniqueId: thisRecord.uniqueId,
    }
    if (thisRecord.readName) {
      mateRecord.mate.readName = thisRecord.readName
    }
  }

  // make sure the proper flags and cramFlags are set on both records
  // paired
  thisRecord.flags |= Constants.BAM_FPAIRED

  // set mate unmapped if needed
  if (mateRecord.flags & Constants.BAM_FUNMAP) {
    thisRecord.flags |= Constants.BAM_FMUNMAP
    // thisRecord.templateLength = 0
  }
  if (thisRecord.flags & Constants.BAM_FUNMAP) {
    // thisRecord.templateLength = 0
    mateRecord.flags |= Constants.BAM_FMUNMAP
  }

  // set mate reversed if needed
  if (mateRecord.flags & Constants.BAM_FREVERSE) {
    thisRecord.flags |= Constants.BAM_FMREVERSE
  }
  if (thisRecord.flags & Constants.BAM_FREVERSE) {
    mateRecord.flags |= Constants.BAM_FMREVERSE
  }

  if (thisRecord.templateLength === undefined) {
    if (complicatedMultiSegment) {
      calculateMultiSegmentMatedTemplateLength(
        allRecords,
        currentRecordNumber,
        thisRecord,
      )
    } else {
      calculateIntraSliceMatePairTemplateLength(thisRecord, mateRecord)
    }
  }

  // delete this last because it's used by the
  // complicated template length estimation
  thisRecord.mateRecordNumber = undefined
}

export default class CramSlice {
  private file: CramFile
  container: CramContainer
  containerPosition: number
  sliceSize: number
  private _headerResult?: ReturnType<CramSlice['_fetchHeader']>
  private _blocksResult?: ReturnType<CramSlice['_fetchBlocks']>
  private _blocksContentIdIndexResult?: ReturnType<
    CramSlice['_fetchBlocksContentIdIndex']
  >

  constructor(
    container: CramContainer,
    containerPosition: number,
    sliceSize: number,
  ) {
    this.file = container.file
    this.container = container
    this.containerPosition = containerPosition
    this.sliceSize = sliceSize
  }

  getHeader() {
    if (this._headerResult === undefined) {
      this._headerResult = this._fetchHeader()
      this._headerResult.catch(() => {
        this._headerResult = undefined
      })
    }
    return this._headerResult
  }

  private async _fetchHeader() {
    // fetch and parse the slice header
    const { majorVersion } = await this.file.getDefinition()
    const sectionParsers = getSectionParsers(majorVersion)
    const containerHeader = await this.container.getHeader()

    const header = await this.file.readBlock(
      containerHeader._endPosition + this.containerPosition,
    )
    const parser =
      header.contentType === 'MAPPED_SLICE_HEADER'
        ? sectionParsers.cramMappedSliceHeader.parser
        : header.contentType === 'UNMAPPED_SLICE_HEADER'
          ? sectionParsers.cramUnmappedSliceHeader.parser
          : undefined
    if (parser) {
      const content = parseItem(
        header.content,
        parser,
        0,
        containerHeader._endPosition,
      )
      return { ...header, parsedContent: content }
    } else {
      throw new CramMalformedError(
        `error reading slice header block, invalid content type ${header.contentType}`,
      )
    }
  }

  getBlocks() {
    if (this._blocksResult === undefined) {
      this._blocksResult = this._fetchBlocks()
      this._blocksResult.catch(() => {
        this._blocksResult = undefined
      })
    }
    return this._blocksResult
  }

  private async _fetchBlocks() {
    const header = await this.getHeader()

    if (this.sliceSize) {
      // if we know the slice size (from the index), do one big read for all
      // blocks and parse from the in-memory buffer
      const containerHeader = await this.container.getHeader()
      const sliceFilePosition =
        containerHeader._endPosition + this.containerPosition
      const blocksFilePosition = header._endPosition
      const headerSize = blocksFilePosition - sliceFilePosition
      const remainingBytes = this.sliceSize - headerSize

      const allBlocksBuffer = await this.file.read(
        remainingBytes,
        blocksFilePosition,
      )

      const blocks: CramFileBlock[] = new Array(header.parsedContent.numBlocks)
      let bufferOffset = 0
      for (let i = 0; i < blocks.length; i++) {
        const block = await this.file.readBlockFromBuffer(
          allBlocksBuffer,
          bufferOffset,
          blocksFilePosition + bufferOffset,
        )
        blocks[i] = block
        bufferOffset = block._endPosition - blocksFilePosition
      }
      return blocks
    }

    // fallback: read blocks one at a time (non-indexed access)
    let blockPosition = header._endPosition
    const blocks: CramFileBlock[] = new Array(header.parsedContent.numBlocks)
    for (let i = 0; i < blocks.length; i++) {
      const block = await this.file.readBlock(blockPosition)
      blocks[i] = block
      blockPosition = block._endPosition
    }
    return blocks
  }

  // no memoize
  async getCoreDataBlock() {
    const blocks = await this.getBlocks()
    return blocks[0]
  }

  _getBlocksContentIdIndex() {
    if (this._blocksContentIdIndexResult === undefined) {
      this._blocksContentIdIndexResult = this._fetchBlocksContentIdIndex()
      this._blocksContentIdIndexResult.catch(() => {
        this._blocksContentIdIndexResult = undefined
      })
    }
    return this._blocksContentIdIndexResult
  }

  private async _fetchBlocksContentIdIndex(): Promise<
    Record<number, CramFileBlock>
  > {
    const blocks = await this.getBlocks()
    const blocksByContentId: Record<number, CramFileBlock> = {}
    blocks.forEach(block => {
      if (block.contentType === 'EXTERNAL_DATA') {
        blocksByContentId[block.contentId] = block
      }
    })
    return blocksByContentId
  }

  async getBlockByContentId(id: number) {
    const blocksByContentId = await this._getBlocksContentIdIndex()
    return blocksByContentId[id]
  }

  async getReferenceRegion() {
    // read the slice header
    const sliceHeader = (await this.getHeader()).parsedContent
    if (!isMappedSliceHeader(sliceHeader)) {
      throw new Error('slice header not mapped')
    }

    if (sliceHeader.refSeqId < 0) {
      return undefined
    }

    const compressionScheme = await this.container.getCompressionScheme()
    if (compressionScheme === undefined) {
      throw new Error('compression scheme undefined')
    }

    if (sliceHeader.refBaseBlockId >= 0) {
      const refBlock = await this.getBlockByContentId(
        sliceHeader.refBaseBlockId,
      )
      if (!refBlock) {
        throw new CramMalformedError(
          'embedded reference specified, but reference block does not exist',
        )
      }

      // TODO: we do not read anything named 'span'
      // if (sliceHeader.span > refBlock.uncompressedSize) {
      //   throw new CramMalformedError('Embedded reference is too small')
      // }

      // TODO verify
      return {
        seq: decodeUtf8(refBlock.content),
        start: sliceHeader.refSeqStart,
        end: sliceHeader.refSeqStart + sliceHeader.refSeqSpan - 1,
        span: sliceHeader.refSeqSpan,
      }
    }
    if (
      compressionScheme.referenceRequired ||
      this.file.fetchReferenceSequenceCallback
    ) {
      if (!this.file.fetchReferenceSequenceCallback) {
        throw new Error(
          'reference sequence not embedded, and seqFetch callback not provided, cannot fetch reference sequence',
        )
      }

      const seq = await this.file.fetchReferenceSequenceCallback(
        sliceHeader.refSeqId,
        sliceHeader.refSeqStart,
        sliceHeader.refSeqStart + sliceHeader.refSeqSpan - 1,
      )

      if (seq.length !== sliceHeader.refSeqSpan) {
        throw new CramArgumentError(
          'seqFetch callback returned a reference sequence of the wrong length',
        )
      }

      return {
        seq,
        start: sliceHeader.refSeqStart,
        end: sliceHeader.refSeqStart + sliceHeader.refSeqSpan - 1,
        span: sliceHeader.refSeqSpan,
      }
    }

    return undefined
  }

  getAllRecords() {
    return this.getRecords(() => true)
  }

  async _fetchRecords(decodeOptions: Required<DecodeOptions>) {
    const { majorVersion } = await this.file.getDefinition()

    const compressionScheme = await this.container.getCompressionScheme()
    if (compressionScheme === undefined) {
      throw new Error('compression scheme undefined')
    }

    const sliceHeader = await this.getHeader()
    const blocksByContentId = await this._getBlocksContentIdIndex()

    // check MD5 of reference if available
    if (
      majorVersion > 1 &&
      this.file.options.checkSequenceMD5 &&
      isMappedSliceHeader(sliceHeader.parsedContent) &&
      sliceHeader.parsedContent.refSeqId >= 0 &&
      sliceHeader.parsedContent.md5?.join('') !== '0000000000000000'
    ) {
      const refRegion = await this.getReferenceRegion()
      if (refRegion) {
        const { seq, start, end } = refRegion
        const seqMd5 = sequenceMD5(seq)
        const storedMd5 = sliceHeader.parsedContent.md5
          ?.map(byte => (byte < 16 ? '0' : '') + byte.toString(16))
          .join('')
        if (seqMd5 !== storedMd5) {
          throw new CramMalformedError(
            `MD5 checksum reference mismatch for ref ${sliceHeader.parsedContent.refSeqId} pos ${start}..${end}. recorded MD5: ${storedMd5}, calculated MD5: ${seqMd5}`,
          )
        }
      }
    }

    // tracks the read position within the block. codec.decode() methods
    // advance the byte and bit positions in the cursor as they decode
    // data note that we are only decoding a single block here, the core
    // data block
    const coreDataBlock = await this.getCoreDataBlock()
    const externalCursorMap = new Map<number, Cursor>()
    const cursors: Cursors = {
      lastAlignmentStart: isMappedSliceHeader(sliceHeader.parsedContent)
        ? sliceHeader.parsedContent.refSeqStart
        : 0,
      coreBlock: { bitPosition: 7, bytePosition: 0 },
      externalBlocks: {
        getCursor(contentId: number) {
          let r = externalCursorMap.get(contentId)
          if (r === undefined) {
            r = { bitPosition: 7, bytePosition: 0 }
            externalCursorMap.set(contentId, r)
          }
          return r
        },
      },
    }

    // Pre-decode external int blocks: batch ITF8 decode via WASM so that
    // ExternalCodec.decode() becomes a simple array index read.
    // A block can only be pre-decoded if ALL accessors use int type.
    // If any byte-type accessor shares the same block, skip it.
    const externalIntBlockIds = new Set<number>()
    const externalByteBlockIds = new Set<number>()

    // Recurse through codec encodings to find which external block IDs are
    // used as int vs byte. codecId 1 = EXTERNAL, 4 = BYTE_ARRAY_LENGTH
    // (whose lengths sub-codec is int, values sub-codec is byte),
    // 5 = BYTE_ARRAY_STOP (always byte).
    function collectExternalBlockIds(
      enc: CramEncoding | undefined,
      isInt: boolean,
    ) {
      if (!enc) {
        return
      }
      if (enc.codecId === 1) {
        if (isInt) {
          externalIntBlockIds.add(enc.parameters.blockContentId)
        } else {
          externalByteBlockIds.add(enc.parameters.blockContentId)
        }
      } else if (enc.codecId === 4) {
        collectExternalBlockIds(enc.parameters.lengthsEncoding, true)
        collectExternalBlockIds(enc.parameters.valuesEncoding, false)
      } else if (enc.codecId === 5) {
        externalByteBlockIds.add(enc.parameters.blockContentId)
      }
    }

    for (const [ds, enc] of Object.entries(
      compressionScheme.dataSeriesEncoding,
    )) {
      const dsType = dataSeriesTypes[ds as keyof typeof dataSeriesTypes]
      collectExternalBlockIds(enc, dsType === 'int')
    }
    for (const tagEnc of Object.values(compressionScheme.tagEncoding)) {
      collectExternalBlockIds(tagEnc, false)
    }

    // Remove any int block that is also used as byte
    for (const id of externalByteBlockIds) {
      externalIntBlockIds.delete(id)
    }

    const preDecodedIntBlocks = new Map<number, PreDecodedIntBlock>()
    for (const contentId of externalIntBlockIds) {
      const block = blocksByContentId[contentId]
      if (block?.content.length) {
        const values = batchDecodeItf8(block.content)
        preDecodedIntBlocks.set(contentId, { values, index: 0 })
      }
    }
    cursors.preDecodedIntBlocks = preDecodedIntBlocks

    // Build bound decode functions per data series. For ExternalCodec this
    // captures the content buffer and cursor directly, eliminating per-call
    // Record/Map lookup overhead. The bound decoders are assembled into a
    // single object literal with all data series present so V8 sees a stable
    // hidden class — call sites in decodeRecord then become direct property
    // accesses with monomorphic inline caches.
    const bind = (dataSeriesName: string) => {
      const codec = compressionScheme.getCodecForDataSeries(
        dataSeriesName as DataSeriesEncodingKey,
      )
      if (!codec) {
        return () => {
          throw new CramMalformedError(
            `no codec defined for ${dataSeriesName} data series`,
          )
        }
      }
      if (codec instanceof ExternalCodec) {
        const bid = codec.parameters.blockContentId
        const preDecoded = preDecodedIntBlocks.get(bid)
        if (preDecoded) {
          const { values } = preDecoded
          return () => values[preDecoded.index++]
        }
        const contentBlock = blocksByContentId[bid]
        if (!contentBlock) {
          return () => {
            throw new CramMalformedError(
              `no block found with content ID ${bid}`,
            )
          }
        }
        const cursor = cursors.externalBlocks.getCursor(bid)
        const content = contentBlock.content
        if (codec.dataType === 'int') {
          return () => parseItf8(content, cursor)
        }
        // Mirror the bounds check in ExternalCodec.decode — without it,
        // a truncated/corrupt block silently yields `undefined` for byte
        // reads, which downstream propagates as NaN/0 (silent data
        // corruption) rather than a clear error.
        return () => {
          if (cursor.bytePosition >= content.length) {
            throw new CramBufferOverrunError(
              'attempted to read beyond end of block. this file seems truncated.',
            )
          }
          return content[cursor.bytePosition++]
        }
      }
      if (codec instanceof ByteArrayStopCodec) {
        const { blockContentId, stopByte } = codec.parameters
        const contentBlock = blocksByContentId[blockContentId]
        if (!contentBlock) {
          return () => {
            throw new CramMalformedError(
              `no block found with content ID ${blockContentId}`,
            )
          }
        }
        const content = contentBlock.content
        const cursor = cursors.externalBlocks.getCursor(blockContentId)
        return () => {
          const start = cursor.bytePosition
          const len = content.length
          let pos = start
          while (pos < len && content[pos] !== stopByte) {
            pos++
          }
          if (pos >= len) {
            throw new CramBufferOverrunError(
              'byteArrayStop reading beyond length of data buffer?',
            )
          }
          cursor.bytePosition = pos + 1
          return content.subarray(start, pos)
        }
      }
      return () =>
        codec.decode(this, coreDataBlock!, blocksByContentId, cursors)
    }

    const bd: BoundDecoders = {
      BF: bind('BF'),
      CF: bind('CF'),
      RI: bind('RI'),
      RL: bind('RL'),
      AP: bind('AP'),
      RG: bind('RG'),
      RN: bind('RN'),
      MF: bind('MF'),
      NS: bind('NS'),
      NP: bind('NP'),
      TS: bind('TS'),
      NF: bind('NF'),
      TL: bind('TL'),
      FN: bind('FN'),
      FC: bind('FC'),
      FP: bind('FP'),
      DL: bind('DL'),
      BB: bind('BB'),
      QQ: bind('QQ'),
      BS: bind('BS'),
      IN: bind('IN'),
      RS: bind('RS'),
      PD: bind('PD'),
      HC: bind('HC'),
      SC: bind('SC'),
      MQ: bind('MQ'),
      BA: bind('BA'),
      QS: bind('QS'),
      TC: bind('TC'),
      TN: bind('TN'),
    } as BoundDecoders

    // Bulk byte decoder for QS and BA — getBytesSubarray returns a subarray
    // view when the codec supports it (e.g. ExternalCodec), or undefined otherwise
    const qsCodec = compressionScheme.getCodecForDataSeries('QS')
    const baCodec = compressionScheme.getCodecForDataSeries('BA')
    const decodeBulkBytesRaw: BulkByteRawDecoder | undefined =
      qsCodec || baCodec
        ? (dataSeriesName, length) => {
            const codec = dataSeriesName === 'QS' ? qsCodec : baCodec
            return codec?.getBytesSubarray(blocksByContentId, cursors, length)
          }
        : undefined

    // Bound tag decoders — tags are typically encoded as byteArrayLength
    // (codecId=4) wrapping External-int lengths + External-byte values. We
    // build a fast closure per tagId that inlines the length read and value
    // subarray, eliminating per-call dispatch through ByteArrayLengthCodec
    // and the inner codecs. Other encodings fall back to the generic dispatch.
    const boundTagDecoders: Record<
      string,
      () => Uint8Array | number | undefined
    > = {}
    const bindTagFallback = (tagId: string) => {
      const codec = compressionScheme.getCodecForTag(tagId)
      return () =>
        codec.decode(this, coreDataBlock!, blocksByContentId, cursors)
    }
    for (const tagId of Object.keys(compressionScheme.tagEncoding)) {
      const enc = compressionScheme.tagEncoding[tagId]!
      if (
        enc.codecId === 4 &&
        enc.parameters.lengthsEncoding.codecId === 1 &&
        enc.parameters.valuesEncoding.codecId === 1
      ) {
        const lenBid = enc.parameters.lengthsEncoding.parameters.blockContentId
        const valBid = enc.parameters.valuesEncoding.parameters.blockContentId
        const lenContentBlock = blocksByContentId[lenBid]
        const valContentBlock = blocksByContentId[valBid]
        if (!lenContentBlock || !valContentBlock) {
          boundTagDecoders[tagId] = bindTagFallback(tagId)
          continue
        }
        const valContent = valContentBlock.content
        const valCursor = cursors.externalBlocks.getCursor(valBid)
        const lenPreDecoded = preDecodedIntBlocks.get(lenBid)
        const lenContent = lenContentBlock.content
        const lenCursor = cursors.externalBlocks.getCursor(lenBid)
        const readTagLen = lenPreDecoded
          ? () => lenPreDecoded.values[lenPreDecoded.index++]!
          : () => parseItf8(lenContent, lenCursor)
        boundTagDecoders[tagId] = () => {
          const length = readTagLen()
          if (length === 0) {
            return EMPTY_BYTES
          }
          const start = valCursor.bytePosition
          const end = start + length
          if (end > valContent.length) {
            throw new CramBufferOverrunError(
              'attempted to read beyond end of block. this file seems truncated.',
            )
          }
          valCursor.bytePosition = end
          return valContent.subarray(start, end)
        }
      } else {
        boundTagDecoders[tagId] = bindTagFallback(tagId)
      }
    }

    const records: CramRecord[] = new Array(
      sliceHeader.parsedContent.numRecords,
    )
    const rfSchema = buildRFSchema(bd, majorVersion)
    for (let i = 0; i < records.length; i += 1) {
      try {
        records[i] = new CramRecord(
          decodeRecord(
            this,
            bd,
            rfSchema,
            boundTagDecoders,
            compressionScheme,
            sliceHeader,
            coreDataBlock!,
            blocksByContentId,
            cursors,
            majorVersion,
            i,
            sliceHeader.contentPosition +
              sliceHeader.parsedContent.recordCounter +
              i +
              1,
            decodeOptions,
            decodeBulkBytesRaw,
          ),
        )
      } catch (e) {
        const err = e as { code?: string; message?: string }
        if (err.code === 'CRAM_BUFFER_OVERRUN') {
          const recordsDecoded = i
          const recordsExpected = sliceHeader.parsedContent.numRecords
          throw new CramMalformedError(
            `Failed to decode all records in slice. Decoded ${recordsDecoded} of ${recordsExpected} expected records. ` +
              `Buffer overrun suggests either: (1) file is truncated/corrupted, (2) compression scheme is incorrect, ` +
              `or (3) there's a bug in the decoder. Original error: ${err.message}`,
          )
        } else {
          throw e
        }
      }
    }

    // interpret `recordsToNextFragment` attributes to make standard `mate`
    // objects. The records loop above fills every slot or throws — by the
    // time we get here, records[i] is always defined. The records[mate]
    // guard protects against malformed mateRecordNumber pointing past the
    // slice.
    for (let i = 0; i < records.length; i += 1) {
      const r = records[i]!
      const { mateRecordNumber } = r
      if (
        mateRecordNumber !== undefined &&
        mateRecordNumber >= 0 &&
        records[mateRecordNumber]
      ) {
        associateIntraSliceMate(records, i, r, records[mateRecordNumber])
      }
    }

    return records
  }

  async getRecords(
    filterFunction: (r: CramRecord) => boolean,
    decodeOptions?: DecodeOptions,
  ) {
    // Merge with defaults
    const opts = { ...defaultDecodeOptions, ...decodeOptions }

    // fetch the features if necessary, using the file-level feature cache
    // Include decode options in cache key so different decode configs are cached separately
    const optionsKey = `${opts.decodeTags ? 1 : 0}`
    const cacheKey = `${this.container.filePosition}:${this.containerPosition}:${optionsKey}`
    let recordsPromise = this.file.featureCache.get(cacheKey)
    if (!recordsPromise) {
      recordsPromise = this._fetchRecords(opts)
      this.file.featureCache.set(cacheKey, recordsPromise)
    }

    const unfiltered = await recordsPromise
    const records = unfiltered.filter(filterFunction)

    // if we can fetch reference sequence, add the reference sequence to the records
    if (records.length && this.file.fetchReferenceSequenceCallback) {
      const sliceHeader = await this.getHeader()
      if (
        isMappedSliceHeader(sliceHeader.parsedContent) &&
        (sliceHeader.parsedContent.refSeqId >= 0 || // single-ref slice
          sliceHeader.parsedContent.refSeqId === -2) // multi-ref slice
      ) {
        const singleRefId =
          sliceHeader.parsedContent.refSeqId >= 0
            ? sliceHeader.parsedContent.refSeqId
            : undefined
        const compressionScheme = await this.container.getCompressionScheme()
        if (compressionScheme === undefined) {
          throw new Error('compression scheme undefined')
        }
        const refRegions: Record<string, RefRegion> = {}

        // iterate over the records to find the spans of the reference
        // sequences we need to fetch
        for (const record of records) {
          const seqId =
            singleRefId !== undefined ? singleRefId : record.sequenceId
          let refRegion = refRegions[seqId]
          if (!refRegion) {
            refRegion = {
              id: seqId,
              start: record.alignmentStart,
              end: Number.NEGATIVE_INFINITY,
              seq: null,
            }
            refRegions[seqId] = refRegion
          }

          const end =
            record.alignmentStart +
            (record.lengthOnRef || record.readLength) -
            1
          if (end > refRegion.end) {
            refRegion.end = end
          }
          if (record.alignmentStart < refRegion.start) {
            refRegion.start = record.alignmentStart
          }
        }

        // fetch the `seq` for all of the ref regions
        await Promise.all(
          Object.values(refRegions).map(async refRegion => {
            if (
              refRegion.id !== -1 &&
              refRegion.start <= refRegion.end &&
              this.file.fetchReferenceSequenceCallback
            ) {
              refRegion.seq = await this.file.fetchReferenceSequenceCallback(
                refRegion.id,
                refRegion.start,
                refRegion.end,
              )
            }
          }),
        )

        // now decorate all the records with them
        for (const record of records) {
          const seqId =
            singleRefId !== undefined ? singleRefId : record.sequenceId
          const refRegion = refRegions[seqId]
          if (refRegion?.seq) {
            const seq = refRegion.seq
            record.addReferenceSequence(
              { ...refRegion, seq },
              compressionScheme,
            )
          }
        }
      }
    }

    return records
  }
}
