lib/memorex/domain/card.ex

defmodule Memorex.Domain.Card do
  @moduledoc """
  A `Memorex.Domain.Card` is the entity in Memorex which is reviewed/drilled by `MemorexWeb.ReviewLive`.
  A `Memorex.Domain.Card` belongs to a `Memorex.Domain.Note`.  The `Memorex.Domain.Card` contains only drilling-related
  info; the actual flashcard content is contained in the `Memorex.Domain.Note`.
  """

  use Memorex.Ecto.Schema
  import Ecto.Changeset

  alias Memorex.Domain.{CardLog, Note}
  alias Memorex.Ecto.{TimexDuration, Schema}
  alias Timex.Duration

  @type card_type :: :new | :learn | :review | :relearn
  @card_types [:new, :learn, :review, :relearn]
  @spec card_types() :: [card_type()]
  def card_types(), do: @card_types

  @type card_status :: :active | :suspended | :buried
  @card_statuses [:active, :suspended, :buried]
  @spec card_statuses() :: [card_status()]
  def card_statuses(), do: @card_statuses

  @type answer_choice :: :again | :hard | :good | :easy
  @answer_choices [:again, :hard, :good, :easy]
  @spec answer_choices() :: [answer_choice()]
  def answer_choices(), do: @answer_choices

  @type t :: %__MODULE__{
          id: Schema.id() | nil,
          #
          card_status: card_status(),
          card_type: card_type(),
          current_step: non_neg_integer(),
          due: DateTime.t(),
          ease_factor: float(),
          interval: Duration.t(),
          interval_prior_to_lapse: Duration.t(),
          lapses: non_neg_integer(),
          note_answer_index: non_neg_integer(),
          note_question_index: non_neg_integer(),
          reps: non_neg_integer(),
          #
          note_id: Schema.id(),
          #
          inserted_at: DateTime.t(),
          updated_at: DateTime.t()
        }

  schema "cards" do
    field :card_status, Ecto.Enum, values: [:active, :suspended, :buried], default: :active
    field :card_type, Ecto.Enum, values: [:new, :learn, :review, :relearn], default: :new
    field :current_step, :integer
    field :due, :utc_datetime
    field :ease_factor, :float
    field :interval, TimexDuration
    field :interval_prior_to_lapse, TimexDuration
    field :lapses, :integer
    field :note_answer_index, :integer
    field :note_question_index, :integer
    field :reps, :integer

    belongs_to :note, Note
    has_one :deck, through: [:note, :deck]
    has_many :card_logs, CardLog, preload_order: [desc: :inserted_at]

    timestamps()
  end

  @spec changeset(Ecto.Changeset.t() | t(), map()) :: Ecto.Changeset.t()
  def changeset(card, params \\ %{}) do
    card
    |> cast(params, [
      :card_status,
      :card_type,
      :current_step,
      :due,
      :ease_factor,
      :lapses,
      :note_answer_index,
      :note_question_index,
      :reps
    ])
    |> cast_duration_field(:interval, params)
    |> cast_duration_field(:interval_prior_to_lapse, params)
  end

  @spec set_due_field_in_changeset(Ecto.Changeset.t() | t(), DateTime.t()) :: Ecto.Changeset.t()
  def set_due_field_in_changeset(changeset, time) do
    interval = Ecto.Changeset.get_field(changeset, :interval)

    changeset
    |> cast(%{due: Timex.add(time, interval)}, [:due])
  end

  @spec increment_reps(Ecto.Changeset.t()) :: Ecto.Changeset.t()
  def increment_reps(changeset) do
    reps = Ecto.Changeset.get_field(changeset, :reps)

    changeset
    |> cast(%{reps: reps + 1}, [:reps])
  end

  @spec is_image_card?(t()) :: boolean()
  def is_image_card?(card) do
    card.note.image_file_path != nil
  end

  @spec question(t()) :: String.t()
  def question(card) do
    card.note.content |> List.pop_at(card.note_question_index) |> elem(0)
  end

  @spec answer(t()) :: String.t()
  def answer(card) do
    card.note.content |> List.pop_at(card.note_answer_index) |> elem(0)
  end

  # Casts a duration field (i.e., `:interval` or `:interval_prior_to_lapse`.  If the field comes in as a string param
  # (such as "PT30M") then it is converted to a `Time x.Duration`.
  @spec cast_duration_field(Ecto.Changeset.t(), atom(), map()) :: Ecto.Changeset.t()
  defp cast_duration_field(changeset, field_name, params) do
    string_field_name = field_name |> Atom.to_string()

    if Map.has_key?(params, string_field_name) || Map.has_key?(params, field_name) do
      value = Map.get(params, string_field_name)
      value = value || Map.get(params, field_name)
      add_to_changeset = &cast(changeset, %{string_field_name => &1}, [field_name])

      if is_binary(value) do
        case Duration.parse(value) do
          {:ok, duration} -> add_to_changeset.(duration)
          {:error, _reason} -> changeset |> add_error(field_name, "invalid duration")
        end
      else
        add_to_changeset.(value)
      end
    else
      changeset
    end
  end
end