defmodule Oban.Web.SearchComponent do
  use Oban.Web, :live_component

  @spinner_timeout 100

  @impl Phoenix.LiveComponent
  def mount(socket) do
    {:ok, assign(socket, buffer: "", loading: false)}
  end

  @impl Phoenix.LiveComponent
  def update(assigns, socket) do
    suggestions =
      Map.get_lazy(assigns, :suggestions, fn ->
        assigns.queryable.suggest(socket.assigns.buffer, assigns.conf, resolver: assigns.resolver)
      end)

    socket =
      socket
      |> assign(assigns)
      |> assign(suggestions: suggestions)

    {:ok, socket}
  end

  @impl Phoenix.LiveComponent
  def render(assigns) do
    ~H"""
    <form
      class="grow relative"
      id="search"
      data-shortcut={JS.focus(to: "#search-input")}
      phx-hook="Completer"
      phx-change="change"
      phx-submit="search"
      phx-target={@myself}
    >
      <div
        id="search-wrapper"
        class="w-full flex items-center space-x-1.5 rounded-md shadow-inner ring-1 ring-inset ring-gray-300 dark:ring-gray-700"
      >
        <div class="ml-1.5 flex-none">
          <%= if @loading do %>
            <Icons.spinner class="w-5 h-5 text-gray-200 animate-spin dark:text-gray-600 fill-violet-500" />
          <% else %>
            <Icons.magnifying_glass class="w-5 h-5 text-gray-500" />
          <% end %>
        </div>

        <div class="w-full flex flex-wrap space-x-1.5">
          <.filter
            :for={{param, terms} <- filterable(@params, @queryable)}
            param={param}
            terms={terms}
          />

          <input
            aria-label="Add filters"
            aria-placeholder="Add filters"
            autocorrect="false"
            class="min-w-[10rem] flex-grow my-2 px-0 py-0.5 text-sm appearance-none border-none bg-transparent placeholder-gray-400 dark:placeholder-gray-500 focus:ring-0"
            id="search-input"
            name="terms"
            phx-blur={hide_focus()}
            phx-debounce={100}
            phx-focus={show_focus()}
            phx-target={@myself}
            placeholder="Add filters"
            spellcheck="false"
            type="search"
            value={@buffer}
          />
        </div>
      </div>

      <button
        class={[
          "absolute inset-y-0 right-0 pr-3 items-center text-gray-400 hover:text-blue-500",
          unless(clearable?(@buffer, @params, @queryable), do: "hidden")
        ]}
        data-title="Clear filters"
        id="search-reset"
        phx-target={@myself}
        phx-click="clear"
        type="reset"
      >
        <Icons.x_circle class="w-5 h-5" />
      </button>

      <nav
        class="hidden absolute z-10 mt-1 w-full text-sm shadow-lg bg-white dark:bg-gray-800 focus:outline-none overflow-hidden rounded-md ring-1 ring-black/5"
        id="search-suggest"
        phx-click-away={JS.hide()}
      >
        <div class="p-2">
          <.option
            :for={{name, desc, exmp} <- @suggestions}
            buff={@buffer}
            name={name}
            desc={desc}
            exmp={exmp}
          />

          <div :if={Enum.empty?(@suggestions)} class="w-full flex items-center space-x-2 p-1">
            <Icons.exclamation_circle class="w-5 h-5 text-gray-400" />
            <span class="text-gray-700">No suggestions matching <b>"{@buffer}"</b></span>
          </div>
        </div>

        <a
          href="https://oban.pro/docs/web/filtering.html"
          class="w-full flex items-center space-x-1 p-2 bg-gray-100 dark:bg-gray-900 text-gray-500 dark:text-gray-400 hover:text-gray-950 dark:hover:text-gray-100"
          title="Read the filtering and searching guide"
          target="_blank"
        >
          <Icons.info_circle class="w-4 h-4" />
          <span class="text-xs">Filtering Tips</span>
        </a>
      </nav>
    </form>
    """
  end

  attr :param, :any, required: true
  attr :terms, :any, required: true

  defp filter(assigns) do
    ~H"""
    <div class="my-1.5 flex items-center text-sm font-medium" id={"search-filter-#{@param}"}>
      <span class="pl-1.5 pr-0.5 py-1 text-gray-700 dark:text-violet-950 bg-violet-100 dark:bg-violet-300 rounded-s-md whitespace-nowrap">
        {format_filter(@param, @terms)}
      </span>

      <button
        class="flex items-center pl-0.5 pr-1 py-1 rounded-e-md text-gray-800/70 bg-violet-100 dark:bg-violet-300 hover:bg-violet-500 dark:hover:bg-violet-500 hover:text-gray-100"
        type="button"
        phx-click="remove-filter"
        phx-value-param={@param}
        phx-value-terms={@terms}
        phx-target="#search"
      >
        <Icons.x_mark class="w-5 h-5" />
      </button>
    </div>
    """
  end

  defp format_filter(param, [path, term]) when is_list(path) do
    "#{param}.#{Enum.join(path, ".")}:#{term}"
  end

  defp format_filter(param, term) when is_list(term) do
    "#{param}:#{Enum.join(term, ",")}"
  end

  defp format_filter(param, term) do
    "#{param}:#{term}"
  end

  attr :buff, :string
  attr :name, :string
  attr :desc, :string
  attr :exmp, :string

  defp option(assigns) do
    ~H"""
    <button
      class="w-full flex items-center cursor-pointer p-1 rounded-md group hover:bg-violet-500"
      phx-click={JS.push("append", value: %{choice: @name})}
      phx-target="#search"
      type="button"
    >
      <span class="block px-1 py-0.5 font-medium rounded-md bg-gray-100 dark:bg-gray-900">
        {highlight(@name, @buff)}
      </span>
      <span class="block ml-2 text-gray-600 dark:text-gray-300 group-hover:text-white">
        {@desc}
      </span>
      <span class="block ml-auto text-right text-gray-400 dark:text-gray-500 group-hover:text-white">
        {@exmp}
      </span>
    </button>
    """
  end

  defp clearable?(buffer, params, queryable) do
    String.length(buffer) > 0 or map_size(filterable(params, queryable)) > 0
  end

  defp filterable(params, queryable), do: Map.take(params, queryable.filterable())

  defp highlight(value, substr) do
    match =
      substr
      |> String.split([":", "."], trim: true)
      |> List.last()

    if is_binary(value) and is_binary(match) do
      pattern = Regex.compile!("(#{Regex.escape(match)})", "i")

      value
      |> String.replace(pattern, "<b>\\1</b>")
      |> raw()
    else
      value
    end
  end

  def show_focus do
    %JS{}
    |> JS.remove_class("ring-gray-300 dark:ring-gray-700", to: "#search-wrapper")
    |> JS.add_class("shadow-blue-100 dark:shadow-blue-950", to: "#search-wrapper")
    |> JS.add_class("ring-blue-500 dark:ring-blue-700", to: "#search-wrapper")
    |> JS.add_class("bg-blue-100/30 dark:bg-blue-900/30", to: "#search-wrapper")
    |> JS.show(to: "#search-suggest")
  end

  # Closing the suggest menu is done with push events to allow clicking on a suggestion to fire
  # before the menu closes.
  def hide_focus do
    %JS{}
    |> JS.add_class("ring-gray-300 dark:ring-gray-700", to: "#search-wrapper")
    |> JS.remove_class("shadow-blue-100 dark:shadow-blue-950", to: "#search-wrapper")
    |> JS.remove_class("ring-blue-500 dark:ring-blue-700", to: "#search-wrapper")
    |> JS.remove_class("bg-blue-100/30 dark:bg-blue-900/30", to: "#search-wrapper")
  end

  # Events

  @impl Phoenix.LiveComponent
  def handle_event("change", %{"terms" => buffer}, socket) do
    socket =
      socket
      |> assign(buffer: buffer)
      |> async_suggest(buffer)

    {:noreply, socket}
  end

  def handle_event("clear", _params, socket) do
    suggestions = socket.assigns.queryable.suggest("", socket.assigns.conf)

    {:noreply,
     socket
     |> assign(buffer: "", loading: false, suggestions: suggestions)
     |> push_patch(to: oban_path(socket.assigns.page))}
  end

  def handle_event("append", %{"choice" => choice}, socket) do
    buffer = socket.assigns.queryable.append(socket.assigns.buffer, choice)

    socket
    |> assign(buffer: buffer)
    |> handle_submit()
  end

  def handle_event("complete", _params, socket) do
    buffer = socket.assigns.queryable.complete(socket.assigns.buffer, socket.assigns.conf)

    socket =
      socket
      |> assign(buffer: buffer)
      |> push_event("completed", %{buffer: buffer})
      |> async_suggest(buffer)

    {:noreply, socket}
  end

  def handle_event("search", _, socket) do
    handle_submit(socket)
  end

  def handle_event("remove-filter", %{"param" => param, "terms" => _}, socket) do
    params = Map.delete(socket.assigns.params, String.to_existing_atom(param))

    {:noreply, push_patch(socket, to: oban_path(socket.assigns.page, params))}
  end

  defp handle_submit(socket) do
    %{buffer: buffer, queryable: queryable} = socket.assigns

    if String.ends_with?(buffer, ":") or String.ends_with?(buffer, ".") do
      {:noreply, async_suggest(socket, buffer)}
    else
      parsed = queryable.parse(buffer)
      params = Map.merge(socket.assigns.params, parsed, fn _key, old, new -> old ++ new end)
      suggestions = queryable.suggest("", socket.assigns.conf)

      {:noreply,
       socket
       |> assign(buffer: "", loading: false, suggestions: suggestions)
       |> push_patch(to: oban_path(socket.assigns.page, params))}
    end
  end

  def handle_info({:DOWN, _ref, :process, _pid, _reason}, socket) do
    {:noreply, socket}
  end

  def handle_info({ref, suggestions}, socket) when is_reference(ref) do
    {:noreply, assign(socket, loading: false, suggestions: suggestions)}
  end

  defp async_suggest(socket, buffer) do
    self = self()

    fun = fn ->
      suggestions =
        socket.assigns.queryable.suggest(buffer, socket.assigns.conf,
          resolver: socket.assigns.resolver
        )

      assigns =
        socket.assigns
        |> Map.take(~w(id conf params queryable resolver)a)
        |> Map.put(:loading, false)
        |> Map.put(:suggestions, suggestions)

      send_update(self, __MODULE__, assigns)
    end

    fun
    |> Task.async()
    |> Task.yield(@spinner_timeout)
    |> case do
      nil -> assign(socket, loading: true)
      _ok -> socket
    end
  end
end
