defmodule ExDebugToolbar do
  @moduledoc ExDebugToolbar.Docs.load!("README.md")

  alias ExDebugToolbar.Database.RequestRepo
  alias ExDebugToolbar.Data.{Collection, BreakpointCollection}
  alias ExDebugToolbar.{Breakpoint, Request}
  use ExDebugToolbar.Decorator.Noop

  @type uuid :: String.t
  @type id :: uuid | pid()
  @type ok :: :ok
  @type options :: Keyword.t()
  @type breakpoint_uuid :: Breakpoint.UUID.t()

  @doc """
  Creates a new request record with to provided `uuid` and current process pid.

  Request is required to be present before adding new timeline events. By default
  request is started on `:ex_debug_toolbar` `:start` intrumentation event.
  """
  @spec start_request(uuid) :: ok
  @decorate noop_when_toolbar_disabled()
  def start_request(uuid) do
    :ok = RequestRepo.insert(%Request{
      pid: self(),
      uuid: uuid,
      created_at: NaiveDateTime.utc_now()
    })
  end

  @doc  """
  Stops request. Toolbar waits for request to stop before rendering.

  By default request is stopped on `:ex_debug_toolbar` `:stop` instrumentation event.
  """
  @spec stop_request(id) :: ok
  @decorate noop_when_toolbar_disabled()
  def stop_request(id) do
    :ok = RequestRepo.update(id, &(%{&1 | stopped?: true}), async: false)
  end

  @doc """
  Deletes request from repository
  """
  @spec delete_request(uuid) :: ok
  @decorate noop_when_toolbar_disabled()
  def delete_request(uuid) do
    RequestRepo.delete(uuid)
  end

  @doc """
  Returns request matching provided `id`, which defaults to `self()`
  """
  @spec get_request(id) :: Request.t()
  @decorate noop_when_toolbar_disabled()
  def get_request(id \\ self()) do
    RequestRepo.get(id)
  end

  @doc """
  Returns the breakpoint
  """
  @spec get_breakpoint(breakpoint_uuid) :: Breakpoint.t()
  @decorate noop_when_toolbar_disabled()
  def get_breakpoint(breakpoint_uuid) do
    case ExDebugToolbar.get_request(breakpoint_uuid.request_id) do
      {:ok, request} -> BreakpointCollection.find(request.breakpoints, breakpoint_uuid.breakpoint_id)
      {:error, reason} -> {:error, reason}
    end
  end

  @doc """
  Returns all requests from repository
  """
  @spec get_all_requests() :: [Request.t()]
  @decorate noop_when_toolbar_disabled([])
  def get_all_requests do
    RequestRepo.all
  end

  @doc """
  Starts a timeline event `name` in request identified by `id`, which defaults to `self()`
  """
  @decorate noop_when_toolbar_disabled()
  @spec start_event(id, String.t()) :: ok
  def start_event(id \\ self(), name) do
    add_data(id, :timeline, {:start_event, name, System.monotonic_time})
  end

  @doc """
  Finishes event `name` in request with pid `self()`

  See `finish_event/3` for more details.
  """
  @decorate noop_when_toolbar_disabled()
  @spec finish_event(String.t()) :: ok
  def finish_event(name), do: finish_event(self(), name, [])

  @decorate noop_when_toolbar_disabled()
  def finish_event(name, opts) when is_list(opts), do: finish_event(self(), name, opts)

  @decorate noop_when_toolbar_disabled()
  def finish_event(id, name) when is_bitstring(name), do: finish_event(id, name, [])

  @doc """
  Finishes event `name` for request with id `id`

  Event duration is calculated as a difference between call to `start_event/2` and `finish_event/3` with
  matching `name` and request `id`.

  Available options:

  * `:duration` - overrides event duration, should be in `:native` time units
  """
  @decorate noop_when_toolbar_disabled()
  @spec finish_event(id, String.t(), options) :: ok
  def finish_event(id, name, opts) do
    add_data(id, :timeline, {:finish_event, name, System.monotonic_time, opts[:duration]})
  end

  @doc """
  Creates a timeline event for provided function `func` execution.

  Returns `func` return value.
  """
  @spec record_event(id, String.t(), function()) :: any()
  def record_event(id \\ self(), name, func) do
    start_event(id, name)
    result = func.()
    finish_event(id, name)
    result
  end

  @doc """
  Adds timeline event `name` with provided `duration` without explicitly starting it.
  """
  @spec add_finished_event(id, String.t(), Integer.t()) :: ok
  @decorate noop_when_toolbar_disabled()
  def add_finished_event(id \\ self(), name, duration) do
    add_data(id, :timeline, {:add_finished_event, name, duration})
  end

  @doc """
  Adds a breakpoint
  """
  @spec add_breakpoint(id, Breakpoint.t()) :: ok
  @decorate noop_when_toolbar_disabled()
  def add_breakpoint(id \\ self(), breakpoint) do
    add_data(id, :breakpoints, breakpoint)
  end

  @doc """
  Adds data to request with id `id`
  """
  @spec add_data(id, atom(), any()) :: ok
  @decorate noop_when_toolbar_disabled()
  def add_data(id \\ self(), key, data) do
    do_add_data(id, key, data)
  end

  @doc """
  Adds a breakpoint that can be interacted with using Breakpoints Panel on toolbar.
  """
  @spec pry() :: nil
  @decorate noop_when_toolbar_disabled(nil)
  defmacro pry do
    code_snippet = Breakpoint.code_snippet(__CALLER__)
    quote do
      file = __ENV__.file |> String.trim_leading(File.cwd!) |> Path.relative
      ExDebugToolbar.add_breakpoint(%Breakpoint{
        file: file,
        line: __ENV__.line,
        env: __ENV__,
        binding: binding(),
        code_snippet: unquote(code_snippet),
        inserted_at: NaiveDateTime.utc_now()
      })
    end
  end

  defp do_add_data(id, key, data) do
    if Map.has_key?(%Request{}, key) do
      :ok = RequestRepo.update(id, &update_request(&1, key, data))
    else
      {:error, :undefined_collection}
    end
  end

  defp update_request(%Request{} = request, key, data) do
    request |> Map.get(key) |> Collection.add(data) |> (&Map.put(request, key, &1)).()
  end
end
