defmodule Tarakan.Accounts.ClientAuthorization do
  @moduledoc """
  A short-lived browser approval initiated by Tarakan Client.

  The high-entropy device code is never stored directly. Approval binds the
  request to a browser-authenticated account; the API credential is created
  only when the client exchanges the secret device code.
  """

  use Ecto.Schema
  import Ecto.Changeset

  alias Tarakan.Accounts.Account

  @statuses ~w(pending approved denied consumed expired)

  schema "client_authorizations" do
    field :device_code_hash, :binary, redact: true
    field :user_code, :string
    field :client_name, :string
    field :scopes, {:array, :string}, default: []
    field :status, :string, default: "pending"
    field :expires_at, :utc_datetime_usec
    field :approved_at, :utc_datetime_usec
    field :consumed_at, :utc_datetime_usec

    belongs_to :account, Account

    timestamps(type: :utc_datetime_usec)
  end

  def statuses, do: @statuses

  # `client_name` arrives from the unauthenticated device-start endpoint and is
  # shown to whoever approves the request. Anyone can therefore choose the text
  # on somebody else's consent screen, so it is held to plain printable
  # characters: no control or bidirectional codes to disguise it, no newlines to
  # fake extra interface, and short enough that it reads as a name rather than
  # as a sentence of instructions. The screen labels it as client-reported for
  # the same reason - a lie should read as a claim, not as Tarakan's own copy.
  @client_name_format ~r/\A[\p{L}\p{N} ._\-\/()+@]+\z/u

  def creation_changeset(authorization, attrs) do
    authorization
    |> cast(attrs, [:client_name, :scopes, :expires_at])
    |> validate_required([:client_name, :scopes, :expires_at])
    |> validate_length(:client_name, min: 1, max: 40)
    |> validate_format(:client_name, @client_name_format,
      message: "may only contain letters, numbers, spaces and . _ - / ( ) + @"
    )
    |> validate_length(:scopes, min: 1)
    |> check_constraint(:status, name: :client_authorizations_status_must_be_valid)
    |> unique_constraint(:device_code_hash)
    |> unique_constraint(:user_code)
  end
end
