defmodule Phoenix.Sync.Shape do
  @moduledoc """
  Materialize a shape stream into memory and subscribe to changes.

  Add a `#{inspect(__MODULE__)}` to your supervision tree to subscribe to a
  sync stream and maintain a synchronized local copy of the data in-memory.

      defmodule MyApp.Application do
        use Application

        def start(_type, _args) do
          children = [
            {Phoenix.Sync.Shape, [MyApp.Todo, name: MyApp.TodoShape]}
          ]

          opts = [strategy: :one_for_one, name: MyApp.Supervisor]
          Supervisor.start_link(children, opts)
        end
      end

  Or use `start_link/1` or `start_link/2` to start a shape process manually:

      {:ok, pid} = #{inspect(__MODULE__)}.start_link(MyApp.Todo)

      {:ok, pid} = #{inspect(__MODULE__)}.start_link(MyApp.Todo, replica: :full)

  This allows you to subscribe to the shape and receive notifications and
  also retrieve the current dataset.

  `to_list/2` will return the current state of the shape as a list:

      #{inspect(__MODULE__)}.to_list(MyApp.TodoShape)
      [
        {~s|"public"."todos"/"1"|, %MyApp.Todo{id: 1}},
        {~s|"public"."todos"/"2"|, %MyApp.Todo{id: 2}},
        # ...
      ]

  `subscribe/2` registers the current process to receive change events (and
  `unsubscribe/1` allows you to stop receiving them):

      # use the registered `name` of the shape to receive change events
      # within a GenServer or other process
      def init(_args) do
        ref = #{inspect(__MODULE__)}.subscribe(MyApp.TodoShape)
        {:ok, ref}
      end

      # handle the shape events...
      def handle_info({:sync, ref, {operation, {key, _value}}, ref)
          when operation in [:insert, :update, :delete] do
        IO.inspect([{operation, key}])
        {:noreply, state}
      end

      def handle_info({:sync, ref, control}, ref)
          when control in [:up_to_date, :must_refetch] do
        IO.inspect(control: control)
        {:noreply, state}
      end

  ### Keys

  The sync stream gives every value in the source table a unique key which is
  defined as the full table name (with namespace) and the primary key value,
  e.g. `~s|"public"."todos"/"1"|`.

  If a table has a composite primary key, the key will include all primary key
  values. e.g. `~s|"public"."todos"/"1"/"123"|`.
  """

  @doc false
  use GenServer

  alias Phoenix.Sync.PredefinedShape
  alias Electric.Client.Message
  alias Phoenix.Sync.Shape

  @type shape() :: GenServer.server()
  @type key() :: String.t()
  @type value() :: term()
  @type tag() :: term()
  @type operation() :: :insert | :update | :delete
  @type control() :: :up_to_date | :must_refetch
  @type payload() :: {operation(), {key(), value()}} | control()
  @type event() :: {tag(), reference(), payload()}

  @type subscription_msg_type() :: operation() | control()
  @type subscribe_opts() :: [{:only, [subscription_msg_type()]} | {:tag, term()}]

  @type enum_opts() :: [{:keys, boolean()}]

  @type stream_options() :: [
          unquote(NimbleOptions.option_typespec(Phoenix.Sync.PredefinedShape.schema()))
          | {:name, GenServer.name()}
        ]
  @type shape_options() :: [
          Phoenix.Sync.queryable()
          | stream_options()
        ]

  @doc """
  Start a new shape process that will receive the sync stream events and
  maintain an in-memory copy of the dataset in sync with Postgres.

  ## Options

  Shapes are defined exactly as for `Phoenix.Sync.Client.stream/2`:

      # using an `Ecto.Schema` module
      {:ok, pid} = #{inspect(__MODULE__)}.start_link(MyApp.Todo)

      # or a full `Ecto.Query`
      {:ok, pid} = #{inspect(__MODULE__)}.start_link(
        from(t in MyApp.Todo, where: t.completed == true)
      )

      # we can pass extra sync options
      {:ok, pid} = #{inspect(__MODULE__)}.start_link(
        from(t in MyApp.Todo, where: t.completed == true),
        replica: :full
      )

  but also accept a `:name` much like other `GenServer` processes.

      {:ok, pid} = #{inspect(__MODULE__)}.start_link(MyApp.Todo, name: TodosShape)

  To start a Shape within a supervision tree, you pass the options as the child
  spec as if they were arguments:

      children = [
        {#{inspect(__MODULE__)}, [MyApp.Todo, name: TodoShape]}
      ]

  ## Options

  #{Electric.Client.ShapeDefinition.schema_definition() |> NimbleOptions.new!() |> NimbleOptions.docs()}
  """
  @spec start_link(shape_options()) :: GenServer.on_start()
  def start_link(args) do
    with {:ok, stream_args, shape_opts} <- validate_args(args) do
      GenServer.start_link(
        __MODULE__,
        {stream_args, shape_opts},
        Keyword.take(shape_opts, [:name])
      )
    end
  end

  @doc """
  See `start_link/1`.
  """
  @spec start_link(String.t() | Phoenix.Sync.queryable(), stream_options()) ::
          GenServer.on_start()
  def start_link(queryable, opts) do
    start_link([queryable | opts])
  end

  @doc """
  Subscribe the current process to the given shape and receive notifications
  about new changes on the stream.

      # create a shape that holds all the completed todos
      {:ok, shape} = Phoenix.Sync.Shape.start_link(from(t in Todo, where: t.completed == true))

      # subscribe this process to the shape events
      ref = Phoenix.Sync.Shape.subscribe(shape)

      receive do
        {:sync, ^ref, {:insert, {_key, todo}} ->
          # a todo has been marked as completed
        {:sync, ^ref, {:delete, {_key, todo}} ->
          # a todo has been deleted or moved from completed to not completed
        {:sync, ^ref, {:update, {_key, todo}} ->
          # the todo's title has been changed
        {:sync, ^ref, :up_to_date} ->
          # the local shape is fully synchronized with the remote server
        {:sync, ^ref, :must_refetch} ->
          # the local data has been invalidated and needs to be refetched
      end

  In the example above, changes in the `completed` state of a todo will move
  the affected item into or out of the shape because it's built using a filter
  that only allows completed todos. These moves into or out of the shape are
  mapped to `:insert` and `:delete` events, respectively.

  The message format is `{tag, reference, payload}`

  - `tag` defaults to `:sync` but can be customized using the `:tag` option (see below).

  - `reference` is the unique reference for the subscription returned by `subscribe/2`.

  - `type` is the operation type, which can be one of `:insert`, `:update`,
    `:delete`, `:up_to_date`, or `:must_refetch`.
    - `:up_to_date` indicates that the shape is fully synchronized.
    - `:must_refetch` indicates that the shape needs to be refetched due to a
    schema change, table truncation or some other server-side change.

  - `payload` depends on the operation type:
    - For `:insert`, `:update` and `:delete`, it is a tuple `{operation, {key, value}}` where `key` is a unique
      identifier for the value and `value` is the actual data.
    - For `:up_to_date` and `:must_refetch`, it is just the operation.

  ## Options

  - `only` - Limit events to only a subset of the available message types  (see
    above). If not provided, all message types will be sent. If you want to
    receive all `:insert`, `:update` and `:delete` events, you can use `only:
    :changes`.

  - `tag` - change the tag of the message sent to subscriber. Defaults to `:sync`.

  ## Examples

      # only receive notification when the shape is fully synchronized
      ref = Phoenix.Sync.Shape.subscribe(query, only: [:up_do_date])

      # only receive notification of deletes
      ref = Phoenix.Sync.Shape.subscribe(query, only: [:delete])

      # only receive notification when the shape has been invalidated and needs
      # to be refetched. Give the message a custom tag to make it easier to
      # identify the originating shape.
      ref = Phoenix.Sync.Shape.subscribe(Todo, only: [:must_refetch], tag: {:sync, :todo})
  """
  @spec subscribe(shape(), subscribe_opts()) :: reference()
  def subscribe(shape, opts \\ []) do
    GenServer.call(shape, {:subscribe, opts})
  end

  @doc """
  Unsubscribe the current process from the given shape.
  """
  @spec unsubscribe(shape()) :: :ok
  def unsubscribe(shape) do
    GenServer.call(shape, :unsubscribe)
  end

  @doc """
  Get a list of the current subscribers to the given shape.
  """
  @spec subscribers(shape()) :: [pid()]
  def subscribers(shape) do
    GenServer.call(shape, :subscribers)
  end

  @doc """
  Get all values in the shape.

  Returns a list of `{key, value}` tuples, where `key` is a unique identifier
  for the value.

  ## Options

  - `keys` (default: `true`) - if set to false, returns only the values without keys.
  """
  @spec to_list(shape(), opts :: enum_opts()) :: [{key(), value()}] | [value()]
  def to_list(shape, opts \\ []) do
    shape
    |> whereis!()
    |> :ets.match_object(:_)
    |> map_all(Keyword.get(opts, :keys, true))
  end

  defp map_all(rows, true), do: rows
  defp map_all(rows, false), do: Enum.map(rows, &elem(&1, 1))

  @doc """
  Return a lazy stream of the current values in the shape.


      {:ok, _pid} = Phoenix.Sync.Shape.start_link(MyApp.Todo, name: :todos)

      stream = Phoenix.Sync.Shape.stream(:todos)

      Enum.into(stream, %{})
      %{
        ~s|"public"."todos"/"1"| => %MyApp.Todo{id: 1},
        ~s|"public"."todos"/"2"| => %MyApp.Todo{id: 2},
        # ...
      }


  ## Options

  - `keys` (default: `true`) - if set to `false`, returns only the values without keys.
  - `batch_size` (default: `100`) - the number of rows to fetch from the
    backing ETS table in each iteration. Larger values are more efficient but will use
    more memory.
  """
  @spec stream(shape(), opts :: enum_opts()) :: Enumerable.t({key(), value()} | value())
  def stream(shape, opts \\ []) do
    table = whereis!(shape)

    match_spec =
      if Keyword.get(opts, :keys, true), do: :"$1", else: {:_, :"$1"}

    batch_size = Keyword.get(opts, :batch_size, 100)

    Stream.resource(
      fn -> nil end,
      fn
        nil ->
          case :ets.match(table, match_spec, batch_size) do
            {match, continuation} -> {List.flatten(match), continuation}
            :"$end_of_table" -> {:halt, nil}
          end

        :"$end_of_table" ->
          {:halt, nil}

        continuation ->
          case :ets.match(continuation) do
            {match, continuation} -> {List.flatten(match), continuation}
            :"$end_of_table" -> {:halt, nil}
          end
      end,
      fn _ -> :ok end
    )
  end

  @doc """
  Get all data in the shape as a map.

  The keys of the map will be the sync stream keys.

      {:ok, _pid} = Phoenix.Sync.Shape.start_link(MyApp.Todo, name: :todos)

      Phoenix.Sync.Shape.to_map(:todos)
      %{
        ~s|"public"."todos"/"1"| => %MyApp.Todo{id: 1},
        ~s|"public"."todos"/"2"| => %MyApp.Todo{id: 2},
        # ...
      }
  """
  @spec to_map(shape()) :: map()
  def to_map(shape) do
    to_map(shape, & &1)
  end

  @doc """
  Get all data in the shape as a map, transforming the keys and values.

      {:ok, _pid} = Phoenix.Sync.Shape.start_link(MyApp.Todo, name: :todos)

      Phoenix.Sync.Shape.to_map(:todos, fn {_key, %MyApp.Todo{id: id, title: title} ->
        {id, title}
      end)
      %{
        1 => "my first todo",
        2 => "my second todo",
        2 => "my third todo",
      }
  """
  @spec to_map(shape(), ({key(), value()} -> {Map.key(), Map.value()})) :: map()
  def to_map(shape, transform) do
    shape
    |> to_list()
    |> Map.new(transform)
  end

  @doc """
  A wrapper around `Enum.find/3` that searches for a value in the shape.

  The `matcher` function is only passed the shape value, not the key.
  """
  @spec find(shape(), Enum.default(), (value() -> any())) :: value() | Enum.default()
  def find(shape, default \\ nil, matcher) do
    shape
    |> stream(keys: false)
    |> Enum.find(default, matcher)
  end

  defp whereis!(shape) do
    case Shape.Registry.whereis(shape) do
      {:ok, table} -> table
      :error -> raise ArgumentError, "Shape #{inspect(shape)} is not registered"
    end
  end

  @impl GenServer
  def init({stream_args, _shape_opts}) do
    {:ok, %{stream_pid: nil, table: nil, subscriptions: %{}},
     {:continue, {:start_shape, stream_args}}}
  end

  @impl GenServer
  def handle_continue({:start_shape, stream_args}, state) do
    {:ok, table_name} = Shape.Registry.register(self())

    table =
      :ets.new(table_name, [:named_table, :ordered_set, :protected, read_concurrency: true])

    pid = self()

    stream =
      try do
        apply(Phoenix.Sync.Client, :stream, stream_args)
      rescue
        Phoenix.Sync.Sandbox.Error ->
          # TODO: some way of lazily subscribing to the stream once a shared-mode sandbox
          # is available.
          # Emit a single up-to-date message to indicate that the shape is ready
          # followed by an infinite but empty stream.
          Stream.concat(
            [[%Message.ControlMessage{control: :up_to_date}]],
            Stream.repeatedly(fn -> [] end)
          )
          |> Stream.flat_map(& &1)
      end

    {:ok, pid} =
      Task.start_link(fn ->
        stream |> Stream.each(&send(pid, {:sync_message, &1})) |> Stream.run()
      end)

    {:noreply, %{state | stream_pid: pid, table: table}}
  end

  @impl GenServer
  def handle_call(:subscribers, _from, state) do
    {:reply, Map.keys(state.subscriptions), state}
  end

  def handle_call({:subscribe, opts}, {pid, _}, state) do
    ref = Process.monitor(pid)
    filter = build_subscription_filter(opts)
    tag = Keyword.get(opts, :tag, :sync)

    state = Map.update!(state, :subscriptions, &Map.put(&1, pid, {filter, tag, ref}))

    {:reply, ref, state}
  end

  def handle_call(:unsubscribe, {pid, _}, state) do
    subscriptions = Map.delete(state.subscriptions, pid)
    {:reply, :ok, %{state | subscriptions: subscriptions}}
  end

  @impl GenServer
  def handle_info({:sync_message, msg}, state) do
    state = state |> handle_sync_message(msg) |> notify_subscribers(msg)

    {:noreply, state}
  end

  def handle_info({:DOWN, _ref, :process, pid, _reason}, state) do
    subscriptions = Map.delete(state.subscriptions, pid)
    {:noreply, %{state | subscriptions: subscriptions}}
  end

  defp notify_subscribers(state, msg) do
    event = msg_event(msg)

    Enum.each(state.subscriptions, fn {pid, {filter, tag, ref}} ->
      if filter.(msg), do: send(pid, {tag, ref, event})
    end)

    state
  end

  defp msg_event(%Message.ChangeMessage{key: key, headers: %{operation: operation}} = msg) do
    {operation, {key, msg.value}}
  end

  defp msg_event(%Message.ControlMessage{control: control}) do
    control
  end

  defp handle_sync_message(state, %Message.ChangeMessage{headers: %{operation: operation}} = msg) do
    case operation do
      :delete ->
        :ets.delete(state.table, msg.key)

      op when op in [:insert, :update] ->
        :ets.insert(state.table, {msg.key, msg.value})
    end

    state
  end

  defp handle_sync_message(state, %Message.ControlMessage{control: :up_to_date}) do
    state
  end

  defp handle_sync_message(state, %Message.ControlMessage{control: :must_refetch}) do
    :ets.delete_all_objects(state.table)
    state
  end

  defp build_subscription_filter(opts) do
    case Keyword.fetch(opts, :only) do
      {:ok, only} ->
        types = List.wrap(only)

        types =
          types
          |> Enum.flat_map(fn
            :changes -> [:insert, :update, :delete]
            type -> [type]
          end)
          |> Enum.uniq()

        fn msg -> Enum.any?(types, &message_matches_type(&1, msg)) end

      :error ->
        fn _ -> true end
    end
  end

  defp message_matches_type(operation, %Message.ChangeMessage{headers: %{operation: operation}}),
    do: true

  defp message_matches_type(control, %Message.ControlMessage{control: control}), do: true
  defp message_matches_type(_, _), do: false

  # shape may be of form [SchemaModule, where: "..."] which is fine syntactically but breaks Keyword functions
  defp validate_args(args) when is_list(args) do
    case Enum.split_with(args, &is_tuple/1) do
      {opts, []} ->
        {stream_opts, shape_opts} = extract_shape_opts(opts)
        {:ok, [shape_opts], stream_opts}

      {opts, [queryable]} ->
        if PredefinedShape.is_queryable?(queryable) do
          {stream_opts, shape_opts} = extract_shape_opts(opts)
          {:ok, [queryable, shape_opts], stream_opts}
        else
          {:error, "#{queryable} is not a valid Ecto query"}
        end

      {_opts, [_s1, _s2 | _] = queryables} ->
        {:error, "Multiple queryables provided: #{inspect(queryables)}"}
    end
  end

  defp validate_args(queryable) do
    if PredefinedShape.is_queryable?(queryable) do
      {:ok, [queryable, []], []}
    else
      {:error, "#{queryable} is not a valid Ecto query"}
    end
  end

  defp extract_shape_opts(args) when is_list(args) do
    Keyword.split(args, [:name])
  end
end
