defmodule PaperTrail do
  alias PaperTrail.Version
  alias PaperTrail.Serializer

  defdelegate get_version(record), to: PaperTrail.VersionQueries
  defdelegate get_version(model_or_record, id_or_options), to: PaperTrail.VersionQueries
  defdelegate get_version(model, id, options), to: PaperTrail.VersionQueries
  defdelegate has_version?(record), to: PaperTrail.VersionQueries
  defdelegate has_version?(model_or_record, id_or_options), to: PaperTrail.VersionQueries
  defdelegate has_version?(model, id, options), to: PaperTrail.VersionQueries
  defdelegate get_versions(record), to: PaperTrail.VersionQueries
  defdelegate get_versions(model_or_record, id_or_options), to: PaperTrail.VersionQueries
  defdelegate get_versions(model, id, options), to: PaperTrail.VersionQueries
  defdelegate get_current_model(version), to: PaperTrail.VersionQueries
  defdelegate make_version_struct(version, model, options), to: Serializer
  defdelegate serialize(data), to: Serializer
  defdelegate get_sequence_id(table_name), to: Serializer
  defdelegate add_prefix(schema, prefix), to: Serializer
  defdelegate get_item_type(data), to: Serializer
  defdelegate get_model_id(model), to: Serializer

  @default_transaction_options [
    origin: nil,
    meta: nil,
    originator: nil,
    prefix: nil,
    model_key: :model,
    version_key: :version,
    ecto_options: []
  ]

  @doc """
  Explicitly inserts a non-versioned already existing record into the Versions table
  """
  def initialise(
        model,
        options \\ [origin: nil, meta: nil, originator: nil, prefix: nil, version_key: :version]
      ) do
    case has_version?(model) do
      false ->
        with {:ok, _} <-
               make_version_struct(%{event: "insert"}, model, options)
               |> PaperTrail.RepoClient.repo().insert() do
          :ok
        end

      _ ->
        # already initalised
        :ok
    end
  end

  @doc """
  Inserts a record to the database with a related version insertion in one transaction
  """
  @spec insert(
          changeset :: Ecto.Changeset.t(model),
          options :: Keyword.t()
        ) ::
          {:ok, %{model: model, version: Version.t()}} | {:error, Ecto.Changeset.t(model) | term}
        when model: struct
  def insert(changeset, options \\ @default_transaction_options) do
    PaperTrail.Multi.new()
    |> PaperTrail.Multi.insert(changeset, options)
    |> PaperTrail.Multi.commit()
  end

  @doc """
  Same as insert/2 but returns only the model struct or raises if the changeset is invalid.
  """
  @spec insert!(changeset :: Ecto.Changeset.t(model), options :: Keyword.t()) :: model
        when model: struct
  def insert!(changeset, options \\ @default_transaction_options) do
    changeset
    |> insert(options)
    |> model_or_error(:insert)
  end

  @doc """
  Upserts a record to the database with a related version insertion in one transaction.
  """
  @spec insert_or_update(changeset :: Ecto.Changeset.t(model), options :: Keyword.t()) ::
          {:ok, %{model: model, version: Version.t()}} | {:error, Ecto.Changeset.t(model) | term}
        when model: struct
  def insert_or_update(changeset, options \\ @default_transaction_options) do
    PaperTrail.Multi.new()
    |> PaperTrail.Multi.insert_or_update(changeset, options)
    |> PaperTrail.Multi.commit()
  end

  @doc """
  Same as insert_or_update/2 but returns only the model struct or raises if the changeset is invalid.
  """
  @spec insert_or_update!(changeset :: Ecto.Changeset.t(model), options :: Keyword.t()) :: model
        when model: struct
  def insert_or_update!(changeset, options \\ @default_transaction_options) do
    changeset
    |> insert_or_update(options)
    |> model_or_error(:insert_or_update)
  end

  @doc """
  Updates a record from the database with a related version insertion in one transaction
  """
  @spec update(changeset :: Ecto.Changeset.t(model), options :: Keyword.t()) ::
          {:ok, %{model: model, version: Version.t()}} | {:error, Ecto.Changeset.t(model) | term}
        when model: struct
  def update(changeset, options \\ @default_transaction_options) do
    PaperTrail.Multi.new()
    |> PaperTrail.Multi.update(changeset, options)
    |> PaperTrail.Multi.commit()
  end

  @doc """
  Same as update/2 but returns only the model struct or raises if the changeset is invalid.
  """
  @spec update!(changeset :: Ecto.Changeset.t(model), options :: Keyword.t()) :: model
        when model: struct
  def update!(changeset, options \\ @default_transaction_options) do
    changeset
    |> update(options)
    |> model_or_error(:update)
  end

  @doc """
  Deletes a record from the database with a related version insertion in one transaction
  """
  @spec delete(model_or_changeset :: model | Ecto.Changeset.t(model), options :: Keyword.t()) ::
          {:ok, %{model: model, version: Version.t()}} | {:error, Ecto.Changeset.t(model) | term}
        when model: struct
  def delete(model_or_changeset, options \\ @default_transaction_options) do
    PaperTrail.Multi.new()
    |> PaperTrail.Multi.delete(model_or_changeset, options)
    |> PaperTrail.Multi.commit()
  end

  @doc """
  Same as delete/2 but returns only the model struct or raises if the changeset is invalid.
  """
  @spec delete!(model_or_changeset :: model | Ecto.Changeset.t(model), options :: Keyword.t()) ::
          model
        when model: struct
  def delete!(model_or_changeset, options \\ @default_transaction_options) do
    model_or_changeset
    |> delete(options)
    |> model_or_error(:delete)
  end

  @spec model_or_error(
          result :: {:ok, %{required(:model) => model, optional(any()) => any()}},
          action :: :insert | :insert_or_update | :update | :delete
        ) ::
          model
        when model: struct()
  defp model_or_error({:ok, %{model: model}}, _action) do
    model
  end

  @spec model_or_error(
          result :: {:error, reason :: term},
          action :: :insert | :insert_or_update | :update | :delete
        ) :: no_return
  defp model_or_error({:error, %Ecto.Changeset{} = changeset}, action) do
    raise Ecto.InvalidChangesetError, action: action, changeset: changeset
  end

  defp model_or_error({:error, reason}, _action) do
    raise reason
  end
end
