defmodule Licensir.TableRex.Table do
  @moduledoc """
  A set of functions for working with tables.

  The `Table` is represented internally as a struct though the
  fields are private and must not be accessed directly. Instead,
  use the functions in this module.
  """
  alias Licensir.TableRex.Cell
  alias Licensir.TableRex.Column
  alias Licensir.TableRex.Renderer
  alias Licensir.TableRex.Table

  defstruct title: nil, header_row: [], rows: [], columns: %{}, default_column: %Column{}

  @type t :: %__MODULE__{}

  @default_renderer Renderer.Text

  @doc """
  Creates a new blank table.

  The table created will not be able to be rendered until it has some row data.

  ## Examples

      iex> Table.new
      %Licensir.TableRex.Table{}

  """
  @spec new() :: Table.t()
  def new, do: %Table{}

  @doc """
  Creates a new table with an initial set of rows and an optional header and title.
  """
  @spec new(list, list, String.t() | nil) :: Table.t()
  def new(rows, header_row \\ [], title \\ nil) when is_list(rows) and is_list(header_row) do
    new()
    |> put_title(title)
    |> put_header(header_row)
    |> add_rows(rows)
  end

  # ------------
  # Mutation API
  # ------------

  @doc """
  Sets a string as the optional table title.
  Set to `nil` or `""` to remove an already set title from renders.
  """
  @spec put_title(Table.t(), String.t() | nil) :: Table.t()
  def put_title(%Table{} = table, ""), do: put_title(table, nil)

  def put_title(%Table{} = table, title) when is_binary(title) or is_nil(title) do
    %Table{table | title: title}
  end

  @doc """
  Sets a list as the optional header row.
  Set to `nil` or `[]` to remove an already set header from renders.
  """
  @spec put_header(Table.t(), list | nil) :: Table.t()
  def put_header(%Table{} = table, nil), do: put_header(table, [])

  def put_header(%Table{} = table, header_row) when is_list(header_row) do
    new_header_row = Enum.map(header_row, &Cell.to_cell(&1))
    %Table{table | header_row: new_header_row}
  end

  @doc """
  Sets column level information such as padding and alignment.
  """
  @spec put_column_meta(Table.t(), integer | atom | Enum.t(), Keyword.t()) :: Table.t()
  def put_column_meta(%Table{} = table, col_index, col_meta)
      when is_integer(col_index) and is_list(col_meta) do
    col_meta = col_meta |> Enum.into(%{})
    col = get_column(table, col_index) |> Map.merge(col_meta)
    new_columns = Map.put(table.columns, col_index, col)
    %Table{table | columns: new_columns}
  end

  def put_column_meta(%Table{} = table, :all, col_meta) when is_list(col_meta) do
    col_meta = col_meta |> Enum.into(%{})
    # First update default column, then any already set columns.
    table = put_in(table.default_column, Map.merge(table.default_column, col_meta))

    new_columns =
      Enum.reduce(table.columns, %{}, fn {col_index, col}, acc ->
        new_col = Map.merge(col, col_meta)
        Map.put(acc, col_index, new_col)
      end)

    %Table{table | columns: new_columns}
  end

  def put_column_meta(%Table{} = table, col_indexes, col_meta) when is_list(col_meta) do
    Enum.reduce(col_indexes, table, &put_column_meta(&2, &1, col_meta))
  end

  @doc """
  Sets cell level information such as alignment.
  """
  @spec put_cell_meta(Table.t(), integer, integer, Keyword.t()) :: Table.t()
  def put_cell_meta(%Table{} = table, col_index, row_index, cell_meta)
      when is_integer(col_index) and is_integer(row_index) and is_list(cell_meta) do
    cell_meta = cell_meta |> Enum.into(%{})
    inverse_row_index = -(row_index + 1)

    rows =
      List.update_at(table.rows, inverse_row_index, fn row ->
        List.update_at(row, col_index, &Map.merge(&1, cell_meta))
      end)

    %Table{table | rows: rows}
  end

  @doc """
  Sets cell level information for the header cells.
  """
  @spec put_header_meta(Table.t(), integer | Enum.t(), Keyword.t()) :: Table.t()
  def put_header_meta(%Table{} = table, col_index, cell_meta)
      when is_integer(col_index) and is_list(cell_meta) do
    cell_meta = cell_meta |> Enum.into(%{})
    header_row = List.update_at(table.header_row, col_index, &Map.merge(&1, cell_meta))
    %Table{table | header_row: header_row}
  end

  def put_header_meta(%Table{} = table, col_indexes, cell_meta) when is_list(cell_meta) do
    Enum.reduce(col_indexes, table, &put_header_meta(&2, &1, cell_meta))
  end

  @doc """
  Adds a single row to the table.
  """
  @spec add_row(Table.t(), list) :: Table.t()
  def add_row(%Table{} = table, row) when is_list(row) do
    new_row = Enum.map(row, &Cell.to_cell(&1))
    %Table{table | rows: [new_row | table.rows]}
  end

  @doc """
  Adds multiple rows to the table.
  """
  @spec add_rows(Table.t(), list) :: Table.t()
  def add_rows(%Table{} = table, rows) when is_list(rows) do
    rows =
      rows
      |> Enum.reverse()
      |> Enum.map(fn row ->
        Enum.map(row, &Cell.to_cell(&1))
      end)

    %Table{table | rows: rows ++ table.rows}
  end

  @doc """
  Removes column meta for all columns, effectively resetting
  column meta back to the default options across the board.
  """
  @spec clear_all_column_meta(Table.t()) :: Table.t()
  def clear_all_column_meta(%Table{} = table) do
    %Table{table | columns: %{}}
  end

  @doc """
  Removes all row data from the table, keeping everything else.
  """
  @spec clear_rows(Table.t()) :: Table.t()
  def clear_rows(%Table{} = table) do
    %Table{table | rows: []}
  end

  @doc """
  Sorts the table rows by using the values in a specified column.

  This is very much a simple sorting function and relies on Elixir's
  built-in comparison operators & types to cover the basic cases.

  As each cell retains the original value it was created with, we
  use that value to sort on as this allows us to sort on many
  built-in types in the most obvious fashions.

  Remember that rows are stored internally in reverse order that
  they will be output in, to allow for fast insertion.

  Parameters:

      `column_index`: the 0-indexed column number to sort by
      `order`: supply :desc or :asc for sort direction.

  Returns a new Table, with sorted rows.
  """
  @spec sort(Table.t(), integer, atom) :: Table.t()
  def sort(table, column_index, order \\ :desc)

  def sort(%Table{rows: [first_row | _]}, column_index, _order)
      when length(first_row) <= column_index do
    raise Licensir.TableRex.Error,
      message:
        "You cannot sort by column #{column_index}, as the table only has #{length(first_row)} column(s)"
  end

  def sort(table = %Table{rows: rows}, column_index, order) do
    %Table{table | rows: Enum.sort(rows, build_sort_function(column_index, order))}
  end

  defp build_sort_function(column_index, order) when order in [:desc, :asc] do
    fn previous, next ->
      %{raw_value: prev_value} = Enum.at(previous, column_index)
      %{raw_value: next_value} = Enum.at(next, column_index)

      if order == :desc do
        next_value > prev_value
      else
        next_value < prev_value
      end
    end
  end

  defp build_sort_function(_column_index, order) do
    raise Licensir.TableRex.Error,
      message: "Invalid sort order parameter: #{order}. Must be an atom, either :desc or :asc."
  end

  # -------------
  # Retrieval API
  # -------------

  defp get_column(%Table{} = table, col_index) when is_integer(col_index) do
    Map.get(table.columns, col_index, table.default_column)
  end

  @doc """
  Retrieves the value of a column meta option at the specified col_index.
  If no value has been set, default values are returned.
  """
  @spec get_column_meta(Table.t(), integer, atom) :: any
  def get_column_meta(%Table{} = table, col_index, key)
      when is_integer(col_index) and is_atom(key) do
    get_column(table, col_index)
    |> Map.fetch!(key)
  end

  @doc """
  Returns a boolean detailing if the passed table has any row data set.
  """
  @spec has_rows?(Table.t()) :: boolean
  def has_rows?(%Table{rows: []}), do: false
  def has_rows?(%Table{rows: rows}) when is_list(rows), do: true

  @doc """
  Returns a boolean detailing if the passed table has a header row set.
  """
  @spec has_header?(Table.t()) :: boolean
  def has_header?(%Table{header_row: []}), do: false
  def has_header?(%Table{header_row: header_row}) when is_list(header_row), do: true

  # -------------
  # Rendering API
  # -------------

  @doc """
  Renders the current table state to string, ready for display via `IO.puts/2` or other means.

  At least one row must have been added before rendering.

  Returns `{:ok, rendered_string}` on success and `{:error, reason}` on failure.
  """
  @spec render(Table.t(), list) :: Renderer.render_return()
  def render(%Table{} = table, opts \\ []) when is_list(opts) do
    {renderer, opts} = Keyword.pop(opts, :renderer, @default_renderer)
    opts = opts |> Enum.into(renderer.default_options)

    if Table.has_rows?(table) do
      renderer.render(table, opts)
    else
      {:error, "Table must have at least one row before being rendered"}
    end
  end

  @doc """
  Renders the current table state to string, ready for display via `IO.puts/2` or other means.

  At least one row must have been added before rendering.

  Returns the rendered string on success, or raises `TableRex.Error` on failure.
  """
  @spec render!(Table.t(), list) :: String.t() | no_return
  def render!(%Table{} = table, opts \\ []) when is_list(opts) do
    case render(table, opts) do
      {:ok, rendered_string} -> rendered_string
      {:error, reason} -> raise Licensir.TableRex.Error, message: reason
    end
  end
end
