import Constants from './constants.ts'
import { readNullTerminatedStringFromBuffer } from './util.ts'

import type CramContainerCompressionScheme from './container/compressionScheme.ts'
import type decodeRecord from './slice/decodeRecord.ts'

// precomputed pair orientation strings indexed by ((flags >> 4) & 0xF) | (isize > 0 ? 16 : 0)
// bits 0-3 encode flag bits 0x10(reverse),0x20(mate reverse),0x40(read1),0x80(read2)
// bit 4 encodes whether isize > 0
// prettier-ignore
const PAIR_ORIENTATION_TABLE = [
  'F F ','F R ','R F ','R R ','F2F1','F2R1','R2F1','R2R1',
  'F1F2','F1R2','R1F2','R1R2','F2F1','F2R1','R2F1','R2R1',
  'F F ','R F ','F R ','R R ','F1F2','R1F2','F1R2','R1R2',
  'F2F1','R2F1','F2R1','R2R1','F1F2','R1F2','F1R2','R1R2',
]

export interface RefRegion {
  start: number
  end: number
  seq: string
}

interface ReadFeatureBase {
  pos: number
  refPos: number
}

/**
 * Read features describe differences between a read and the reference sequence.
 * Each feature has a code indicating the type of difference, a position in the
 * read (pos), and a position on the reference (refPos).
 */
export type ReadFeature =
  /** I=insertion, S=soft clip, b=bases, i=single-base insertion — all carry a sequence string */
  | (ReadFeatureBase & { code: 'I' | 'S' | 'b' | 'i'; data: string })
  /** B=base and quality pair — [substituted base, quality score] */
  | (ReadFeatureBase & { code: 'B'; data: [string, number] })
  /** X=base substitution — data is the substitution matrix index, ref/sub filled in by addReferenceSequence */
  | (ReadFeatureBase & {
      code: 'X'
      data: number
      ref?: string
      sub?: string
    })
  /** D=deletion, N=reference skip, H=hard clip, P=padding, Q=single quality score */
  | (ReadFeatureBase & { code: 'D' | 'N' | 'H' | 'P' | 'Q'; data: number })
  /** q=quality scores for a stretch of bases */
  | (ReadFeatureBase & { code: 'q'; data: number[] })

export interface DecodeOptions {
  /** Whether to parse tags. If false, raw tag data is stored for lazy parsing. Default true. */
  decodeTags?: boolean
}

export const defaultDecodeOptions: Required<DecodeOptions> = {
  decodeTags: true,
}

function decodeReadSequence(cramRecord: CramRecord, refRegion: RefRegion) {
  // if it has no length, it has no sequence
  if (!cramRecord.lengthOnRef && !cramRecord.readLength) {
    return null
  }

  if (cramRecord.isUnknownBases()) {
    return null
  }

  // remember: all coordinates are 1-based closed
  const regionSeqOffset = cramRecord.alignmentStart - refRegion.start

  if (!cramRecord.readFeatures) {
    return refRegion.seq
      .slice(regionSeqOffset, regionSeqOffset + (cramRecord.lengthOnRef || 0))
      .toUpperCase()
  }

  // Walk read features against the reference to reconstruct the read sequence.
  // See CRAMv3 §10.2 (Read features): https://samtools.github.io/hts-specs/CRAMv3.pdf
  let bases = ''
  let regionPos = regionSeqOffset
  let currentReadFeature = 0
  while (bases.length < cramRecord.readLength) {
    if (currentReadFeature < cramRecord.readFeatures.length) {
      const feature = cramRecord.readFeatures[currentReadFeature]!
      if (feature.code === 'Q' || feature.code === 'q') {
        currentReadFeature += 1
      } else if (feature.pos === bases.length + 1) {
        currentReadFeature += 1
        switch (feature.code) {
          case 'b': {
            bases += feature.data
            regionPos += feature.data.length
            break
          }
          case 'B': {
            bases += feature.data[0]
            regionPos += 1
            break
          }
          case 'X': {
            bases += feature.sub
            regionPos += 1
            break
          }
          case 'I':
          case 'i':
          case 'S': {
            bases += feature.data
            break
          }
          case 'D':
          case 'N': {
            regionPos += feature.data
            break
          }
          // H (hard clip), P (padding): do nothing
        }
      } else {
        // put down a chunk of reference up to the next read feature
        const chunk = refRegion.seq.slice(
          regionPos,
          regionPos + feature.pos - bases.length - 1,
        )
        bases += chunk
        regionPos += chunk.length
      }
    } else {
      // put down a chunk of reference up to the full read length
      const chunk = refRegion.seq.slice(
        regionPos,
        regionPos + cramRecord.readLength - bases.length,
      )
      bases += chunk
      regionPos += chunk.length
    }
  }

  return bases.toUpperCase()
}

const baseNumbers: Record<string, number | undefined> = {
  a: 0,
  A: 0,
  c: 1,
  C: 1,
  g: 2,
  G: 2,
  t: 3,
  T: 3,
  n: 4,
  N: 4,
}

function decodeBaseSubstitution(
  cramRecord: CramRecord,
  refRegion: RefRegion,
  compressionScheme: CramContainerCompressionScheme,
  readFeature: ReadFeatureBase & {
    code: 'X'
    data: number
    ref?: string
    sub?: string
  },
) {
  // decode base substitution code using the substitution matrix
  const refCoord = readFeature.refPos - refRegion.start
  const refBase = refRegion.seq.charAt(refCoord)
  if (refBase) {
    readFeature.ref = refBase
  }
  let baseNumber = baseNumbers[refBase]
  if (baseNumber === undefined) {
    baseNumber = 4
  }
  const substitutionScheme = compressionScheme.substitutionMatrix[baseNumber]!
  const base = substitutionScheme[readFeature.data]
  if (base) {
    readFeature.sub = base
  }
}

export interface MateRecord {
  readName?: string
  sequenceId: number
  alignmentStart: number
  flags?: number

  uniqueId?: number
}

export const BamFlags = [
  [0x1, 'Paired'],
  [0x2, 'ProperlyPaired'],
  [0x4, 'SegmentUnmapped'],
  [0x8, 'MateUnmapped'],
  [0x10, 'ReverseComplemented'],
  //  the mate is mapped to the reverse strand
  [0x20, 'MateReverseComplemented'],
  //  this is read1
  [0x40, 'Read1'],
  //  this is read2
  [0x80, 'Read2'],
  //  not primary alignment
  [0x100, 'Secondary'],
  //  QC failure
  [0x200, 'FailedQc'],
  //  optical or PCR duplicate
  [0x400, 'Duplicate'],
  //  supplementary alignment
  [0x800, 'Supplementary'],
] as const

export const CramFlags = [
  [0x1, 'PreservingQualityScores'],
  [0x2, 'Detached'],
  [0x4, 'WithMateDownstream'],
  [0x8, 'DecodeSequenceAsStar'],
] as const

export const MateFlags = [
  [0x1, 'OnNegativeStrand'],
  [0x2, 'Unmapped'],
] as const

type FlagsDecoder<Type> = {
  [Property in Type as `is${Capitalize<string & Property>}`]: (
    flags: number,
  ) => boolean
}

type FlagsEncoder<Type> = {
  [Property in Type as `set${Capitalize<string & Property>}`]: (
    flags: number,
  ) => number
}

function makeFlagsHelper<T>(
  x: readonly (readonly [number, T])[],
): FlagsDecoder<T> & FlagsEncoder<T> {
  const r: Record<string, (flags: number) => boolean | number> = {}
  for (const [code, name] of x) {
    r[`is${name}`] = (flags: number) => !!(flags & code)
    r[`set${name}`] = (flags: number) => flags | code
  }

  return r as unknown as FlagsDecoder<T> & FlagsEncoder<T>
}

export const BamFlagsDecoder = makeFlagsHelper(BamFlags)
export const CramFlagsDecoder = makeFlagsHelper(CramFlags)
export const MateFlagsDecoder = makeFlagsHelper(MateFlags)

/**
 * Class of each CRAM record returned by this API.
 */
export default class CramRecord {
  public tags: Record<string, string | number | number[] | undefined>
  public flags: number
  public cramFlags: number
  public readBases?: string | null
  public _refRegion?: RefRegion
  public readFeatures?: ReadFeature[]
  public alignmentStart: number
  public lengthOnRef: number | undefined
  public readLength: number
  // templateLength is computed post-hoc for intra-slice mate pairs,
  // templateSize is the raw CRAM-encoded TS data series value
  public templateLength?: number
  public templateSize?: number
  private _readName?: string
  private _readNameRaw?: Uint8Array
  public _syntheticReadName?: string
  public mateRecordNumber?: number
  public mate?: MateRecord
  public uniqueId: number
  public sequenceId: number
  public readGroupId: number
  public mappingQuality: number | undefined
  public qualityScores: Uint8Array | null | undefined

  get readName() {
    if (this._readName === undefined) {
      if (this._readNameRaw) {
        this._readName = readNullTerminatedStringFromBuffer(this._readNameRaw)
        this._readNameRaw = undefined
      } else {
        return this._syntheticReadName
      }
    }
    return this._readName
  }

  constructor({
    flags,
    cramFlags,
    readLength,
    mappingQuality,
    lengthOnRef,
    qualityScores,
    mateRecordNumber,
    readBases,
    readFeatures,
    mate,
    readGroupId,
    readNameRaw,
    sequenceId,
    uniqueId,
    templateSize,
    alignmentStart,
    tags,
  }: ReturnType<typeof decodeRecord>) {
    this.flags = flags
    this.cramFlags = cramFlags
    this.readLength = readLength
    this.mappingQuality = mappingQuality
    this.lengthOnRef = lengthOnRef
    this.qualityScores = qualityScores
    this.readGroupId = readGroupId
    this.sequenceId = sequenceId!
    this.uniqueId = uniqueId
    this.alignmentStart = alignmentStart
    this.tags = tags
    if (readNameRaw) {
      this._readNameRaw = readNameRaw
    }
    if (readBases) {
      this.readBases = readBases
    }
    this.templateSize = templateSize
    if (readFeatures) {
      this.readFeatures = readFeatures
    }
    if (mate) {
      this.mate = mate
    }
    if (mateRecordNumber) {
      this.mateRecordNumber = mateRecordNumber
    }
  }

  /**
   * Get a single quality score at the given index.
   * @param index 0-based index into the quality scores
   * @returns the quality score at that index, or undefined if not available
   */
  qualityScoreAt(index: number): number | undefined {
    return this.qualityScores?.[index]
  }

  // BAM flags — see SAM/BAM spec §1.4 (Flag field):
  // https://samtools.github.io/hts-specs/SAMv1.pdf
  /** @returns {boolean} true if the read is paired, regardless of whether both segments are mapped */
  isPaired() {
    return !!(this.flags & Constants.BAM_FPAIRED)
  }
  /** @returns {boolean} true if the read is paired, and both segments are mapped */
  isProperlyPaired() {
    return !!(this.flags & Constants.BAM_FPROPER_PAIR)
  }
  /** @returns {boolean} true if the read itself is unmapped; conflictive with isProperlyPaired */
  isSegmentUnmapped() {
    return !!(this.flags & Constants.BAM_FUNMAP)
  }
  /** @returns {boolean} true if the mate is unmapped; conflictive with isProperlyPaired */
  isMateUnmapped() {
    return !!(this.flags & Constants.BAM_FMUNMAP)
  }
  /** @returns {boolean} true if the read is mapped to the reverse strand */
  isReverseComplemented() {
    return !!(this.flags & Constants.BAM_FREVERSE)
  }
  /** @returns {boolean} true if the mate is mapped to the reverse strand */
  isMateReverseComplemented() {
    return !!(this.flags & Constants.BAM_FMREVERSE)
  }
  isRead1() {
    return !!(this.flags & Constants.BAM_FREAD1)
  }
  isRead2() {
    return !!(this.flags & Constants.BAM_FREAD2)
  }
  isSecondary() {
    return !!(this.flags & Constants.BAM_FSECONDARY)
  }
  isFailedQc() {
    return !!(this.flags & Constants.BAM_FQCFAIL)
  }
  isDuplicate() {
    return !!(this.flags & Constants.BAM_FDUP)
  }
  isSupplementary() {
    return !!(this.flags & Constants.BAM_FSUPPLEMENTARY)
  }

  // CRAM-specific compression flags — see CRAMv3 §8.4 (Bit Flags):
  // https://samtools.github.io/hts-specs/CRAMv3.pdf
  isDetached() {
    return !!(this.cramFlags & Constants.CRAM_FLAG_DETACHED)
  }
  hasMateDownStream() {
    return !!(this.cramFlags & Constants.CRAM_FLAG_MATE_DOWNSTREAM)
  }
  isPreservingQualityScores() {
    return !!(this.cramFlags & Constants.CRAM_FLAG_PRESERVE_QUAL_SCORES)
  }
  isUnknownBases() {
    return !!(this.cramFlags & Constants.CRAM_FLAG_NO_SEQ)
  }

  /**
   * Get the original sequence of this read.
   * @returns {String} sequence basepairs
   */
  getReadBases() {
    if (!this.readBases && this._refRegion) {
      const decoded = decodeReadSequence(this, this._refRegion)
      if (decoded) {
        this.readBases = decoded
      }
    }
    return this.readBases
  }

  /**
   * Get the CIGAR string describing this read's alignment against the
   * reference, reconstructed from the read features. Substitutions and
   * verbatim bases are reported as alignment matches (M), following the plain
   * CIGAR convention where M covers both matches and mismatches. Unmapped
   * reads return '*'.
   *
   * See CRAMv3 §10.2 (Read features):
   * https://samtools.github.io/hts-specs/CRAMv3.pdf
   *
   * @returns {string} the CIGAR string, e.g. "50M2I48M"
   */
  getCigarString(): string {
    if (this.isSegmentUnmapped()) {
      return '*'
    }

    // build up (length, op) pairs, merging adjacent runs of the same op so
    // e.g. consecutive single-base insertions collapse into one I operation
    const ops: [number, string][] = []
    const push = (len: number, op: string) => {
      if (len > 0) {
        const last = ops.at(-1)
        if (last?.[1] === op) {
          last[0] += len
        } else {
          ops.push([len, op])
        }
      }
    }

    let readConsumed = 0
    let refPos = this.alignmentStart

    if (this.readFeatures !== undefined) {
      for (const feature of this.readFeatures) {
        // 'q'/'Q' carry only quality information; their refPos does not track
        // the alignment geometry, so they must not perturb position tracking
        if (feature.code !== 'q' && feature.code !== 'Q') {
          // reference bases between the last position and this feature are matches
          const gap = feature.refPos - refPos
          push(gap, 'M')
          readConsumed += gap
          refPos = feature.refPos

          switch (feature.code) {
            case 'b': {
              // verbatim stretch of bases, aligned as matches
              push(feature.data.length, 'M')
              readConsumed += feature.data.length
              refPos += feature.data.length
              break
            }
            case 'B':
            case 'X': {
              // single-base (substitution or base+quality), aligned as a match
              push(1, 'M')
              readConsumed += 1
              refPos += 1
              break
            }
            case 'D':
            case 'N': {
              push(feature.data, feature.code)
              refPos += feature.data
              break
            }
            case 'I':
            case 'S': {
              push(feature.data.length, feature.code)
              readConsumed += feature.data.length
              break
            }
            case 'i': {
              // single-base insertion
              push(1, 'I')
              readConsumed += 1
              break
            }
            case 'P':
            case 'H': {
              push(feature.data, feature.code)
              break
            }
          }
        }
      }
    }

    // any read bases past the last feature are trailing matches
    push(this.readLength - readConsumed, 'M')

    return ops.map(([len, op]) => `${len}${op}`).join('')
  }

  // adapted from igv.js
  // uses precomputed lookup table indexed by flag bits + isize sign.
  // the BAM spec defines tlen as positive for the leftmost segment and
  // negative for the rightmost, so isize > 0 reliably indicates which
  // read comes first without needing position-based correction
  // (see also: gmod/bam-js src/record.ts pair_orientation getter)
  getPairOrientation() {
    const f = this.flags
    // paired (0x1) set, unmapped (0x4) clear, mate unmapped (0x8) clear
    if ((f & 0xd) !== 0x1 || this.sequenceId !== this.mate?.sequenceId) {
      return undefined
    }
    const isize = this.templateLength || this.templateSize || 0
    return PAIR_ORIENTATION_TABLE[((f >> 4) & 0xf) | (isize > 0 ? 16 : 0)]
  }

  /**
   * Annotates this feature with the given reference sequence basepair
   * information. This will add a `sub` and a `ref` item to base
   * substitution read features given the actual substituted and reference
   * base pairs, and will make the `getReadBases()` method work.
   *
   * @param {object} refRegion
   * @param {number} refRegion.start
   * @param {number} refRegion.end
   * @param {string} refRegion.seq
   * @param {CramContainerCompressionScheme} compressionScheme
   * @returns {undefined} nothing
   */
  addReferenceSequence(
    refRegion: RefRegion,
    compressionScheme: CramContainerCompressionScheme,
  ) {
    if (this.readFeatures) {
      // use the reference bases to decode the bases substituted in each base
      // substitution
      for (const readFeature of this.readFeatures) {
        if (readFeature.code === 'X') {
          decodeBaseSubstitution(
            this,
            refRegion,
            compressionScheme,
            readFeature,
          )
        }
      }
    }

    // if this region completely covers this read,
    // keep a reference to it
    if (
      !this.readBases &&
      refRegion.start <= this.alignmentStart &&
      refRegion.end >=
        this.alignmentStart + (this.lengthOnRef || this.readLength) - 1
    ) {
      this._refRegion = refRegion
    }
  }

  // Serializer used by snapshot tests and consumers that JSON.stringify a
  // record. qualityScores (Uint8Array) is converted to number[] so snapshots
  // stay diffable. Optional fields are added only when defined to match the
  // historical shape of the output.
  toJSON() {
    const data: Record<string, unknown> = {
      alignmentStart: this.alignmentStart,
      cramFlags: this.cramFlags,
      flags: this.flags,
      readGroupId: this.readGroupId,
      readLength: this.readLength,
      sequenceId: this.sequenceId,
      tags: this.tags,
      uniqueId: this.uniqueId,
      readName: this.readName,
      readBases: this.getReadBases(),
      qualityScores: this.qualityScores
        ? Array.from(this.qualityScores)
        : this.qualityScores,
    }
    if (this.lengthOnRef !== undefined) {
      data.lengthOnRef = this.lengthOnRef
    }
    if (this.mappingQuality !== undefined) {
      data.mappingQuality = this.mappingQuality
    }
    if (this.templateSize !== undefined) {
      data.templateSize = this.templateSize
    }
    if (this.templateLength !== undefined) {
      data.templateLength = this.templateLength
    }
    if (this.readFeatures !== undefined) {
      data.readFeatures = this.readFeatures
    }
    if (this.mate !== undefined) {
      data.mate = this.mate
    }
    if (this.mateRecordNumber !== undefined) {
      data.mateRecordNumber = this.mateRecordNumber
    }
    return data
  }
}

export { CramRecord }
