//
// Copyright (c) 2024, Brian Frank and Andy Frank
// Licensed under the Academic Free License version 3.0
//
// History:
//   02 Nov 2024  Matthew Giannini  Creation
//

**
** Extension for GFM tables using "|" pipes. (GitHub Flavored Markdown).
**
** See [Tables (extension) in GitHub Flavored Markdown Spec]`https://github.github.com/gfm/#tables-extension`
**
@Js
const class TablesExt : MarkdownExt
{
  override Void extendParser(ParserBuilder builder)
  {
    builder.customBlockParserFactory(TableParser.factory)
  }

  override Void extendHtml(HtmlRendererBuilder builder)
  {
    builder.nodeRendererFactory |cx->NodeRenderer| { TableRenderer(cx) }
  }

  override Void extendMarkdown(MarkdownRendererBuilder builder)
  {
    builder
      .nodeRendererFactory |cx->NodeRenderer| { MarkdownTableRenderer(cx) }
      .withSpecialChars(['|'])
  }
}

**************************************************************************
** Nodes
**************************************************************************

** Table block containing a `TableHead` and optionally a `TableBody`
@Js
class Table : CustomBlock { }

** Head part of a `Table` containing `TableRow`s
@Js
class TableHead : CustomNode { }

** Body part of a `Table` containing `TableRow`s
@Js
class TableBody : CustomNode { }

** Table row of a `TableHead` or `TableBody` containing `TableCell`s
@Js
class TableRow : CustomNode { }

** Table cell of a `TableRow` containing inline nodes
@Js
class TableCell : CustomNode
{
  new make() : this.makeFields(false, Alignment.unspecified, 0) { }
  new makeFields(Bool header, Alignment alignment, Int width)
  {
    this.header = header
    this.alignment = alignment
    this.width = width
  }

  ** Is the cell a header or not
  Bool header

  ** The cell alignment
  Alignment alignment

  ** The cell width (the number of dash and colon characters in the delimiter
  ** row of the table for this column)
  Int width

  override protected Str toStrAttributes() { "(header=${header}, align=${alignment}, width=${width})" }
}

@Js
enum class Alignment { unspecified, left, center, right }

**************************************************************************
** TableParser
**************************************************************************

@Js
internal class TableParser : BlockParser
{
  new make(TableCell[] columns, SourceLine headerLine)
  {
    this.columns = columns
    this.rowLines.add(headerLine)
  }

  private TableCell[] columns
  private SourceLine[] rowLines := [,]

  override Table block := Table() { private set }

  override Bool canHaveLazyContinuationLines := true { private set }

  override BlockContinue? tryContinue(ParserState state)
  {
    content := state.line.content
    pipe := Chars.find('|', content, state.nextNonSpaceIndex)
    if (pipe != -1)
    {
      if (pipe == state.nextNonSpaceIndex)
      {
        // if we *only* have a pipe character (and whitespace), that is not a valid
        // table row and ends the table.
        if (Chars.skipSpaceTab(content, pipe+1) == content.size)
        {
          // we also don't want the pipe to be added via lazy continuation
          this.canHaveLazyContinuationLines = false
          return BlockContinue.none
        }
      }
      return BlockContinue.atIndex(state.index)
    }
    return BlockContinue.none
  }

  override Void addLine(SourceLine line) { rowLines.add(line) }

  override Void parseInlines(InlineParser parser)
  {
    sourceSpans := block.sourceSpans

    headerSourceSpan := !sourceSpans.isEmpty ? sourceSpans.first : null
    head := TableHead()
    head.addSourceSpan(headerSourceSpan)
    block.appendChild(head)

    headerRow := TableRow()
    headerRow.setSourceSpans(head.sourceSpans)
    head.appendChild(headerRow)

    headerCells := split(rowLines[0])
    headerCells.each |SourceLine cell, i|
    {
      tableCell := parseCell(cell, i, parser)
      tableCell.header = true
      headerRow.appendChild(tableCell)
    }

    TableBody? body := null
    // body starts at index 2. 0 is header, 1 is separator
    for (rowIndex := 2; rowIndex < rowLines.size; ++rowIndex)
    {
      rowLine := rowLines[rowIndex]
      sourceSpan := rowIndex < sourceSpans.size ? sourceSpans[rowIndex] : null
      cells := split(rowLine)
      row := TableRow()
      row.addSourceSpan(sourceSpan)

      // body can not have more columns than head
      for (i := 0; i < headerCells.size; ++i)
      {
        cell := i < cells.size ? cells[i] : SourceLine("", null)
        tableCell := parseCell(cell, i, parser)
        row.appendChild(tableCell)
      }

      if (body == null)
      {
        // it's valid to have a table without a body. in that case, don't add
        // an empty TableBody node
        body = TableBody()
        block.appendChild(body)
      }
      body.appendChild(row)
      body.addSourceSpan(sourceSpan)
    }
  }

  private TableCell parseCell(SourceLine cell, Int column, InlineParser parser)
  {
    tableCell := TableCell()
    tableCell.addSourceSpan(cell.sourceSpan)

    if (column < columns.size)
    {
      info := columns[column]
      tableCell.alignment = info.alignment
      tableCell.width = info.width
    }

    content := cell.content
    start := Chars.skipSpaceTab(content)
    end := Chars.skipSpaceTabBackwards(content, content.size-1, start)
    parser.parse(SourceLines(cell.substring(start, end+1)), tableCell)

    return tableCell
  }

  internal static SourceLine[] split(SourceLine line)
  {
    row := line.content
    nonSpace := Chars.skipSpaceTab(row)
    cellStart := nonSpace
    cellEnd := row.size
    if (row[nonSpace] == '|')
    {
      // this row has leadin/trailing pipes - skip the leading pipe
      cellStart = nonSpace + 1
      // strip whitespace from the end but not the pipe or we could miss an empty '||' cell
      nonSpaceEnd := Chars.skipSpaceTabBackwards(row, row.size - 1, cellStart)
      cellEnd = nonSpaceEnd + 1
    }
    cells := SourceLine[,]
    sb := StrBuf()
    for (i := cellStart; i < cellEnd; ++i)
    {
      c := row[i]
      switch (c)
      {
        case '\\':
          if (i + 1 < cellEnd && row[i+1] == '|')
          {
            // pipe is special for table parsing. an escaped pipe doesn't result in
            // a new cell, but is passed down to inline parsing as an unescaped pipe.
            // Note that this applies even for the '\|' in an input like '\\|' - in
            // other words, table parsing doesn't support escaping backslashes
            sb.addChar('|')
            ++i
          }
          else
          {
            // preserve backslash before other characters or at end of line
            sb.addChar('\\')
          }
        case '|':
          content := sb.toStr
          cells.add(SourceLine(content, line.substring(cellStart, i).sourceSpan))
          sb.clear
          // + 1 to skip the pipe itself for the next cell's span
          cellStart = i + 1
        default:
          sb.addChar(c)
      }
    }
    if (sb.size > 0)
    {
      content := sb.toStr
      cells.add(SourceLine(content, line.substring(cellStart, line.content.size).sourceSpan))
    }
    return cells
  }

  static const BlockParserFactory factory := TableParserFactory()
}

**************************************************************************
** TableParserFactory
**************************************************************************

@Js
internal const class TableParserFactory : BlockParserFactory
{
  override BlockStart? tryStart(ParserState state, MatchedBlockParser parser)
  {
    paraLines := parser.paragraphLines.lines
    if (paraLines.size == 1 && Chars.find('|', paraLines.first.content, 0) != -1)
    {
      line := state.line
      separatorLine := line.substring(state.index, line.content.size)
      columns := parseSeparator(separatorLine.content)
      if (columns != null && !columns.isEmpty)
      {
        paragraph := paraLines[0]
        headerCells := TableParser.split(paragraph)
        if (columns.size >= headerCells.size)
        {
          return BlockStart.of([TableParser(columns, paragraph)])
            .atIndex(state.index)
            .replaceActiveBlockParser
        }
      }
    }
    return BlockStart.none
  }

  ** Examples of valid separators:
  **
  ** |-
  ** -|
  ** |-|
  ** -|-
  ** |-|-|
  ** --- | ---
  private static TableCell[]? parseSeparator(Str s)
  {
    // we only care about alignment and width, but re-use this type and ignore header field
    columns := TableCell[,]
    pipes := 0
    valid := false
    i := 0
    width := 0
    while (i < s.size)
    {
      c := s[i]
      switch (c)
      {
        case '|':
          ++i
          ++pipes
          if (pipes > 1)
          {
            // more than one adjacent pipe not allowed
            return null
          }
          // Need at least one pipe, even for a one column table
          valid = true
        case '-':
        case ':':
          if (pipes == 0 && !columns.isEmpty)
          {
            // Need a pipe after the first column (first column doesn't need to start
            // with one)
            return null
          }
          left  := false
          right := false
          if (c == ':') { left = true; ++i; ++width }
          haveDash := false
          while (i < s.size && s[i] == '-')
          {
            ++i
            ++width
            haveDash = true
          }
          if (!haveDash)
          {
            // need at least one dash
            return null
          }
          if (i < s.size && s[i] == ':') { right = true; ++i; ++width }
          columns.add(TableCell(false, toAlignment(left, right), width))
          width = 0
          // next, need another pipe
          pipes = 0
        case ' ':
        case '\t':
          // white space is allowed between pipes and columns
          ++i
        default:
          // any other character is invalid
          return null
      }
    }
    return valid ? columns : null
  }

  private static Alignment toAlignment(Bool left, Bool right)
  {
    if (left && right) return Alignment.center
    else if (left) return Alignment.left
    else if (right) return Alignment.right
    else return Alignment.unspecified
  }
}

**************************************************************************
** TableNodeRenderer
**************************************************************************

@Js
internal class TableRenderer : NodeRenderer, Visitor
{
  new make(HtmlContext cx)
  {
    this.cx = cx
    this.html = cx.writer
  }

  private HtmlContext cx
  private HtmlWriter html

  override const Type[] nodeTypes := [
    Table#,
    TableHead#,
    TableBody#,
    TableRow#,
    TableCell#,
  ]

  override Void render(Node node) { node.walk(this) }

  virtual Void visitTable(Table table)
  {
    renderTableNode(table, "table")
  }

  virtual Void visitTableHead(TableHead head)
  {
    renderTableNode(head, "thead")
  }

  virtual Void visitTableBody(TableBody body)
  {
    renderTableNode(body, "tbody")
  }

  virtual Void visitTableRow(TableRow row)
  {
    renderTableNode(row, "tr")
  }

  virtual Void visitTableCell(TableCell cell)
  {
    tagName := cell.header ? "th" : "td"
    renderTableNode(cell, tagName, cellAttrs(cell, tagName))
  }

  private Void renderTableNode(Node node, Str tagName, [Str:Str?] attrs := [:])
  {
    html.line
    html.tag(tagName, toAttrs(node, tagName, attrs))
    renderChildren(node)
    html.tag("/${tagName}")
    html.line
  }

  private Void renderChildren(Node parent)
  {
    node := parent.firstChild
    while (node != null)
    {
      next := node.next
      cx.render(node)
      node = next
    }
  }

  private [Str:Str] cellAttrs(TableCell cell, Str tagName)
  {
    attrs := [Str:Str][:] { ordered = true }
    if (cell.alignment !== Alignment.unspecified)
      attrs["align"] = cell.alignment.name.lower
    return attrs
  }

  private [Str:Str?] toAttrs(Node node, Str tagName, [Str:Str?] attrs)
  {
    cx.extendAttrs(node, tagName, attrs)
  }
}

**************************************************************************
** TableNodeRenderer
**************************************************************************

@Js
internal class MarkdownTableRenderer : NodeRenderer, Visitor
{
  new make(MarkdownContext cx)
  {
    this.cx = cx
    this.writer = cx.writer
  }

  private static const |Int->Bool| pipe := |c->Bool| { c == '|' }

  private MarkdownContext cx
  private MarkdownWriter writer
  private Alignment[] columns := [,]

  override const Type[] nodeTypes := [
    Table#,
    TableHead#,
    TableBody#,
    TableRow#,
    TableCell#,
  ]

  override Void render(Node node) { node.walk(this) }

  virtual Void visitTable(Table node)
  {
    columns.clear
    writer.pushTight(true)
    renderChildren(node)
    writer.popTight
    writer.block
  }

  virtual Void visitTableHead(TableHead head)
  {
    renderChildren(head)
    columns.each |alignment|
    {
      writer.raw('|')
      switch (alignment)
      {
        case Alignment.left:   writer.raw(":---")
        case Alignment.right:  writer.raw("---:")
        case Alignment.center: writer.raw(":---:")
        default: writer.raw("---")
      }
    }
    writer.raw('|')
    writer.block
  }

  virtual Void visitTableBody(TableBody body)
  {
    renderChildren(body)
  }

  virtual Void visitTableRow(TableRow row)
  {
    renderChildren(row)
    // trailing | at the end of the line
    writer.raw('|')
    writer.block
  }

  virtual Void visitTableCell(TableCell cell)
  {
    if (cell.parent != null && cell.parent.parent is TableHead)
      columns.add(cell.alignment)
    writer.raw('|')
    writer.pushRawEscape(pipe)
    renderChildren(cell)
    writer.popRawEscape
  }

  private Void renderChildren(Node parent)
  {
    node := parent.firstChild
    while (node != null)
    {
      next := node.next
      cx.render(node)
      node = next
    }
  }
}