// https://github.com/nodejs/node/blob/4c08c20e575a0954fe3977a20e9f52b4980a2e48/lib/internal/test_runner/tap_parser.js
'use strict'

const {
  ArrayPrototypeFilter,
  ArrayPrototypeForEach,
  ArrayPrototypeIncludes,
  ArrayPrototypeJoin,
  ArrayPrototypeMap,
  ArrayPrototypePop,
  ArrayPrototypePush,
  Boolean,
  Number,
  RegExpPrototypeExec,
  String,
  StringPrototypeEndsWith,
  StringPrototypeReplaceAll,
  StringPrototypeSlice,
  StringPrototypeSplit,
  StringPrototypeTrim
} = require('#internal/per_context/primordials')
const Transform = require('stream').Transform
const { TapLexer, TokenKind } = require('#internal/test_runner/tap_lexer')
const { TapChecker } = require('#internal/test_runner/tap_checker')
const {
  codes: { ERR_TAP_VALIDATION_ERROR, ERR_TAP_PARSER_ERROR }
} = require('#internal/errors')
const { kEmptyObject } = require('#internal/util')
/**
 *
 * TAP14 specifications
 *
 * See https://testanything.org/tap-version-14-specification.html
 *
 * Note that the following grammar is intended as a rough "pseudocode" guidance.
 * It is not strict EBNF:
 *
 * TAPDocument := Version Plan Body | Version Body Plan
 * Version     := "TAP version 14\n"
 * Plan        := "1.." (Number) (" # " Reason)? "\n"
 * Body        := (TestPoint | BailOut | Pragma | Comment | Anything | Empty | Subtest)*
 * TestPoint   := ("not ")? "ok" (" " Number)? ((" -")? (" " Description) )? (" " Directive)? "\n" (YAMLBlock)?
 * Directive   := " # " ("todo" | "skip") (" " Reason)?
 * YAMLBlock   := "  ---\n" (YAMLLine)* "  ...\n"
 * YAMLLine    := "  " (YAML)* "\n"
 * BailOut     := "Bail out!" (" " Reason)? "\n"
 * Reason      := [^\n]+
 * Pragma      := "pragma " [+-] PragmaKey "\n"
 * PragmaKey   := ([a-zA-Z0-9_-])+
 * Subtest     := ("# Subtest" (": " SubtestName)?)? "\n" SubtestDocument TestPoint
 * Comment     := ^ (" ")* "#" [^\n]* "\n"
 * Empty       := [\s\t]* "\n"
 * Anything    := [^\n]+ "\n"
 *
 */

/**
 * An LL(1) parser for TAP14/TAP13.
 */
class TapParser extends Transform {
  #checker = null
  #lexer = null
  #currentToken = null

  #input = ''
  #currentChunkAsString = ''
  #lastLine = ''

  #tokens = [[]]
  #flatAST = []
  #bufferedComments = []
  #bufferedTestPoints = []
  #lastTestPointDetails = {}
  #yamlBlockBuffer = []

  #currentTokenIndex = 0
  #currentTokenChunk = 0
  #subTestNestingLevel = 0
  #yamlCurrentIndentationLevel = 0
  #kSubtestBlockIndentationFactor = 4

  #isYAMLBlock = false
  #isSyncParsingEnabled = false

  constructor ({ specs = TapChecker.TAP13 } = kEmptyObject) {
    super({ __proto__: null, readableObjectMode: true })

    this.#checker = new TapChecker({ specs })
  }

  // ----------------------------------------------------------------------//
  // ----------------------------- Public API -----------------------------//
  // ----------------------------------------------------------------------//

  parse (chunkAsString = '', callback = null) {
    this.#isSyncParsingEnabled = false
    this.#currentTokenChunk = 0
    this.#currentTokenIndex = 0
    // Note: we are overwriting the input on each stream call
    // This is fine because we don't want to parse previous chunks
    this.#input = chunkAsString
    this.#lexer = new TapLexer(chunkAsString)

    try {
      this.#tokens = this.#scanTokens()
      this.#parseTokens(callback)
    } catch (error) {
      callback(null, error)
    }
  }

  parseSync (input = '', callback = null) {
    if (typeof input !== 'string' || input === '') {
      return []
    }

    this.#isSyncParsingEnabled = true
    this.#input = input
    this.#lexer = new TapLexer(input)
    this.#tokens = this.#scanTokens()

    this.#parseTokens(callback)

    if (this.#isYAMLBlock) {
      // Looks like we have a non-ending YAML block
      this.#error('Expected end of YAML block')
    }

    // Manually flush the remaining buffered comments and test points
    this._flush()

    return this.#flatAST
  }

  // Check if the TAP content is semantically valid
  // Note: Validating the TAP content requires the whole AST to be available.
  check () {
    if (this.#isSyncParsingEnabled) {
      return this.#checker.check(this.#flatAST)
    }

    // TODO(@manekinekko): when running in async mode, it doesn't make sense to
    // validate the current chunk. Validation needs to whole AST to be available.
    throw new ERR_TAP_VALIDATION_ERROR(
      'TAP validation is not supported for async parsing'
    )
  }
  // ----------------------------------------------------------------------//
  // --------------------------- Transform API ----------------------------//
  // ----------------------------------------------------------------------//

  processChunk (chunk) {
    const str = this.#lastLine + chunk.toString('utf8')
    const lines = StringPrototypeSplit(str, '\n')
    this.#lastLine = ArrayPrototypePop(lines)

    let chunkAsString = ArrayPrototypeJoin(lines, '\n')
    // Special case where chunk is emitted by a child process
    chunkAsString = StringPrototypeReplaceAll(
      chunkAsString,
      '[out] ',
      ''
    )
    chunkAsString = StringPrototypeReplaceAll(
      chunkAsString,
      '[err] ',
      ''
    )
    if (StringPrototypeEndsWith(chunkAsString, '\n')) {
      chunkAsString = StringPrototypeSlice(chunkAsString, 0, -1)
    }
    if (StringPrototypeEndsWith(chunkAsString, 'EOF')) {
      chunkAsString = StringPrototypeSlice(chunkAsString, 0, -3)
    }

    return chunkAsString
  }

  _transform (chunk, _encoding, next) {
    const chunkAsString = this.processChunk(chunk)

    if (!chunkAsString) {
      // Ignore empty chunks
      next()
      return
    }

    this.parse(chunkAsString, (node, error) => {
      if (error) {
        next(error)
        return
      }

      if (node.kind === TokenKind.EOF) {
        // Emit when the current chunk is fully processed and consumed
        next()
      }
    })
  }

  // Flush the remaining buffered comments and test points
  // This will be called automatically when the stream is closed
  // We also call this method manually when we reach the end of the sync parsing
  _flush (next = null) {
    if (!this.#lastLine) {
      this.#__flushPendingTestPointsAndComments()
      next?.()
      return
    }
    // Parse the remaining line
    this.parse(this.#lastLine, (node, error) => {
      this.#lastLine = ''

      if (error) {
        next?.(error)
        return
      }

      if (node.kind === TokenKind.EOF) {
        this.#__flushPendingTestPointsAndComments()
        next?.()
      }
    })
  }

  #__flushPendingTestPointsAndComments () {
    ArrayPrototypeForEach(this.#bufferedTestPoints, (node) => {
      this.#emit(node)
    })
    ArrayPrototypeForEach(this.#bufferedComments, (node) => {
      this.#emit(node)
    })

    // Clean up
    this.#bufferedTestPoints = []
    this.#bufferedComments = []
  }

  // ----------------------------------------------------------------------//
  // ----------------------------- Private API ----------------------------//
  // ----------------------------------------------------------------------//

  #scanTokens () {
    return this.#lexer.scan()
  }

  #parseTokens (callback = null) {
    for (let index = 0; index < this.#tokens.length; index++) {
      const chunk = this.#tokens[index]
      this.#parseChunk(chunk)
    }

    callback?.({ kind: TokenKind.EOF }) // eslint-disable-line n/no-callback-literal
  }

  #parseChunk (chunk) {
    this.#subTestNestingLevel = this.#getCurrentIndentationLevel(chunk)
    // We compute the current index of the token in the chunk
    // based on the indentation level (number of spaces).
    // We also need to take into account if we are in a YAML block or not.
    // If we are in a YAML block, we compute the current index of the token
    // based on the indentation level of the YAML block (start block).

    if (this.#isYAMLBlock) {
      this.#currentTokenIndex =
        this.#yamlCurrentIndentationLevel *
        this.#kSubtestBlockIndentationFactor
    } else {
      this.#currentTokenIndex =
        this.#subTestNestingLevel * this.#kSubtestBlockIndentationFactor
      this.#yamlCurrentIndentationLevel = this.#subTestNestingLevel
    }

    let node

    // Parse current chunk
    try {
      node = this.#TAPDocument(chunk)
    } catch {
      node = {
        kind: TokenKind.UNKNOWN,
        node: {
          value: this.#currentChunkAsString
        }
      }
    }

    // Emit the parsed node to both the stream and the AST
    this.#emitOrBufferCurrentNode(node)

    // Move pointers to the next chunk and reset the current token index
    this.#currentTokenChunk++
    this.#currentTokenIndex = 0
  }

  #error (message) {
    const token = this.#currentToken || { value: '', kind: '' }
    // Escape NewLine characters
    if (token.value === '\n') {
      token.value = '\\n'
    }

    throw new ERR_TAP_PARSER_ERROR(
      message,
      `, received "${token.value}" (${token.kind})`,
      token,
      this.#input
    )
  }

  #peek (shouldSkipBlankTokens = true) {
    if (shouldSkipBlankTokens) {
      this.#skip(TokenKind.WHITESPACE)
    }

    return this.#tokens[this.#currentTokenChunk][this.#currentTokenIndex]
  }

  #next (shouldSkipBlankTokens = true) {
    if (shouldSkipBlankTokens) {
      this.#skip(TokenKind.WHITESPACE)
    }

    if (this.#tokens[this.#currentTokenChunk]) {
      this.#currentToken =
        this.#tokens[this.#currentTokenChunk][this.#currentTokenIndex++]
    } else {
      this.#currentToken = null
    }

    return this.#currentToken
  }

  // Skip the provided tokens in the current chunk
  #skip (...tokensToSkip) {
    let token = this.#tokens[this.#currentTokenChunk][this.#currentTokenIndex]
    while (token && ArrayPrototypeIncludes(tokensToSkip, token.kind)) {
      // pre-increment to skip current tokens but make sure we don't advance index on the last iteration
      token = this.#tokens[this.#currentTokenChunk][++this.#currentTokenIndex]
    }
  }

  #readNextLiterals () {
    const literals = []
    let nextToken = this.#peek(false)

    // Read all literal, numeric, whitespace and escape tokens until we hit a different token
    // or reach end of current chunk
    while (
      nextToken &&
      ArrayPrototypeIncludes(
        [
          TokenKind.LITERAL,
          TokenKind.NUMERIC,
          TokenKind.DASH,
          TokenKind.PLUS,
          TokenKind.WHITESPACE,
          TokenKind.ESCAPE
        ],
        nextToken.kind
      )
    ) {
      const word = this.#next(false).value

      // Don't output escaped characters
      if (nextToken.kind !== TokenKind.ESCAPE) {
        ArrayPrototypePush(literals, word)
      }

      nextToken = this.#peek(false)
    }

    return ArrayPrototypeJoin(literals, '')
  }

  #countLeadingSpacesInCurrentChunk (chunk) {
    // Count the number of whitespace tokens in the chunk, starting from the first token
    let whitespaceCount = 0
    while (chunk?.[whitespaceCount]?.kind === TokenKind.WHITESPACE) {
      whitespaceCount++
    }
    return whitespaceCount
  }

  #addDiagnosticsToLastTestPoint (currentNode) {
    const { length, [length - 1]: lastTestPoint } = this.#bufferedTestPoints

    // Diagnostic nodes are only added to Test points of the same nesting level
    if (lastTestPoint && lastTestPoint.nesting === currentNode.nesting) {
      lastTestPoint.node.time = this.#lastTestPointDetails.duration

      // TODO(@manekinekko): figure out where to put the other diagnostic properties
      // See https://github.com/nodejs/node/pull/44952
      lastTestPoint.node.diagnostics = lastTestPoint.node.diagnostics || []

      ArrayPrototypeForEach(currentNode.node.diagnostics, (diagnostic) => {
        // Avoid adding empty diagnostics
        if (diagnostic) {
          ArrayPrototypePush(lastTestPoint.node.diagnostics, diagnostic)
        }
      })

      this.#bufferedTestPoints = []
    }

    return lastTestPoint
  }

  #flushBufferedTestPointNode (shouldClearBuffer = true) {
    if (this.#bufferedTestPoints.length > 0) {
      this.#emit(this.#bufferedTestPoints[0])

      if (shouldClearBuffer) {
        this.#bufferedTestPoints = []
      }
    }
  }

  #addCommentsToCurrentNode (currentNode) {
    if (this.#bufferedComments.length > 0) {
      currentNode.comments = ArrayPrototypeMap(
        this.#bufferedComments,
        (c) => c.node.comment
      )
      this.#bufferedComments = []
    }

    return currentNode
  }

  #flushBufferedComments (shouldClearBuffer = true) {
    if (this.#bufferedComments.length > 0) {
      ArrayPrototypeForEach(this.#bufferedComments, (node) => {
        this.#emit(node)
      })

      if (shouldClearBuffer) {
        this.#bufferedComments = []
      }
    }
  }

  #getCurrentIndentationLevel (chunk) {
    const whitespaceCount = this.#countLeadingSpacesInCurrentChunk(chunk)
    return (whitespaceCount / this.#kSubtestBlockIndentationFactor) | 0
  }

  #emit (node) {
    if (node.kind !== TokenKind.EOF) {
      ArrayPrototypePush(this.#flatAST, node)
      this.push({
        __proto__: null,
        ...node
      })
    }
  }

  #emitOrBufferCurrentNode (currentNode) {
    currentNode = {
      ...currentNode,
      nesting: this.#subTestNestingLevel,
      lexeme: this.#currentChunkAsString
    }

    switch (currentNode.kind) {
      // Emit these nodes
      case TokenKind.UNKNOWN:
        if (!currentNode.node.value) {
          // Ignore unrecognized and empty nodes
          break
        }
        // falls through

      case TokenKind.TAP_PLAN:
      case TokenKind.TAP_PRAGMA:
      case TokenKind.TAP_VERSION:
      case TokenKind.TAP_BAIL_OUT:
      case TokenKind.TAP_SUBTEST_POINT:
        // Check if we have a buffered test point, and if so, emit it
        this.#flushBufferedTestPointNode()

        // If we have buffered comments, add them to the current node
        currentNode = this.#addCommentsToCurrentNode(currentNode)

        // Emit the current node
        this.#emit(currentNode)
        break

      // By default, we buffer the next test point node in case we have a diagnostic
      // to add to it in the next iteration
      // Note: in case we hit and EOF, we flush the comments buffer (see _flush())
      case TokenKind.TAP_TEST_POINT:
        // In case of an already buffered test point, we flush it and buffer the current one
        // Because diagnostic nodes are only added to the last processed test point
        this.#flushBufferedTestPointNode()

        // Buffer this node (and also add any pending comments to it)
        ArrayPrototypePush(
          this.#bufferedTestPoints,
          this.#addCommentsToCurrentNode(currentNode)
        )
        break

      // Keep buffering comments until we hit a non-comment node, then add them to the that node
      // Note: in case we hit and EOF, we flush the comments buffer (see _flush())
      case TokenKind.COMMENT:
        ArrayPrototypePush(this.#bufferedComments, currentNode)
        break

      // Diagnostic nodes are added to Test points of the same nesting level
      case TokenKind.TAP_YAML_END:
        // Emit either the last updated test point (w/ diagnostics) or the current diagnostics node alone
        this.#emit(
          this.#addDiagnosticsToLastTestPoint(currentNode) || currentNode
        )
        break

      // In case we hit an EOF, we emit it to indicate the end of the stream
      case TokenKind.EOF:
        this.#emit(currentNode)
        break
    }
  }

  #serializeChunk (chunk) {
    return ArrayPrototypeJoin(
      ArrayPrototypeMap(
        // Exclude NewLine and EOF tokens
        ArrayPrototypeFilter(
          chunk,
          (token) =>
            token.kind !== TokenKind.NEWLINE && token.kind !== TokenKind.EOF
        ),
        (token) => token.value
      ),
      ''
    )
  }

  // --------------------------------------------------------------------------//
  // ------------------------------ Parser rules ------------------------------//
  // --------------------------------------------------------------------------//

  // TAPDocument := Version Plan Body | Version Body Plan
  #TAPDocument (tokenChunks) {
    this.#currentChunkAsString = this.#serializeChunk(tokenChunks)
    const firstToken = this.#peek(false)

    if (firstToken) {
      const { kind } = firstToken

      switch (kind) {
        case TokenKind.TAP:
          return this.#Version()
        case TokenKind.NUMERIC:
          return this.#Plan()
        case TokenKind.TAP_TEST_OK:
        case TokenKind.TAP_TEST_NOTOK:
          return this.#TestPoint()
        case TokenKind.COMMENT:
        case TokenKind.HASH:
          return this.#Comment()
        case TokenKind.TAP_PRAGMA:
          return this.#Pragma()
        case TokenKind.WHITESPACE:
          return this.#YAMLBlock()
        case TokenKind.LITERAL:
          // Check for "Bail out!" literal (case insensitive)
          if (
            RegExpPrototypeExec(/^Bail\s+out!/i, this.#currentChunkAsString)
          ) {
            return this.#Bailout()
          } else if (this.#isYAMLBlock) {
            return this.#YAMLBlock()
          }

          // Read token because error needs the last token details
          this.#next(false)
          this.#error('Expected a valid token')

          break
        case TokenKind.EOF:
          return firstToken

        case TokenKind.NEWLINE:
          // Consume and ignore NewLine token
          return this.#next(false)
        default:
          // Read token because error needs the last token details
          this.#next(false)
          this.#error('Expected a valid token')
      }
    }

    const node = {
      kind: TokenKind.UNKNOWN,
      node: {
        value: this.#currentChunkAsString
      }
    }

    // We make sure the emitted node has the same shape
    // both in sync and async parsing (for the stream interface)
    return node
  }

  // ----------------Version----------------
  // Version := "TAP version Number\n"
  #Version () {
    const tapToken = this.#peek()

    if (tapToken.kind === TokenKind.TAP) {
      this.#next() // Consume the TAP token
    } else {
      this.#error('Expected "TAP" keyword')
    }

    const versionToken = this.#peek()
    if (versionToken?.kind === TokenKind.TAP_VERSION) {
      this.#next() // Consume the version token
    } else {
      this.#error('Expected "version" keyword')
    }

    const numberToken = this.#peek()
    if (numberToken?.kind === TokenKind.NUMERIC) {
      const version = this.#next().value
      const node = { kind: TokenKind.TAP_VERSION, node: { version } }
      return node
    }
    this.#error('Expected a version number')
  }

  // ----------------Plan----------------
  // Plan := "1.." (Number) (" # " Reason)? "\n"
  #Plan () {
    // Even if specs mention plan starts at 1, we need to make sure we read the plan start value
    // in case of a missing or invalid plan start value
    const planStart = this.#next()

    if (planStart.kind !== TokenKind.NUMERIC) {
      this.#error('Expected a plan start count')
    }

    const planToken = this.#next()
    if (planToken?.kind !== TokenKind.TAP_PLAN) {
      this.#error('Expected ".." symbol')
    }

    const planEnd = this.#next()
    if (planEnd?.kind !== TokenKind.NUMERIC) {
      this.#error('Expected a plan end count')
    }

    const plan = {
      start: planStart.value,
      end: planEnd.value
    }

    // Read optional reason
    const hashToken = this.#peek()
    if (hashToken) {
      if (hashToken.kind === TokenKind.HASH) {
        this.#next() // skip hash
        plan.reason = StringPrototypeTrim(this.#readNextLiterals())
      } else if (hashToken.kind === TokenKind.LITERAL) {
        this.#error('Expected "#" symbol before a reason')
      }
    }

    const node = {
      kind: TokenKind.TAP_PLAN,
      node: plan
    }

    return node
  }

  // ----------------TestPoint----------------
  // TestPoint := ("not ")? "ok" (" " Number)? ((" -")? (" " Description) )? (" " Directive)? "\n" (YAMLBlock)?
  // Directive   := " # " ("todo" | "skip") (" " Reason)?
  // YAMLBlock   := "  ---\n" (YAMLLine)* "  ...\n"
  // YAMLLine    := "  " (YAML)* "\n"

  // Test Status: ok/not ok (required)
  // Test number (recommended)
  // Description (recommended, prefixed by " - ")
  // Directive (only when necessary)
  #TestPoint () {
    const notToken = this.#peek()
    let isTestFailed = false

    if (notToken.kind === TokenKind.TAP_TEST_NOTOK) {
      this.#next() // skip "not" token
      isTestFailed = true
    }

    const okToken = this.#next()
    if (okToken.kind !== TokenKind.TAP_TEST_OK) {
      this.#error('Expected "ok" or "not ok" keyword')
    }

    // Read optional test number
    let numberToken = this.#peek()
    if (numberToken && numberToken.kind === TokenKind.NUMERIC) {
      numberToken = this.#next().value
    } else {
      numberToken = '' // Set an empty ID to indicate that the test hasn't provider an ID
    }

    const test = {
      // Output both failed and passed properties to make it easier for the checker to detect the test status
      status: {
        fail: isTestFailed,
        pass: !isTestFailed,
        todo: false,
        skip: false
      },
      id: numberToken,
      description: '',
      reason: '',
      time: 0,
      diagnostics: []
    }

    // Read optional description prefix " - "
    const descriptionDashToken = this.#peek()
    if (descriptionDashToken && descriptionDashToken.kind === TokenKind.DASH) {
      this.#next() // skip dash
    }

    // Read optional description
    if (this.#peek()) {
      const description = StringPrototypeTrim(this.#readNextLiterals())
      if (description) {
        test.description = description
      }
    }

    // Read optional directive and reason
    const hashToken = this.#peek()
    if (hashToken && hashToken.kind === TokenKind.HASH) {
      this.#next() // skip hash
    }

    let todoOrSkipToken = this.#peek()
    if (todoOrSkipToken && todoOrSkipToken.kind === TokenKind.LITERAL) {
      if (RegExpPrototypeExec(/todo/i, todoOrSkipToken.value)) {
        todoOrSkipToken = 'todo'
        this.#next() // skip token
      } else if (RegExpPrototypeExec(/skip/i, todoOrSkipToken.value)) {
        todoOrSkipToken = 'skip'
        this.#next() // skip token
      }
    }

    const reason = StringPrototypeTrim(this.#readNextLiterals())
    if (todoOrSkipToken) {
      if (reason) {
        test.reason = reason
      }

      test.status.todo = todoOrSkipToken === 'todo'
      test.status.skip = todoOrSkipToken === 'skip'
    }

    const node = {
      kind: TokenKind.TAP_TEST_POINT,
      node: test
    }

    return node
  }

  // ----------------Bailout----------------
  // BailOut := "Bail out!" (" " Reason)? "\n"
  #Bailout () {
    this.#next() // skip "Bail"
    this.#next() // skip "out!"

    // Read optional reason
    const hashToken = this.#peek()
    if (hashToken && hashToken.kind === TokenKind.HASH) {
      this.#next() // skip hash
    }

    const reason = StringPrototypeTrim(this.#readNextLiterals())

    const node = {
      kind: TokenKind.TAP_BAIL_OUT,
      node: { bailout: true, reason }
    }

    return node
  }

  // ----------------Comment----------------
  // Comment := ^ (" ")* "#" [^\n]* "\n"
  #Comment () {
    const commentToken = this.#next()
    if (
      commentToken.kind !== TokenKind.COMMENT &&
      commentToken.kind !== TokenKind.HASH
    ) {
      this.#error('Expected "#" symbol')
    }

    const commentContent = this.#peek()
    if (commentContent) {
      if (RegExpPrototypeExec(/^Subtest:/i, commentContent.value) !== null) {
        this.#next() // skip subtest keyword
        const name = StringPrototypeTrim(this.#readNextLiterals())
        const node = {
          kind: TokenKind.TAP_SUBTEST_POINT,
          node: {
            name
          }
        }

        return node
      }

      const comment = StringPrototypeTrim(this.#readNextLiterals())
      const node = {
        kind: TokenKind.COMMENT,
        node: { comment }
      }

      return node
    }

    // If there is no comment content, then we ignore the current node
  }

  // ----------------YAMLBlock----------------
  // YAMLBlock := "  ---\n" (YAMLLine)* "  ...\n"
  #YAMLBlock () {
    const space1 = this.#peek(false)
    if (space1 && space1.kind === TokenKind.WHITESPACE) {
      this.#next(false) // skip 1st space
    }

    const space2 = this.#peek(false)
    if (space2 && space2.kind === TokenKind.WHITESPACE) {
      this.#next(false) // skip 2nd space
    }

    const yamlBlockSymbol = this.#peek(false)

    if (yamlBlockSymbol.kind === TokenKind.WHITESPACE) {
      if (this.#isYAMLBlock === false) {
        this.#next(false) // skip 3rd space
        this.#error('Expected valid YAML indentation (2 spaces)')
      }
    }

    if (yamlBlockSymbol.kind === TokenKind.TAP_YAML_START) {
      if (this.#isYAMLBlock) {
        // Looks like we have another YAML start block, but we didn't close the previous one
        this.#error('Unexpected YAML start marker')
      }

      this.#isYAMLBlock = true
      this.#yamlCurrentIndentationLevel = this.#subTestNestingLevel
      this.#lastTestPointDetails = {}

      // Consume the YAML start marker
      this.#next(false) // skip "---"

      // No need to pass this token to the stream interface
      return
    } else if (yamlBlockSymbol.kind === TokenKind.TAP_YAML_END) {
      this.#next(false) // skip "..."

      if (!this.#isYAMLBlock) {
        // Looks like we have an YAML end block, but we didn't encounter any YAML start marker
        this.#error('Unexpected YAML end marker')
      }

      this.#isYAMLBlock = false

      const diagnostics = this.#yamlBlockBuffer
      this.#yamlBlockBuffer = [] // Free the buffer for the next YAML block

      const node = {
        kind: TokenKind.TAP_YAML_END,
        node: {
          diagnostics
        }
      }

      return node
    }

    if (this.#isYAMLBlock) {
      this.#YAMLLine()
    } else {
      return {
        kind: TokenKind.UNKNOWN,
        node: {
          value: yamlBlockSymbol.value
        }
      }
    }
  }

  // ----------------YAMLLine----------------
  // YAMLLine := "  " (YAML)* "\n"
  #YAMLLine () {
    const yamlLiteral = this.#readNextLiterals()
    const { 0: key, 1: value } = StringPrototypeSplit(yamlLiteral, ':', 2)

    // Note that this.#lastTestPointDetails has been cleared when we encounter a YAML start marker

    switch (key) {
      case 'duration_ms':
        this.#lastTestPointDetails.duration = Number(value)
        break
      // Below are diagnostic properties introduced in https://github.com/nodejs/node/pull/44952
      case 'expected':
        this.#lastTestPointDetails.expected = Boolean(value)
        break
      case 'actual':
        this.#lastTestPointDetails.actual = Boolean(value)
        break
      case 'operator':
        this.#lastTestPointDetails.operator = String(value)
        break
    }

    ArrayPrototypePush(this.#yamlBlockBuffer, yamlLiteral)
  }

  // ----------------PRAGMA----------------
  // Pragma := "pragma " [+-] PragmaKey "\n"
  // PragmaKey := ([a-zA-Z0-9_-])+
  // TODO(@manekinekko): pragmas are parsed but not used yet! TapChecker() should take care of that.
  #Pragma () {
    const pragmaToken = this.#next()
    if (pragmaToken.kind !== TokenKind.TAP_PRAGMA) {
      this.#error('Expected "pragma" keyword')
    }

    const pragmas = {}

    let nextToken = this.#peek()
    while (
      nextToken &&
      ArrayPrototypeIncludes(
        [TokenKind.NEWLINE, TokenKind.EOF, TokenKind.EOL],
        nextToken.kind
      ) === false
    ) {
      let isEnabled = true
      const pragmaKeySign = this.#next()
      if (pragmaKeySign.kind === TokenKind.PLUS) {
        isEnabled = true
      } else if (pragmaKeySign.kind === TokenKind.DASH) {
        isEnabled = false
      } else {
        this.#error('Expected "+" or "-" before pragma keys')
      }

      const pragmaKeyToken = this.#peek()
      if (pragmaKeyToken.kind !== TokenKind.LITERAL) {
        this.#error('Expected pragma key')
      }

      let pragmaKey = this.#next().value

      // In some cases, pragma key can be followed by a comma separator,
      // so we need to remove it
      pragmaKey = StringPrototypeReplaceAll(pragmaKey, ',', '')

      pragmas[pragmaKey] = isEnabled
      nextToken = this.#peek()
    }

    const node = {
      kind: TokenKind.TAP_PRAGMA,
      node: {
        pragmas
      }
    }

    return node
  }
}

module.exports = { TapParser }
