lib/kalevala/output/tables.ex

defmodule Kalevala.Output.Tables.Tag do
  @moduledoc false

  defstruct [:name, attributes: %{}, children: []]

  def append(tag, child) do
    %{tag | children: tag.children ++ [child]}
  end
end

defmodule Kalevala.Output.Tables do
  @moduledoc """
  Process table tags into ANSI tables

  Processes 3 tags, `{table}`, `{row}`, and `{cell}`.

  Tables are automatically balanced and text in cells are centered.

  Example table:

  ```
  {table}
    {row}
      {cell}Player Name{/cell}
    {/row}
    {row}
      {cell}HP{/cell}
      {cell}{color foreground="red"}50/50{/color}{/cell}
    {/row}
  {/table}
  ```

  # `{table}` Tag

  This tag starts a new table. It can _only_ contain `{row}` tags as children. Any
  whitespace is trimmed and ignored inside the table.

  # `{row}` Tag

  This tag starts a new row. It can _only_ contain `{cell}` tags as children. Any
  whitespace is trimmed and ignored inside the row.

  # `{cell}` Tag

  This tag is a cell. It can contain strings and other tags that don't increase
  the width of the cell, e.g. color tags. Anything other than a string is not
  used to calculate the width of the cell, which is used for balacing the table.
  """

  use Kalevala.Output

  import Kalevala.Character.View.Macro, only: [sigil_i: 2]

  alias Kalevala.Character.View
  alias Kalevala.Output.Tables.Tag

  @impl true
  def init(opts) do
    %Context{
      data: [],
      opts: opts,
      meta: %{
        current_tag: :empty,
        tag_stack: []
      }
    }
  end

  @impl true
  def parse({:open, "table", attributes}, context) do
    parse_open(context, :table, attributes)
  end

  def parse({:open, "row", attributes}, context) do
    parse_open(context, :row, attributes)
  end

  def parse({:open, "cell", attributes}, context) do
    parse_open(context, :cell, attributes)
  end

  def parse({:close, "table"}, context) do
    parse_close(context)
  end

  def parse({:close, "row"}, context) do
    parse_close(context)
  end

  def parse({:close, "cell"}, context) do
    parse_close(context)
  end

  def parse(datum, context) do
    case context.meta.current_tag == :empty do
      true ->
        Map.put(context, :data, context.data ++ [datum])

      false ->
        current_tag = Tag.append(context.meta.current_tag, datum)
        meta = Map.put(context.meta, :current_tag, current_tag)
        Map.put(context, :meta, meta)
    end
  end

  defp parse_open(context, tag, attributes) do
    tag_stack = [context.meta.current_tag | context.meta.tag_stack]

    meta =
      context.meta
      |> Map.put(:current_tag, %Tag{name: tag, attributes: attributes})
      |> Map.put(:tag_stack, tag_stack)

    Map.put(context, :meta, meta)
  end

  defp parse_close(context) do
    [new_current | tag_stack] = context.meta.tag_stack

    current_tag = context.meta.current_tag
    current_tag = %{current_tag | children: current_tag.children}

    case new_current do
      :empty ->
        meta =
          context.meta
          |> Map.put(:current_tag, :empty)
          |> Map.put(:tag_stack, tag_stack)

        context
        |> Map.put(:data, context.data ++ [current_tag])
        |> Map.put(:meta, meta)

      new_current ->
        meta =
          context.meta
          |> Map.put(:current_tag, Tag.append(new_current, current_tag))
          |> Map.put(:tag_stack, tag_stack)

        Map.put(context, :meta, meta)
    end
  end

  @impl true
  def post_parse(context) do
    data =
      context.data
      |> table_breathing_room()
      |> Enum.map(&parse_data/1)

    case Enum.any?(data, &match?(:error, &1)) do
      true ->
        :error

      false ->
        Map.put(context, :data, data)
    end
  end

  @doc """
  Give table tags some "breathing" room

  - If there is text before the table and no newline, then make a new line
  - If there is text after the table and no newline, then make a new line
  """
  def table_breathing_room([]), do: []

  def table_breathing_room([datum, table = %Tag{name: :table} | data]) do
    case String.ends_with?(datum, "\n") || datum == "" do
      true ->
        [datum | table_breathing_room([table | data])]

      false ->
        [datum, "\n" | table_breathing_room([table | data])]
    end
  end

  def table_breathing_room([table = %Tag{name: :table}, datum | data]) do
    case String.starts_with?(datum, "\n") || datum == "" do
      true ->
        [table | table_breathing_room([datum | data])]

      false ->
        [table, "\n" | table_breathing_room([datum | data])]
    end
  end

  def table_breathing_room([datum | data]) do
    [datum | table_breathing_room(data)]
  end

  defp parse_data(%Tag{name: :table, children: children}) do
    parse_table(children)
  end

  defp parse_data(datum), do: datum

  defp parse_table(rows) do
    rows =
      rows
      |> trim_children()
      |> Enum.map(fn row ->
        cells = trim_children(row.children)
        %{row | children: cells}
      end)

    case valid_rows?(rows) do
      true ->
        display_rows(rows)

      false ->
        :error
    end
  end

  defp trim_children([]), do: []

  defp trim_children([child | children]) when is_binary(child) do
    child = String.trim(child)

    case child == "" do
      true ->
        trim_children(children)

      false ->
        [child | trim_children(children)]
    end
  end

  defp trim_children([child = %Tag{} | children]) do
    [child | trim_children(children)]
  end

  @doc """
  Validate rows in a table (are all row tags and have valid cells)
  """
  def valid_rows?(rows) do
    Enum.all?(rows, fn row ->
      match?(%Tag{name: :row}, row) && valid_cells?(row.children)
    end)
  end

  @doc """
  Validate cells in a row (are all cell tags)
  """
  def valid_cells?(cells) do
    Enum.all?(cells, fn cell ->
      match?(%Tag{name: :cell}, cell)
    end)
  end

  @doc """
  Display a table
  """
  def display_rows(rows) do
    width = max_width(rows)

    split_row = Enum.join(Enum.map(0..(width - 1), fn _i -> "-" end), "")

    rows = Enum.map(rows, &display_row(&1, width, split_row))

    [
      ~i(+#{split_row}+\n),
      View.join(rows, "\n")
    ]
  end

  @doc """
  Display a row's cells
  """
  def display_row(row, max_width, split_row) do
    width_difference = max_width - row_width(row)
    cell_padding = Float.floor(width_difference / Enum.count(row.children))

    [
      "| ",
      View.join(display_cells(row.children, cell_padding), " | "),
      " |\n",
      ["+", split_row, "+"]
    ]
  end

  @doc """
  Display a cell's contents

  Pads the left and right, "pulls" left when centering (if an odd number)
  """
  def display_cells(cells, cell_padding) do
    left_padding = cell_padding(Float.floor(cell_padding / 2))
    right_padding = cell_padding(Float.ceil(cell_padding / 2))

    Enum.map(cells, fn cell ->
      [left_padding, cell.children, right_padding]
    end)
  end

  @doc """
  Build a list of padding spaces for a side of a cell
  """
  def cell_padding(0.0), do: []

  def cell_padding(cell_padding) do
    Enum.map(1..trunc(cell_padding), fn _i ->
      " "
    end)
  end

  @doc """
  Find the max width of rows in a table
  """
  def max_width(rows) do
    rows
    |> Enum.max_by(&row_width/1)
    |> row_width()
  end

  @doc """
  Get the total width of a row

  Each cell tracks it's string width, plus 3 for built in padding
  """
  def row_width(row) do
    # - 1 for not tracking the final "|" of the row
    Enum.reduce(row.children, 0, fn cell, width ->
      children = Enum.filter(cell.children, &is_binary/1)

      # + 1 for cell barrier
      # + 2 for cell padding
      Enum.reduce(children, width, fn elem, width ->
        String.length(elem) + width
      end) + 3
    end) - 1
  end
end