defmodule SurfaceBulma.Table do
  @moduledoc """
  A Bulma HTML table.

  You can create a table by passing `data` to it and defining
  columns using the `Table.Column` component.

  Look in the documentation for `Table.Column` for information on how to do sorting.
  """

  use Surface.LiveComponent
  alias SurfaceBulma.Icon.FontAwesome, as: FA
  alias SurfaceBulma.Icon.FontAwesome.{TextIcon, TextIconText}

  @doc "The data that populates the table internal"
  prop data, :generator, required: true, root: true

  @doc "The table is expanded (full-width)"
  prop expanded, :boolean, default: true

  @doc "Add borders to all the cells"
  prop bordered, :boolean, default: false

  @doc "Add stripes to the table"
  prop striped, :boolean, default: false

  use SurfaceBulma.ClassProp, doc: "The CSS class for the wrapping `<div>` element"

  @doc """
  A function that returns a class for the item's underlying `<tr>`
  element. The function receives the item and index related to
  the row.
  """
  prop row_class, :fun

  @doc "The columns of the table"
  slot cols, generator_prop: :data, required: true

  @doc "Internal holder of sorted data"
  data sorted_data, :generator, default: nil

  @doc "Holder of what we're sorting by"
  data sorted_by, :any, default: nil

  @doc "Clicking column again should reverse search"
  data sort_reverse, :boolean, default: false

  data updated?, :boolean, default: false

  def update(assigns, socket) do
    assigns = Map.put(assigns, :updated?, assigns[:data] != socket.assigns[:data])

    socket = assign(socket, assigns)

    socket = assign(socket, :sorted_data, sorted_data(socket.assigns))

    {:ok, socket}
  end

  def render(assigns) do
    ~F"""
    <div class={@class}>
      <table class={classes(assigns, [
        "table",
        "is-fullwidth": @expanded,
        "is-bordered": @bordered,
        "is-striped": @striped
      ])}>
        <thead>
          <tr>
            {#for col <- @cols, col.show == true}
              <th>
                {#if !is_nil(col.sort_by) && assigns.sorted_by == col.label}
                  <a :on-click="sorted_click" phx-value-value={col.label} href="#">
                    <TextIcon>
                      <TextIconText>
                        {col.label}
                      </TextIconText>
                      <FA icon={if assigns.sort_reverse, do: "caret-up", else: "caret-down"} />
                    </TextIcon>
                  </a>
                {/if}
                {#if !is_nil(col.sort_by) && assigns.sorted_by != col.label}
                  <a :on-click="sorted_click" phx-value-value={col.label} href="#">
                    {col.label}
                  </a>
                {/if}
                {#if is_nil(col.sort_by)}
                  {col.label}
                {/if}
              </th>
            {/for}
          </tr>
        </thead>
        <tbody>
          <tr
            :for={{item, index} <- Enum.with_index(@sorted_data)}
            class={row_class_fun(@row_class).(item, index)}
          >
            <td :for={col <- @cols, col.show == true}>
              <span><#slot {col} generator_value={item} /></span>
            </td>
          </tr>
        </tbody>
      </table>
    </div>
    """
  end

  def handle_event(
        "sorted_click",
        %{"value" => sort_by_new},
        socket = %{assigns: %{sorted_by: sorted_by, sort_reverse: sort_reverse}}
      ) do
    socket =
      cond do
        sorted_by != sort_by_new ->
          assign(socket, :sorted_by, sort_by_new)
          |> assign(:sort_reverse, false)

        sorted_by == sort_by_new ->
          assign(socket, :sort_reverse, !sort_reverse)
      end

    socket = assign(socket, :sorted_data, sorted_data(socket.assigns))
    {:noreply, socket}
  end

  defp sorted_data(%{
         sorted_by: sorted_by,
         data: data,
         cols: cols,
         sorted_data: sorted_data,
         sort_reverse: sort_reverse,
         updated?: updated?
       }) do
    cond do
      !is_nil(sorted_by) ->
        # We find the column that matches the sorted_by assign and extract the sort_by as sorter
        sorter =
          Enum.reduce_while(cols, nil, fn
            %{label: ^sorted_by, sort_by: sorter}, _acc ->
              {:halt, sorter}

            _col, acc ->
              {:cont, acc}
          end)

        sorted_data =
          case sorter do
            sorter when is_binary(sorter) ->
              # We have to try to fetch both by string and atom key as
              # we don't know if the data is using string or atom keys.
              Enum.sort_by(data, fn i ->
                Map.get(i, sorter) || Map.get(i, String.to_atom(sorter))
              end)

            sorter when is_function(sorter) ->
              Enum.sort_by(data, sorter)

            sorter when is_list(sorter) ->
              Enum.sort_by(data, &get_nested_data(&1, sorter))

            {sorter, comparer} when is_function(sorter) and is_function(comparer) ->
              Enum.sort_by(data, sorter, comparer)

            {sorter, comparer} when is_list(sorter) and is_function(comparer) ->
              Enum.sort_by(data, &get_nested_data(&1, sorter), comparer)

            nil ->
              data
          end

        sorted_data =
          if sort_reverse == true do
            Enum.reverse(sorted_data)
          else
            sorted_data
          end

        sorted_data

      is_nil(sorted_data) or updated? ->
        data

      true ->
        sorted_data
    end
  end

  defp row_class_fun(nil), do: fn _, _ -> "" end
  defp row_class_fun(row_class), do: row_class

  defp get_nested_data(row, keys) do
    Enum.reduce_while(keys, row, fn
      key, acc when (is_atom(key) or is_binary(key)) and is_map(acc) ->
        {:cont, Map.get(acc, key)}

      index, acc when is_integer(index) and is_list(acc) ->
        {:cont, Enum.at(acc, index)}

      _key, value ->
        {:halt, value}
    end)
  end
end
