defmodule Owl.Table do
  @moduledoc """
  Draws rich, customizable tables.
  """

  @doc ~S"""
  Draws a table.

  Accepts a list of maps, where each map represents a row.
  The keys and values in the maps should be of type `t:Owl.Data.t/0`. Otherwise, use the `:render_cell` option to make values printable.

  ## Options

  * `:border_style` - sets the border style. Defaults to `:solid`.
  * `:divide_body_rows` - whether to show a divider between rows in the body. Prefer enabling this if cells have multi-line values. Ignored if `:border_style` is `:none`. Defaults to `false`.
  * `:filter_columns` - a function that filters columns (second argument to `Enum.filter/2`). No filter function by default.
  * `:padding_x`- sets horizontal padding. Defaults to `0`.
  * `:render_cell` - sets how to render header and body cells. Accepts either a function or a keyword list. Defaults to `&Function.identity/1`.
  Options in case of a keyword list:
    * `:header` - sets a function to render header cell. Defaults to `&Function.identity/1`.
    * `:body` - sets a function to render body cell. Defaults to `&Function.identity/1`.
  * `:sort_columns` - sets a sorter (second argument to `Enum.sort/2`) for columns. Defaults to `:asc`.
  * `:max_column_widths` - sets the maximum width for columns in characters. Accepts a function that returns the inner width (content + padding) for each column. Defaults to `fn _ -> :infinity end`.
  * `:max_width` - sets the maximum width of the table in characters, including borders. Defaults to the terminal width or `:infinity` if a terminal is not available.
  * `:word_wrap` - sets the word-wrapping mode. Can be `:break_word` or `:normal`. Defaults to `:break_word`. Ignored if `:truncate_lines` is `true`.
  * `:truncate_lines` - whether to truncate lines when they reach the width specified by `:max_content_width`. Defaults to `false`.

  ## Examples

      # render as is without options
      iex> [
      ...>   %{"id" => "1", "name" => "Yaroslav"},
      ...>   %{"id" => "2", "name" => "Volodymyr"}
      ...> ] |> Owl.Table.new() |> to_string()
      \"""
      ┌──┬─────────┐
      │id│name     │
      ├──┼─────────┤
      │1 │Yaroslav │
      │2 │Volodymyr│
      └──┴─────────┘
      \""" |> String.trim_trailing()

      # ...and more complex example with a bunch of options
      iex> [
      ...>   %{a: :qwertyuiop, b: :asdfghjkl},
      ...>   %{a: :zxcvbnm, b: :dcb}
      ...> ]
      ...> |> Owl.Table.new(
      ...>   render_cell: [
      ...>     header: &(&1 |> inspect() |> Owl.Data.tag(:red)),
      ...>     body: &(&1 |> inspect() |> Owl.Data.truncate(8) |> Owl.Data.tag(:yellow))
      ...>   ],
      ...>   divide_body_rows: true,
      ...>   border_style: :solid_rounded,
      ...>   padding_x: 1,
      ...>   sort_columns: :desc
      ...> )
      ...> |> Owl.Data.to_chardata()
      ...> |> to_string()
      \"""
      ╭──────────┬──────────╮
      │ \e[31m:b\e[39m       │ \e[31m:a\e[39m       │
      ├──────────┼──────────┤
      │ \e[33m:asdfgh…\e[39m │ \e[33m:qwerty…\e[39m │
      ├──────────┼──────────┤
      │ \e[33m:dcb\e[39m     │ \e[33m:zxcvbnm\e[39m │
      ╰──────────┴──────────╯\e[0m
      \""" |> String.trim_trailing()
  """
  @spec new(rows :: nonempty_list(row :: %{column => value}),
          border_style: :solid | :solid_rounded | :none | :double,
          divide_body_rows: boolean(),
          word_wrap: :break_word | :normal,
          truncate_lines: boolean(),
          filter_columns: (column -> as_boolean(term)),
          padding_x: non_neg_integer(),
          max_column_widths: (column -> pos_integer() | :infinity),
          max_width: pos_integer() | :infinity,
          render_cell:
            [
              header: (column -> Owl.Data.t()),
              body: (value -> Owl.Data.t()) | (column, value -> Owl.Data.t())
            ]
            | (value | column -> Owl.Data.t()),
          sort_columns:
            (column, column -> boolean())
            | :asc
            | :desc
            | module()
            | {:asc | :desc, module()}
        ) :: Owl.Data.t()
        when column: any(), value: any()
  def new([_ | _] = rows, opts \\ []) do
    border_style = Keyword.get(opts, :border_style, :solid)
    border_symbols = if border_style != :none, do: Owl.BorderStyle.fetch!(border_style)

    divide_body_rows = Keyword.get(opts, :divide_body_rows, false)
    truncate_lines = Keyword.get(opts, :truncate_lines, false)
    word_wrap = Keyword.get(opts, :word_wrap, :break_word)

    columns = columns(rows)

    columns =
      case Keyword.get(opts, :filter_columns) do
        nil -> columns
        filter_callback -> Enum.filter(columns, filter_callback)
      end

    columns = Enum.sort(columns, Keyword.get(opts, :sort_columns, :asc))

    padding_x = opts[:padding_x] || 0
    max_width = opts[:max_width] || Owl.IO.columns() || :infinity

    if is_integer(max_width) and max_width < 2 do
      raise ":max_width is too small, got: #{max_width}"
    end

    render_functions =
      case Keyword.get(opts, :render_cell) || (&Function.identity/1) do
        render when is_function(render, 1) ->
          {render, render}

        opts when is_list(opts) ->
          {opts[:header] || (&Function.identity/1), opts[:body] || (&Function.identity/1)}
      end

    max_content_widths =
      case Keyword.get(opts, :max_column_widths) do
        nil ->
          Map.new(columns, &{&1, :infinity})

        get_max_column_width ->
          Map.new(columns, fn column ->
            max_content_width =
              case get_max_column_width.(column) do
                :infinity ->
                  :infinity

                width when is_integer(width) and width > 0 ->
                  if width <= padding_x * 2 do
                    raise "max column width must be bigger than `:padding_x` * 2"
                  else
                    width - padding_x
                  end
              end

            {column, max_content_width}
          end)
      end

    border_size = if is_nil(border_symbols), do: 0, else: 1

    {rows, columns_data} =
      rows
      |> apply_render_function_to_cells(columns, render_functions)
      |> apply_width_limits(
        columns,
        max_content_widths,
        word_wrap,
        truncate_lines,
        max_width,
        padding_x,
        border_size
      )

    render_table(rows, divide_body_rows, border_symbols, columns_data)
  end

  defp render_table(rows, _divide_body_rows, nil = border_symbols, columns_data) do
    Enum.map_intersperse(rows, "\n", fn row -> render_row(row, border_symbols, columns_data) end)
  end

  defp render_table([header | body], divide_body_rows, border_symbols, columns_data) do
    horizontal_border =
      Enum.map(header, fn %{column_width: column_width, column: column} ->
        column_data = columns_data[column]

        List.duplicate(
          border_symbols.horizontal,
          column_width + column_data.padding_left + column_data.padding_right
        )
      end)

    top_border = [
      border_symbols.top_left,
      Enum.intersperse(horizontal_border, border_symbols.top_cross),
      border_symbols.top_right,
      "\n"
    ]

    bottom_border = [
      "\n",
      border_symbols.bottom_left,
      Enum.intersperse(horizontal_border, border_symbols.bottom_cross),
      border_symbols.bottom_right
    ]

    internal_horizontal_border = [
      "\n",
      border_symbols.left_cross,
      Enum.intersperse(horizontal_border, border_symbols.cross),
      border_symbols.right_cross,
      "\n"
    ]

    body_rows_divider =
      if divide_body_rows do
        internal_horizontal_border
      else
        "\n"
      end

    [
      top_border,
      render_row(header, border_symbols, columns_data),
      internal_horizontal_border,
      Enum.map_intersperse(body, body_rows_divider, fn row ->
        render_row(row, border_symbols, columns_data)
      end),
      bottom_border
    ]
  end

  @empty_line %{length: 0, value: []}
  defp render_row(row, border_symbols, columns_data) do
    row_height = row |> Enum.reduce(0, &max(&1.height, &2))

    row
    |> Enum.flat_map(fn cell ->
      lines = cell.lines ++ List.duplicate(@empty_line, row_height - cell.height)
      column_data = columns_data[cell.column]

      Enum.map(lines, fn line ->
        [
          column_data.padding_left_symbols,
          line.value,
          List.duplicate(" ", column_data.width - line.length),
          column_data.padding_right_symbols
        ]
      end)
    end)
    |> Enum.chunk_every(row_height)
    |> Enum.zip_with(
      if is_nil(border_symbols) do
        &Function.identity/1
      else
        fn elements -> Enum.intersperse(elements, border_symbols.vertical) end
      end
    )
    |> Enum.map_intersperse(
      "\n",
      if is_nil(border_symbols) do
        &Function.identity/1
      else
        fn row -> [border_symbols.vertical, row, border_symbols.vertical] end
      end
    )
  end

  defp columns(rows) do
    rows
    |> Enum.flat_map(&Map.keys/1)
    |> Enum.uniq()
  end

  defp to_lines(content, max_content_width, word_wrap, truncate_lines) do
    lines =
      content
      |> Owl.Data.lines()
      |> Owl.Lines.format(max_content_width, word_wrap, truncate_lines)
      |> Enum.map(fn line ->
        %{value: line, length: Owl.Data.length(line)}
      end)

    if lines == [] do
      [%{value: [], length: 0}]
    else
      lines
    end
  end

  defp transpose(matrix) do
    Enum.zip_with(matrix, &Function.identity/1)
  end

  defp apply_render_function_to_cells(rows, columns, {render_header_cell, render_body_cell}) do
    [
      Enum.map(columns, fn column ->
        render_header_cell.(column)
      end)
      | Enum.map(rows, fn row ->
          Enum.map(columns, fn column ->
            case Map.fetch(row, column) do
              :error ->
                []

              {:ok, value} ->
                case render_body_cell do
                  render when is_function(render, 1) -> render.(value)
                  render when is_function(render, 2) -> render.(column, value)
                end
            end
          end)
        end)
    ]
  end

  defp apply_width_limits(
         rows,
         columns,
         max_content_widths,
         word_wrap,
         truncate_lines,
         max_width,
         padding_x,
         border_size
       ) do
    %{data: data, columns_data: columns_data} =
      [columns | rows]
      |> Enum.zip_with(fn [column | column_values] -> {column, column_values} end)
      |> Enum.reduce_while(
        %{
          data: [],
          columns_data: %{},
          width_left:
            case max_width do
              :infinity ->
                :infinity

              max_width ->
                start_table_border_size = border_size
                max_width - start_table_border_size
            end
        },
        fn {column, column_values},
           %{
             data: data,
             columns_data: columns_data,
             width_left: width_left
           } ->
          max_content_width = Map.fetch!(max_content_widths, column)

          width_left =
            case width_left do
              :infinity -> :infinity
              width_left -> width_left - border_size
            end

          {width_left, padding_left, padding_right} =
            case width_left do
              :infinity ->
                {:infinity, padding_x, padding_x}

              width_left ->
                padding_left = min(width_left, padding_x)
                width_left = width_left - padding_left
                padding_right = min(width_left, padding_x)
                width_left = width_left - padding_right
                {width_left, padding_left, padding_right}
            end

          max_content_width =
            case width_left do
              :infinity -> max_content_width
              width_left -> min(width_left, max_content_width)
            end

          {cells, column_width} =
            column_values
            |> Enum.map_reduce(0, fn value, max_column_width_so_far ->
              lines = to_lines(value, max_content_width, word_wrap, truncate_lines)
              width = lines |> Enum.reduce(0, &max(&1.length, &2))

              {
                %{
                  lines: lines,
                  height: length(lines),
                  column: column
                },
                max(width, max_column_width_so_far)
              }
            end)

          cells = Enum.map(cells, &Map.put(&1, :column_width, column_width))

          width_left =
            case width_left do
              :infinity -> :infinity
              width_left -> width_left - column_width
            end

          column_data = %{
            width: column_width,
            padding_left: padding_left,
            padding_left_symbols: List.duplicate(" ", padding_left),
            padding_right: padding_right,
            padding_right_symbols: List.duplicate(" ", padding_right)
          }

          {
            if(width_left == 0, do: :halt, else: :cont),
            %{
              width_left: width_left,
              data: [cells | data],
              columns_data: Map.put(columns_data, column, column_data)
            }
          }
        end
      )

    {data
     |> Enum.reverse()
     |> transpose(), columns_data}
  end
end
