lib/pdf/table.ex

defmodule Pdf.Table do
  @moduledoc false
  alias Pdf.{Page, Text}

  def table!(page, xy, wh, data, opts \\ [])

  def table!(page, xy, wh, data, opts) do
    case table(page, xy, wh, data, opts) do
      {page, :complete} -> page
      _ -> raise(RuntimeError, "The supplied data did not fit within the supplied boundary")
    end
  end

  def table(page, xy, wh, data, opts \\ [])

  def table(page, {x, :cursor}, wh, data, opts),
    do: table(page, {x, Page.cursor(page)}, wh, data, opts)

  def table(page, _xy, _wh, [], _opts), do: {page, []}

  def table(page, {x, y}, {w, h}, {:continue, data}, opts) do
    case draw_table(page, {x, y}, {w, h}, data, opts) do
      {page, []} ->
        {page, :complete}

      {page, remaining} ->
        repeat_rows = Keyword.get(opts, :repeat_header, 0)
        {page, {:continue, Enum.take(data, repeat_rows) ++ remaining}}
    end
  end

  def table(page, {x, y}, {w, h}, data, opts) do
    [first_row | _] = data
    num_cols = length(first_row)

    {opts, col_opts} = fix_column_options(num_cols, opts)

    data =
      data
      |> chunk_data(page, opts, Keyword.get(opts, :rows, %{}))
      |> set_col_dimensions({x, y}, {w, h}, col_opts)

    table(page, {x, y}, {w, h}, {:continue, data}, opts)
  end

  defp set_col_dimensions(data, {x, _y}, {width, _height}, col_opts) do
    widths =
      data
      |> calculate_widths()
      |> Enum.zip(Enum.map(col_opts, &Keyword.get(&1, :width)))
      |> Enum.zip(col_opts)
      |> Enum.map(fn
        {{flex_width, width}, col_opts} ->
          min_width = Keyword.get(col_opts, :min_width)
          max_width = Keyword.get(col_opts, :max_width)
          {flex_width, width, min_width, max_width}
      end)
      # First pass makes min/max width adjustments
      |> calculate_dimensions(width)
      # Second pass resizes cols without min/max widths
      |> calculate_dimensions(width)
      |> Enum.map(fn
        {width, nil, _, _} -> width
        {_, width, _, _} -> width
      end)

    # Make sure the total width distribution matches the expected width
    total_calculated = Enum.reduce(widths, 0, &(&1 + &2))

    widths =
      if total_calculated != width do
        Enum.map(widths, &(&1 / total_calculated * width))
      else
        widths
      end

    data
    |> set_widths(widths, x)
    |> set_spanning()
  end

  defp set_spanning([]), do: []

  defp set_spanning([row | rows]) do
    [span_cols(row) | set_spanning(rows)]
  end

  defp span_cols([]), do: []

  defp span_cols([{_content, col_opts} | _] = cols) do
    case Enum.split(cols, Keyword.get(col_opts, :colspan, 1)) do
      {[col], cols} ->
        [col | span_cols(cols)]

      {[{content, col_opts} | _tail] = spanned, cols} ->
        spanned_width =
          Enum.reduce(spanned, 0, fn {_, col_opts}, acc ->
            Keyword.get(col_opts, :width, 0) + acc
          end)

        [{content, Keyword.put(col_opts, :width, spanned_width)} | span_cols(cols)]
    end
  end

  defp calculate_dimensions(widths, width) do
    total_fixed =
      Enum.reduce(widths, 0, fn
        {_, nil, _, _}, acc -> acc
        {_, width, _, _}, acc -> width + acc
      end)

    available_width = width - total_fixed

    total_flexible =
      Enum.reduce(widths, 0, fn
        {width, nil, _, _}, acc -> width + acc
        _, acc -> acc
      end)

    widths
    |> Enum.map(fn
      {width, nil, min_width, max_width} ->
        calculated_width = width / total_flexible * available_width

        adjusted_width =
          if min_width && min_width > calculated_width, do: min_width, else: calculated_width

        adjusted_width =
          if max_width && max_width < adjusted_width, do: max_width, else: adjusted_width

        if adjusted_width != calculated_width do
          {width, adjusted_width, min_width, max_width}
        else
          {calculated_width, nil, nil, nil}
        end

      width ->
        width
    end)
  end

  defp set_widths([], _widths, _x), do: []

  defp set_widths([row | rows], widths, x) do
    {row, _} =
      row
      |> Enum.zip(widths)
      |> Enum.map_reduce(x, fn {{col, col_opts}, width}, x ->
        {{col, Keyword.merge(col_opts, width: width, x: x)}, x + width}
      end)

    [row | set_widths(rows, widths, x)]
  end

  defp chunk_data(data, page, opts, row_opts) do
    row_count = length(data)

    row_opts =
      Enum.map(row_opts, fn
        {neg_idx, opts} when neg_idx < 0 -> {row_count + neg_idx, opts}
        {idx, opts} -> {idx, opts}
      end)
      |> Map.new()

    chunk_rows(data, 0, page, opts, row_opts)
  end

  defp chunk_rows([], _row_index, _page, _opts, _row_opts), do: []

  defp chunk_rows([row | rows], row_index, page, opts, row_opts) do
    row_even_odd_style = Map.get(row_opts, if(rem(row_index, 2) == 0, do: :even, else: :odd), [])
    row_index_style = Map.get(row_opts, row_index, [])
    row_style = Keyword.merge(row_even_odd_style, row_index_style)

    row = merge_col_opts(row, Keyword.get(opts, :cols, []), opts, row_style)

    {chunked_row, page} = chunk_cols(row, page)
    [chunked_row | chunk_rows(rows, row_index + 1, page, opts, row_opts)]
  end

  defp chunk_cols([], page), do: {[], page}

  defp chunk_cols([col | cols], page) do
    {chunked, page} = chunk_col(col, page)
    {rest, page} = chunk_cols(cols, page)
    {[chunked | rest], page}
  end

  defp chunk_col({content, col_opts}, page) do
    {annotated, page} = Page.annotate_attributed_text(content, page, col_opts)
    chunked = Text.chunk_attributed_text(annotated, col_opts)
    {{chunked, col_opts}, page}
  end

  defp fix_column_options(num_cols, opts) do
    col_opts = Keyword.get(opts, :cols, [])
    num_col_opts = length(col_opts)

    col_opts =
      if num_col_opts >= num_cols do
        col_opts
      else
        col_opts ++ Enum.map(1..(num_cols - num_col_opts), fn _ -> [] end)
      end

    {Keyword.put(opts, :cols, col_opts), col_opts}
  end

  defp merge_col_opts(cols, col_opts, opts, row_opts) do
    {row_opts, row_col_opts} = fix_column_options(length(cols), row_opts)

    row_opts = Keyword.drop(row_opts, [:cols])

    [cols, col_opts, row_col_opts]
    |> Enum.zip()
    |> Enum.map(fn {col, col_opts, row_col_opts} ->
      col_opts =
        opts
        |> Keyword.drop([:cols, :rows])
        |> Keyword.merge(col_opts)
        |> Keyword.merge(row_opts)
        |> Keyword.merge(row_col_opts)

      {col, col_opts}
    end)
  end

  defp calculate_widths(data) do
    data
    |> Enum.map(fn row ->
      row
      |> Enum.map(fn
        {[], _col_opts} ->
          0

        {col, _col_opts} ->
          col
          |> Enum.map(fn {_, width, col_opts} ->
            {_, pr, _, pl} = padding(col_opts)
            width + pr + pl
          end)
          |> Enum.max()
      end)
    end)
    |> Enum.zip()
    # TODO: I don't know how efficient it is to work with tuples and back to lists
    #   It may be good to do a zip/1 that returns a list of lists again
    |> Enum.map(&Tuple.to_list/1)
    |> Enum.map(&Enum.max/1)
  end

  defp draw_table(page, _xy, _wh, [], _opts), do: {page, []}

  defp draw_table(page, {x, y}, {w, h}, [head | tail], opts) do
    {row, overflow} =
      head
      |> Enum.map(fn {col, col_opts} ->
        {_, pr, _, pl} = padding(col_opts)
        width = Keyword.get(col_opts, :width) - pr - pl

        lines = Text.wrap_all_chunks(col, width)

        {lines, overflow} =
          if Keyword.get(opts, :allow_row_overflow, false) do
            overflow_lines(lines, h)
          else
            {lines, []}
          end

        height =
          Enum.reduce(lines, 0, fn {:line, line}, acc ->
            line_height = Enum.max(Enum.map(line, &Keyword.get(elem(&1, 2), :height)))
            line_height + acc
          end)

        {{lines, Keyword.put(col_opts, :height, height)}, {overflow, col_opts}}
      end)
      |> Enum.unzip()

    row_height = Enum.map(row, &col_height/1) |> Enum.max(&>=/2, fn -> 0 end)

    cond do
      # We have an overflow
      !Enum.all?(overflow, &Enum.empty?(elem(&1, 0))) ->
        page =
          page
          |> draw_row(y - row_height, row_height, row)
          |> Page.set_cursor(y - row_height)

        {page, [overflow | tail]}

      # No space for this row so we need to bail with {:continue, data}
      row_height > h ->
        {page, [head | tail]}

      true ->
        page =
          page
          |> draw_row(y - row_height, row_height, row)
          |> Page.set_cursor(y - row_height)

        draw_table(page, {x, y - row_height}, {w, h - row_height}, tail, opts)
    end
  end

  defp overflow_lines(lines, max_height) do
    {lines, overflow} =
      lines
      |> Enum.reject(fn
        {:line, []} -> true
        _ -> false
      end)
      |> Enum.map_reduce(0, fn {:line, line}, acc ->
        line_height = Enum.max(Enum.map(line, &Keyword.get(elem(&1, 2), :height)))
        {{:line, line, line_height + acc}, line_height + acc}
      end)
      |> elem(0)
      |> Enum.split_while(fn {:line, _line, height} -> height < max_height end)

    {
      Enum.map(lines, fn {:line, line, _} -> {:line, line} end),
      Enum.flat_map(overflow, fn {:line, line, _} -> line end)
    }
  end

  defp draw_row(page, _y, _row_height, []), do: page

  defp draw_row(page, y, row_height, [{lines, col_opts} | tail]) do
    {pt, pr, pb, pl} = padding(col_opts)
    width = Keyword.get(col_opts, :width)
    x = Keyword.get(col_opts, :x)

    {page, :complete} =
      page
      |> draw_background(lines, {x, y}, {width, row_height}, background(col_opts))
      |> Page.save_state()
      |> clip({x + pl, y + pb}, {width - pl - pr, row_height - pt - pb})
      |> Page.text_wrap(
        {x + pl, y + row_height - pt},
        {width - pl - pr, row_height},
        lines,
        col_opts
      )

    page
    |> Page.restore_state()
    |> draw_border({x, y}, {width, row_height}, border(col_opts))
    |> draw_row(y, row_height, tail)
  end

  defp draw_background(page, _lines, _xy, _wh, nil), do: page

  defp draw_background(page, _lines, {x, y}, {w, h}, color) do
    page
    |> Page.save_state()
    |> Page.rectangle({x, y}, {w, h})
    |> Page.set_fill_color(color)
    |> Page.fill()
    |> Page.restore_state()
  end

  defp clip(page, {x, y}, {w, h}) do
    page
    |> Page.rectangle({x, y}, {w, h})
    |> Page.clip()
  end

  defp draw_border(page, {x, y}, {w, h}, {bt, br, bb, bl} = border) do
    page
    |> draw_top_border({x, y}, {w, h}, bt)
    |> draw_right_border({x, y}, {w, h}, br)
    |> draw_bottom_border({x, y}, {w, h}, bb)
    |> draw_left_border({x, y}, {w, h}, bl)
    |> stroke_border(border)
  end

  defp stroke_border(page, {0, 0, 0, 0}), do: page
  defp stroke_border(page, _border), do: Page.stroke(page)

  defp draw_top_border(page, _xy, _wh, 0), do: page

  defp draw_top_border(page, {x, y}, {w, h}, width) do
    page
    |> Page.set_line_width(width)
    |> Page.line({x, y + h}, {x + w, y + h})
  end

  defp draw_right_border(page, _xy, _wh, 0), do: page

  defp draw_right_border(page, {x, y}, {w, h}, width) do
    page
    |> Page.set_line_width(width)
    |> Page.line({x + w, y + h}, {x + w, y})
  end

  defp draw_bottom_border(page, _xy, _wh, 0), do: page

  defp draw_bottom_border(page, {x, y}, {w, _h}, width) do
    page
    |> Page.set_line_width(width)
    |> Page.line({x, y}, {x + w, y})
  end

  defp draw_left_border(page, _xy, _wh, 0), do: page

  defp draw_left_border(page, {x, y}, {_w, h}, width) do
    page
    |> Page.set_line_width(width)
    |> Page.line({x, y + h}, {x, y})
  end

  defp col_height({_, col_opts}) do
    {pt, _pr, pb, _pl} = padding(col_opts)
    height = Keyword.get(col_opts, :height)
    height + pt + pb
  end

  defp col_height({_, _, col_opts}) do
    {pt, _pr, pb, _pl} = padding(col_opts)
    height = Keyword.get(col_opts, :height)
    height + pt + pb
  end

  defp padding(nil), do: {0, 0, 0, 0}
  defp padding(opts) when is_list(opts), do: padding(Keyword.get(opts, :padding, 0))
  defp padding(p) when is_number(p), do: {p, p, p, p}
  defp padding({p}) when is_number(p), do: {p, p, p, p}
  defp padding({py, px}), do: {py, px, py, px}
  defp padding({pt, px, pb}), do: {pt, px, pb, px}
  defp padding({pt, pr, pb, pl}), do: {pt, pr, pb, pl}

  defp border(nil), do: {0, 0, 0, 0}
  defp border(opts) when is_list(opts), do: border(Keyword.get(opts, :border, 0))
  defp border(b) when is_number(b), do: {b, b, b, b}
  defp border({b}) when is_number(b), do: {b, b, b, b}
  defp border({by, bx}), do: {by, bx, by, bx}
  defp border({bt, bx, bb}), do: {bt, bx, bb, bx}
  defp border({bt, br, bb, bl}), do: {bt, br, bb, bl}

  defp background(opts) when is_list(opts), do: Keyword.get(opts, :background)
end