defmodule Livebook.Notebook.AppSettings do
  # Data structure configuring how notebook gets deployed as an app.

  use Ecto.Schema

  import Ecto.Changeset, except: [change: 1, change: 2]

  @type t :: %__MODULE__{
          slug: String.t() | nil,
          multi_session: boolean(),
          zero_downtime: boolean(),
          show_existing_sessions: boolean(),
          auto_shutdown_ms: pos_integer() | nil,
          access_type: access_type(),
          password: String.t() | nil,
          show_source: boolean(),
          output_type: output_type()
        }

  @type access_type :: :public | :protected
  @type output_type :: :all | :rich

  @access_types [:public, :protected]

  @primary_key false
  embedded_schema do
    field :slug, :string
    field :multi_session, :boolean
    field :zero_downtime, :boolean
    field :show_existing_sessions, :boolean
    field :auto_shutdown_ms, :integer
    field :access_type, Ecto.Enum, values: @access_types
    field :password, :string
    field :show_source, :boolean
    field :output_type, Ecto.Enum, values: [:all, :rich]
  end

  @doc """
  Returns default app settings.
  """
  @spec new() :: t()
  def new() do
    %__MODULE__{
      slug: nil,
      multi_session: false,
      zero_downtime: false,
      show_existing_sessions: false,
      auto_shutdown_ms: nil,
      access_type: :protected,
      password: generate_password(),
      show_source: false,
      output_type: :all
    }
  end

  defp generate_password() do
    :crypto.strong_rand_bytes(10) |> Base.encode32(case: :lower)
  end

  @doc """
  Returns an `%Ecto.Changeset{}` for tracking settings changes.
  """
  @spec change(t(), map()) :: Ecto.Changeset.t()
  def change(%__MODULE__{} = settings, attrs \\ %{}) do
    changeset(settings, attrs)
  end

  @doc """
  Updates settings with the given changes.
  """
  @spec update(t(), map()) :: {:ok, t()} | {:error, Ecto.Changeset.t()}
  def update(%__MODULE__{} = settings, attrs) do
    changeset = changeset(settings, attrs)
    apply_action(changeset, :update)
  end

  defp changeset(settings, attrs) do
    settings
    |> cast(attrs, [
      :slug,
      :multi_session,
      :auto_shutdown_ms,
      :access_type,
      :show_source,
      :output_type
    ])
    |> validate_required([
      :slug,
      :multi_session,
      :access_type,
      :show_source,
      :output_type
    ])
    |> validate_format(:slug, ~r/^[a-z0-9-]+$/,
      message: "should only contain lowercase alphanumeric characters and dashes"
    )
    |> cast_access_attrs(attrs)
    |> cast_mode_specific_attrs(attrs)
    |> put_defaults()
  end

  defp cast_access_attrs(changeset, attrs) do
    case get_field(changeset, :access_type) do
      :protected ->
        changeset
        |> cast(attrs, [:password])
        |> validate_required([:password])
        |> validate_length(:password, min: 12)

      _other ->
        changeset
    end
  end

  defp cast_mode_specific_attrs(changeset, attrs) do
    case get_field(changeset, :multi_session) do
      false ->
        changeset
        |> cast(attrs, [:zero_downtime])
        |> validate_required([:zero_downtime])
        # Listing sessions is not applicable to single-session apps,
        # since they have a single session at a time. We reset to
        # the default, so we do not persist it unnecessarily
        |> put_change(:show_existing_sessions, false)

      true ->
        changeset
        |> cast(attrs, [:show_existing_sessions])
        |> validate_required([:show_existing_sessions])
        # Zero-downtime deployment is not applicable to multi-session
        # apps, since they are inherently zero-downtime. We reset to
        # the default, so we do not persist it unnecessarily
        |> put_change(:zero_downtime, false)
    end
  end

  defp put_defaults(changeset) do
    case get_change(changeset, :multi_session) do
      nil ->
        changeset

      false ->
        changeset
        |> put_change(:auto_shutdown_ms, nil)

      true ->
        changeset
        |> put_change(:auto_shutdown_ms, 5_000)
    end
  end

  @doc """
  Checks if the app settings are complete and valid.
  """
  @spec valid?(t()) :: boolean()
  def valid?(settings) do
    change(settings).valid?
  end

  @doc """
  Returns a list of possible access types.
  """
  @spec access_types() :: list(atom())
  def access_types(), do: @access_types
end
