defmodule Oban.Pro.Cron do
  @moduledoc false

  use Ecto.Schema

  import Ecto.Changeset

  alias Oban.Cron.Expression
  alias Oban.Worker

  @primary_key {:name, :string, autogenerate: false}
  schema "oban_crons" do
    field :expression, :string
    field :worker, :string
    field :opts, :map
    field :paused, :boolean
    field :insertions, {:array, :utc_datetime_usec}
    field :lock_version, :integer, default: 1
    field :parsed, :any, virtual: true

    timestamps(
      inserted_at: :inserted_at,
      updated_at: :updated_at,
      type: :utc_datetime_usec
    )
  end

  @permitted ~w(name expression insertions worker opts paused)a
  @requried ~w(name expression worker)a
  @allowed_opts ~w(args guaranteed max_attempts meta priority queue tags timezone)a

  @doc false
  def allowed_opts, do: @allowed_opts

  @spec changeset({binary(), module()} | {binary(), module(), Keyword.t()}) :: Ecto.Changeset.t()
  def changeset({expression, worker}) do
    params = %{expression: expression, name: worker, worker: worker, opts: %{}}

    changeset(%__MODULE__{}, params)
  end

  def changeset({expression, worker, opts}) do
    {name, opts} = Keyword.pop(opts, :name, worker)
    {paused, opts} = Keyword.pop(opts, :paused)
    {insertions, opts} = Keyword.pop(opts, :insertions)

    params = %{
      expression: expression,
      insertions: insertions,
      name: name,
      opts: Map.new(opts),
      paused: paused,
      worker: worker
    }

    changeset(%__MODULE__{}, params)
  end

  @spec changeset({Ecto.Schema.t(), map()}) :: Ecto.Changeset.t()
  def changeset(schema, params) when is_map(params) do
    params =
      params
      |> coerce_name(:name)
      |> coerce_name(:worker)
      |> merge_opts(schema)

    schema
    |> cast(params, @permitted)
    |> validate_required(@requried)
    |> validate_change(:expression, &expression_validator/2)
    |> validate_change(:opts, &opts_validator/2)
    |> optimistic_lock(:lock_version)
  end

  def update_changeset(schema, params) when is_map(params) do
    if Map.has_key?(params, :expression) or Map.has_key?(params, :timezone) do
      changeset(schema, Map.put(params, :insertions, []))
    else
      changeset(schema, params)
    end
  end

  defp coerce_name(params, key) do
    case params do
      %{^key => value} when is_atom(value) ->
        Map.put(params, key, Worker.to_string(value))

      _ ->
        params
    end
  end

  defp merge_opts(params, schema) do
    case Map.split(params, @allowed_opts) do
      {opts, params} when map_size(opts) > 0 ->
        opts = Map.new(opts, fn {key, val} -> {to_string(key), val} end)

        Map.put(params, :opts, Map.merge(schema.opts, opts))

      _ ->
        params
    end
  end

  # Validators

  defp expression_validator(:expression, expression) do
    Expression.parse!(expression)

    []
  rescue
    ArgumentError ->
      [expression: "expected cron expression to be a parsable binary"]
  end

  defp opts_validator(:opts, opts) do
    string_keys = Enum.map(@allowed_opts, &to_string/1)

    if Enum.all?(opts, fn {key, _} -> to_string(key) in string_keys end) do
      []
    else
      [opts: "expected cron opts to be one of #{inspect(@allowed_opts)}"]
    end
  end
end
