defmodule Licensir.TableRex.Renderer.Text do
  @moduledoc """
  Renderer module which handles outputting ASCII-style tables for display.
  """
  alias Licensir.TableRex.Cell
  alias Licensir.TableRex.Table
  alias Licensir.TableRex.Renderer.Text.Meta

  @behaviour Licensir.TableRex.Renderer

  # horizontal_styles: [:all, :header, :frame:, :off]
  # vertical_styles: [:all, :frame, :off]

  # Which horizontal/vertical styles render a specific separator.
  @render_horizontal_frame_styles [:all, :frame, :header]
  @render_vertical_frame_styles [:all, :frame]
  @render_column_separators_styles [:all]
  @render_row_separators_styles [:all]

  @doc """
  Provides a level of sane defaults for the Text rendering module.
  """
  def default_options do
    %{
      horizontal_style: :header,
      vertical_style: :all,
      horizontal_symbol: "-",
      vertical_symbol: "|",
      intersection_symbol: "+",
      top_frame_symbol: "-",
      title_separator_symbol: "-",
      header_separator_symbol: "-",
      bottom_frame_symbol: "-"
    }
  end

  @doc """
  Implementation of the TableRex.Renderer behaviour.

  Available styling options.

  `horizontal_styles` controls horizontal separators and can be one of:

    * `:all`: display separators between and around every row.
    * `:header`: display outer and header horizontal separators only.
    * `:frame`: display outer horizontal separators only.
    * `:off`: display no horizontal separators.

  `vertical_styles` controls vertical separators and can be one of:

    * `:all`: display between and around every column.
    * `:frame`: display outer vertical separators only.
    * `:off`: display no vertical separators.
  """
  def render(table = %Table{}, opts) do
    {col_widths, row_heights} = max_dimensions(table)

    # Calculations that would otherwise be carried out multiple times are done once and their
    # results are stored in the %Meta{} struct which is then passed through the pipeline.
    render_horizontal_frame? = opts[:horizontal_style] in @render_horizontal_frame_styles
    render_vertical_frame? = opts[:vertical_style] in @render_vertical_frame_styles
    render_column_separators? = opts[:vertical_style] in @render_column_separators_styles
    render_row_separators? = opts[:horizontal_style] in @render_row_separators_styles
    table_width = table_width(col_widths, vertical_frame?: render_vertical_frame?)
    intersections = intersections(table_width, col_widths, vertical_style: opts[:vertical_style])

    meta = %Meta{
      col_widths: col_widths,
      row_heights: row_heights,
      table_width: table_width,
      intersections: intersections,
      render_horizontal_frame?: render_horizontal_frame?,
      render_vertical_frame?: render_vertical_frame?,
      render_column_separators?: render_column_separators?,
      render_row_separators?: render_row_separators?
    }

    rendered =
      {table, meta, opts, []}
      |> render_top_frame
      |> render_title
      |> render_title_separator
      |> render_header
      |> render_header_separator
      |> render_rows
      |> render_bottom_frame
      |> render_to_string

    {:ok, rendered}
  end

  defp render_top_frame({table, %Meta{render_horizontal_frame?: false} = meta, opts, rendered}) do
    {table, meta, opts, rendered}
  end

  defp render_top_frame({%Table{title: title} = table, meta, opts, rendered})
       when is_binary(title) do
    intersections = if meta.render_vertical_frame?, do: [0, meta.table_width - 1], else: []

    line =
      render_line(
        meta.table_width,
        intersections,
        opts[:top_frame_symbol],
        opts[:intersection_symbol]
      )

    {table, meta, opts, [line | rendered]}
  end

  defp render_top_frame({table, meta, opts, rendered}) do
    line =
      render_line(
        meta.table_width,
        meta.intersections,
        opts[:top_frame_symbol],
        opts[:intersection_symbol]
      )

    {table, meta, opts, [line | rendered]}
  end

  defp render_title({%Table{title: nil} = table, meta, opts, rendered}) do
    {table, meta, opts, rendered}
  end

  defp render_title({%Table{title: title} = table, meta, opts, rendered}) do
    inner_width = Meta.inner_width(meta)
    line = do_render_cell(title, inner_width)

    line =
      if meta.render_vertical_frame? do
        line |> frame_with(opts[:vertical_symbol])
      else
        line
      end

    {table, meta, opts, [line | rendered]}
  end

  defp render_title_separator({%Table{title: nil} = table, meta, opts, rendered}) do
    {table, meta, opts, rendered}
  end

  defp render_title_separator(
         {table, meta, %{horizontal_style: horizontal_style} = opts, rendered}
       )
       when horizontal_style in [:all, :header] do
    line =
      render_line(
        meta.table_width,
        meta.intersections,
        opts[:title_separator_symbol],
        opts[:intersection_symbol]
      )

    {table, meta, opts, [line | rendered]}
  end

  defp render_title_separator({table, %Meta{render_vertical_frame?: true} = meta, opts, rendered}) do
    line = render_line(meta.table_width, [0, meta.table_width - 1], " ", opts[:vertical_symbol])
    {table, meta, opts, [line | rendered]}
  end

  defp render_title_separator(
         {table, %Meta{render_vertical_frame?: false} = meta, opts, rendered}
       ) do
    {table, meta, opts, ["" | rendered]}
  end

  defp render_header({%Table{header_row: []} = table, meta, opts, rendered}) do
    {table, meta, opts, rendered}
  end

  defp render_header({%Table{header_row: header_row} = table, meta, opts, rendered}) do
    separator = if meta.render_column_separators?, do: opts[:vertical_symbol], else: " "
    line = render_cell_row(table, meta, header_row, separator, opts[:vertical_symbol])

    {table, meta, opts, [line | rendered]}
  end

  defp render_header_separator({%Table{header_row: []} = table, meta, opts, rendered}) do
    {table, meta, opts, rendered}
  end

  defp render_header_separator(
         {table, meta, %{horizontal_style: horizontal_style} = opts, rendered}
       )
       when horizontal_style in [:all, :header] do
    line =
      render_line(
        meta.table_width,
        meta.intersections,
        opts[:header_separator_symbol],
        opts[:intersection_symbol]
      )

    {table, meta, opts, [line | rendered]}
  end

  defp render_header_separator(
         {table, %Meta{render_vertical_frame?: true} = meta, opts, rendered}
       ) do
    line = render_line(meta.table_width, [0, meta.table_width - 1], " ", opts[:vertical_symbol])
    {table, meta, opts, [line | rendered]}
  end

  defp render_header_separator(
         {table, %Meta{render_vertical_frame?: false} = meta, opts, rendered}
       ) do
    {table, meta, opts, ["" | rendered]}
  end

  defp render_rows({%Table{rows: rows} = table, meta, opts, rendered}) do
    cell_separator = if meta.render_column_separators?, do: opts[:vertical_symbol], else: " "

    lines =
      Enum.map(rows, &render_cell_row(table, meta, &1, cell_separator, opts[:vertical_symbol]))

    lines =
      if meta.render_row_separators? do
        row_separator =
          render_line(
            meta.table_width,
            meta.intersections,
            opts[:horizontal_symbol],
            opts[:intersection_symbol]
          )

        Enum.intersperse(lines, row_separator)
      else
        lines
      end

    rendered = lines ++ rendered
    {table, meta, opts, rendered}
  end

  defp vertically_framed(lines, %{render_vertical_frame?: true}, symbol) do
    Enum.map(lines, &frame_with(&1, symbol))
  end

  defp vertically_framed(lines, _, _), do: lines

  defp render_bottom_frame({table, %Meta{render_horizontal_frame?: false} = meta, opts, rendered}) do
    {table, meta, opts, rendered}
  end

  defp render_bottom_frame({table, meta, opts, rendered}) do
    line =
      render_line(
        meta.table_width,
        meta.intersections,
        opts[:bottom_frame_symbol],
        opts[:intersection_symbol]
      )

    {table, meta, opts, [line | rendered]}
  end

  defp render_line(table_width, intersections, separator_symbol, intersection_symbol) do
    for n <- 0..(table_width - 1) do
      if n in intersections, do: intersection_symbol, else: separator_symbol
    end
    |> Enum.join()
  end

  defp render_cell_row(%Table{} = table, %Meta{} = meta, row, cell_separator, frame_symbol) do
    row_height =
      row
      |> Enum.map(&Cell.height/1)
      |> Enum.max()

    1..row_height
    |> Enum.map(&render_cell_row_line(table, meta, row, cell_separator, &1))
    |> vertically_framed(meta, frame_symbol)
    |> Enum.join("\n")
  end

  defp render_cell_row_line(%Table{} = table, %Meta{} = meta, row, separator, line_index) do
    row
    |> Enum.map(fn %Cell{wrapped_lines: lines} = cell ->
      line_value = Enum.at(lines, line_index - 1) || ""
      %Cell{cell | rendered_value: line_value}
    end)
    |> Enum.with_index()
    |> Enum.map(&render_cell(table, meta, &1))
    |> Enum.intersperse(separator)
    |> Enum.join()
  end

  defp render_cell(%Table{} = table, %Meta{} = meta, {%Cell{} = cell, col_index}) do
    col_width = Meta.col_width(meta, col_index)
    col_padding = Table.get_column_meta(table, col_index, :padding)
    cell_align = Map.get(cell, :align) || Table.get_column_meta(table, col_index, :align)
    cell_color = Map.get(cell, :color) || Table.get_column_meta(table, col_index, :color)

    do_render_cell(cell.rendered_value, col_width, col_padding, align: cell_align)
    |> format_with_color(cell.rendered_value, cell_color)
  end

  defp do_render_cell(value, inner_width) do
    do_render_cell(value, inner_width, 0, align: :center)
  end

  defp do_render_cell(value, inner_width, _padding, align: :center) do
    value_len = String.length(strip_ansi_color_codes(value))
    post_value = ((inner_width - value_len) / 2) |> round
    pre_value = inner_width - (post_value + value_len)
    String.duplicate(" ", pre_value) <> value <> String.duplicate(" ", post_value)
  end

  defp do_render_cell(value, inner_width, padding, align: align) do
    value_len = String.length(strip_ansi_color_codes(value))
    alt_side_padding = inner_width - value_len - padding

    {pre_value, post_value} =
      case align do
        :left ->
          {padding, alt_side_padding}

        :right ->
          {alt_side_padding, padding}
      end

    String.duplicate(" ", pre_value) <> value <> String.duplicate(" ", post_value)
  end

  defp intersections(_table_width, _col_widths, vertical_style: :off), do: []

  defp intersections(table_width, _col_widths, vertical_style: :frame) do
    [0, table_width - 1]
    |> Enum.into(MapSet.new())
  end

  defp intersections(table_width, col_widths, vertical_style: :all) do
    col_widths = ordered_col_widths(col_widths)

    inner_intersections =
      Enum.reduce(col_widths, [0], fn x, [acc_h | _] = acc ->
        [acc_h + x + 1 | acc]
      end)

    ([0, table_width - 1] ++ inner_intersections)
    |> Enum.into(MapSet.new())
  end

  defp max_dimensions(%Table{} = table) do
    {col_widths, row_heights} =
      [table.header_row | table.rows]
      |> Enum.with_index()
      |> Enum.reduce({%{}, %{}}, &reduce_row_maximums(table, &1, &2))

    num_columns = map_size(col_widths)

    # Infer padding on left and right of title
    title_padding =
      [0, num_columns - 1]
      |> Enum.map(&Table.get_column_meta(table, &1, :padding))
      |> Enum.sum()

    # Compare table body width with title width
    col_separators_widths = num_columns - 1
    body_width = (col_widths |> Map.values() |> Enum.sum()) + col_separators_widths
    title_width = if(is_nil(table.title), do: 0, else: String.length(table.title)) + title_padding

    # Add extra padding equally to all columns if required to match body and title width.
    revised_col_widths =
      if body_width >= title_width do
        col_widths
      else
        extra_padding = ((title_width - body_width) / num_columns) |> Float.ceil() |> round
        Enum.into(col_widths, %{}, fn {k, v} -> {k, v + extra_padding} end)
      end

    {revised_col_widths, row_heights}
  end

  defp reduce_row_maximums(%Table{} = table, {row, row_index}, {col_widths, row_heights}) do
    row
    |> Enum.with_index()
    |> Enum.reduce({col_widths, row_heights}, &reduce_cell_maximums(table, &1, &2, row_index))
  end

  defp reduce_cell_maximums(
         %Table{} = table,
         {cell, col_index},
         {col_widths, row_heights},
         row_index
       ) do
    padding = Table.get_column_meta(table, col_index, :padding)
    {width, height} = content_dimensions(cell.rendered_value, padding)
    col_widths = Map.update(col_widths, col_index, width, &Enum.max([&1, width]))
    row_heights = Map.update(row_heights, row_index, height, &Enum.max([&1, height]))
    {col_widths, row_heights}
  end

  defp content_dimensions(value, padding) when is_binary(value) and is_number(padding) do
    lines =
      value
      |> strip_ansi_color_codes()
      |> String.split("\n")

    height = Enum.count(lines)
    width = lines |> Enum.map(&String.length/1) |> Enum.max()
    {width + padding * 2, height}
  end

  defp table_width(%{} = col_widths, vertical_frame?: vertical_frame?) do
    width =
      col_widths
      |> Map.values()
      |> Enum.intersperse(1)
      |> Enum.sum()

    if vertical_frame?, do: width + 2, else: width
  end

  defp ordered_col_widths(%{} = col_widths) do
    col_widths
    |> Enum.into([])
    |> Enum.sort()
    |> Enum.map(&elem(&1, 1))
  end

  defp frame_with(string, frame) do
    frame <> string <> frame
  end

  defp render_to_string({_, _, _, rendered_lines}) when is_list(rendered_lines) do
    rendered_lines
    |> Enum.map(&String.trim_trailing/1)
    |> Enum.reverse()
    |> Enum.join("\n")
    |> Kernel.<>("\n")
  end

  defp format_with_color(text, _, nil), do: text

  defp format_with_color(text, value, color) when is_function(color) do
    [color.(text, value) | IO.ANSI.reset()]
    |> IO.ANSI.format_fragment(true)
  end

  defp format_with_color(text, _, color) do
    [[color | text] | IO.ANSI.reset()]
    |> IO.ANSI.format_fragment(true)
  end

  defp strip_ansi_color_codes(text) do
    Regex.replace(~r|\e\[\d+m|u, text, "")
  end
end
