import type * as messages from '@cucumber/messages'
import AstNode from './AstNode'
import { AstBuilderException } from './Errors'
import type { IAstBuilder } from './IAstBuilder'
import type IToken from './IToken'
import { RuleType, TokenType } from './Parser'

export default class AstBuilder implements IAstBuilder<AstNode, TokenType, RuleType> {
  stack: AstNode[]
  comments: messages.Comment[]
  readonly newId: messages.IdGenerator.NewId

  constructor(newId: messages.IdGenerator.NewId) {
    this.newId = newId
    if (!newId) {
      throw new Error('No newId')
    }
    this.reset()
  }

  reset() {
    this.stack = [new AstNode(RuleType.None)]
    this.comments = []
  }

  startRule(ruleType: RuleType) {
    this.stack.push(new AstNode(ruleType))
  }

  endRule() {
    const node = this.stack.pop()
    const transformedNode = this.transformNode(node)
    this.currentNode().add(node.ruleType, transformedNode)
  }

  build(token: IToken<TokenType>) {
    if (token.matchedType === TokenType.Comment) {
      this.comments.push({
        location: this.getLocation(token),
        text: token.matchedText,
      })
    } else {
      this.currentNode().add(token.matchedType, token)
    }
  }

  getResult() {
    return this.currentNode().getSingle(RuleType.GherkinDocument)
  }

  currentNode() {
    return this.stack[this.stack.length - 1]
  }

  getLocation(token: IToken<TokenType>, column?: number): messages.Location {
    return !column ? token.location : { line: token.location.line, column }
  }

  getTags(node: AstNode) {
    const tags: messages.Tag[] = []
    const tagsNode = node.getSingle(RuleType.Tags)
    if (!tagsNode) {
      return tags
    }
    const tokens = tagsNode.getTokens(TokenType.TagLine)
    for (const token of tokens) {
      for (const tagItem of token.matchedItems) {
        tags.push({
          location: this.getLocation(token, tagItem.column),
          name: tagItem.text,
          id: this.newId(),
        })
      }
    }
    return tags
  }

  getCells(tableRowToken: IToken<TokenType>) {
    return tableRowToken.matchedItems.map((cellItem) => ({
      location: this.getLocation(tableRowToken, cellItem.column),
      value: cellItem.text,
    }))
  }

  getDescription(node: AstNode) {
    return node.getSingle(RuleType.Description) || ''
  }

  getSteps(node: AstNode) {
    return node.getItems(RuleType.Step)
  }

  getTableRows(node: AstNode) {
    const rows = node.getTokens(TokenType.TableRow).map((token) => ({
      id: this.newId(),
      location: this.getLocation(token),
      cells: this.getCells(token),
    }))
    this.ensureCellCount(rows)
    return rows.length === 0 ? [] : rows
  }

  ensureCellCount(rows: messages.TableRow[]) {
    if (rows.length === 0) {
      return
    }
    const cellCount = rows[0].cells.length

    rows.forEach((row) => {
      if (row.cells.length !== cellCount) {
        throw AstBuilderException.create('inconsistent cell count within the table', row.location)
      }
    })
  }

  transformNode(node: AstNode) {
    switch (node.ruleType) {
      case RuleType.Step: {
        const stepLine = node.getToken(TokenType.StepLine)
        const dataTable = node.getSingle(RuleType.DataTable)
        const docString = node.getSingle(RuleType.DocString)

        const location = this.getLocation(stepLine)
        const step: messages.Step = {
          id: this.newId(),
          location,
          keyword: stepLine.matchedKeyword,
          keywordType: stepLine.matchedKeywordType,
          text: stepLine.matchedText,
          dataTable: dataTable,
          docString: docString,
        }
        return step
      }
      case RuleType.DocString: {
        const separatorToken = node.getTokens(TokenType.DocStringSeparator)[0]
        const mediaType =
          separatorToken.matchedText.length > 0 ? separatorToken.matchedText : undefined
        const lineTokens = node.getTokens(TokenType.Other)
        const content = lineTokens.map((t) => t.matchedText).join('\n')

        const result: messages.DocString = {
          location: this.getLocation(separatorToken),
          content,
          delimiter: separatorToken.matchedKeyword,
        }
        // conditionally add this like this (needed to make tests pass on node 0.10 as well as 4.0)
        if (mediaType) {
          result.mediaType = mediaType
        }
        return result
      }
      case RuleType.DataTable: {
        const rows = this.getTableRows(node)
        const dataTable: messages.DataTable = {
          location: rows[0].location,
          rows,
        }
        return dataTable
      }
      case RuleType.Background: {
        const backgroundLine = node.getToken(TokenType.BackgroundLine)
        const description = this.getDescription(node)
        const steps = this.getSteps(node)

        const background: messages.Background = {
          id: this.newId(),
          location: this.getLocation(backgroundLine),
          keyword: backgroundLine.matchedKeyword,
          name: backgroundLine.matchedText,
          description,
          steps,
        }
        return background
      }
      case RuleType.ScenarioDefinition: {
        const tags = this.getTags(node)
        const scenarioNode = node.getSingle(RuleType.Scenario)
        const scenarioLine = scenarioNode.getToken(TokenType.ScenarioLine)
        const description = this.getDescription(scenarioNode)
        const steps = this.getSteps(scenarioNode)
        const examples = scenarioNode.getItems(RuleType.ExamplesDefinition)
        const scenario: messages.Scenario = {
          id: this.newId(),
          tags,
          location: this.getLocation(scenarioLine),
          keyword: scenarioLine.matchedKeyword,
          name: scenarioLine.matchedText,
          description,
          steps,
          examples,
        }
        return scenario
      }
      case RuleType.ExamplesDefinition: {
        const tags = this.getTags(node)
        const examplesNode = node.getSingle(RuleType.Examples)
        const examplesLine = examplesNode.getToken(TokenType.ExamplesLine)
        const description = this.getDescription(examplesNode)
        const examplesTable: messages.TableRow[] = examplesNode.getSingle(RuleType.ExamplesTable)

        const examples: messages.Examples = {
          id: this.newId(),
          tags,
          location: this.getLocation(examplesLine),
          keyword: examplesLine.matchedKeyword,
          name: examplesLine.matchedText,
          description,
          tableHeader: examplesTable ? examplesTable[0] : undefined,
          tableBody: examplesTable ? examplesTable.slice(1) : [],
        }
        return examples
      }
      case RuleType.ExamplesTable: {
        return this.getTableRows(node)
      }
      case RuleType.Description: {
        let lineTokens = node.getTokens(TokenType.Other)
        // Trim trailing empty lines
        let end = lineTokens.length
        while (end > 0 && lineTokens[end - 1].line.trimmedLineText === '') {
          end--
        }
        lineTokens = lineTokens.slice(0, end)

        return lineTokens.map((token) => token.matchedText).join('\n')
      }

      case RuleType.Feature: {
        const header = node.getSingle(RuleType.FeatureHeader)
        if (!header) {
          return null
        }
        const tags = this.getTags(header)
        const featureLine = header.getToken(TokenType.FeatureLine)
        if (!featureLine) {
          return null
        }
        const children: messages.FeatureChild[] = []
        const background = node.getSingle(RuleType.Background)
        if (background) {
          children.push({
            background,
          })
        }
        for (const scenario of node.getItems(RuleType.ScenarioDefinition)) {
          children.push({
            scenario,
          })
        }
        for (const rule of node.getItems(RuleType.Rule)) {
          children.push({
            rule,
          })
        }

        const description = this.getDescription(header)
        const language = featureLine.matchedGherkinDialect

        const feature: messages.Feature = {
          tags,
          location: this.getLocation(featureLine),
          language,
          keyword: featureLine.matchedKeyword,
          name: featureLine.matchedText,
          description,
          children,
        }
        return feature
      }

      case RuleType.Rule: {
        const header = node.getSingle(RuleType.RuleHeader)
        if (!header) {
          return null
        }
        const ruleLine = header.getToken(TokenType.RuleLine)
        if (!ruleLine) {
          return null
        }
        const tags = this.getTags(header)
        const children: messages.RuleChild[] = []
        const background = node.getSingle(RuleType.Background)
        if (background) {
          children.push({
            background,
          })
        }
        for (const scenario of node.getItems(RuleType.ScenarioDefinition)) {
          children.push({
            scenario,
          })
        }
        const description = this.getDescription(header)

        const rule: messages.Rule = {
          id: this.newId(),
          location: this.getLocation(ruleLine),
          keyword: ruleLine.matchedKeyword,
          name: ruleLine.matchedText,
          description,
          children,
          tags,
        }
        return rule
      }
      case RuleType.GherkinDocument: {
        const feature = node.getSingle(RuleType.Feature)

        const gherkinDocument: messages.GherkinDocument = {
          feature,
          comments: this.comments,
        }
        return gherkinDocument
      }
      default:
        return node
    }
  }
}
