Skip to main content

lib/crosswake/companions/chimeway/contracts.ex

defmodule Crosswake.Companions.Chimeway.Contracts do
  @moduledoc """
  Provider-neutral Chimeway token evidence and binding contracts.

  Token evidence is diagnostic/provider evidence only. Backend-owned token
  bindings decide lifecycle state; neither evidence nor provider feedback grants
  auth, session, route, notification-open, or delivery authority.
  """

  alias Crosswake.Bridge.Commands.PermissionsStatus

  @providers [:apns, :fcm]
  @platforms [:ios, :android]
  @environments [:sandbox, :production, :development, :unknown]
  @notification_statuses PermissionsStatus.supported_statuses()
  @binding_states [:active, :superseded, :revoked, :stale, :invalid]
  @binding_reasons [
    :initial_bind,
    :token_rotated,
    :logout_revoked,
    :session_revoked,
    :permission_denied,
    :provider_unregistered,
    :provider_invalid_token,
    :environment_mismatch,
    :app_identity_mismatch,
    :staleness_pruned,
    :manual_revocation
  ]
  @provider_feedback_events [
    :token_unregistered,
    :token_invalid,
    :environment_mismatch,
    :app_identity_mismatch,
    :credentials_invalid,
    :provider_throttled,
    :provider_unavailable,
    :delivery_accepted,
    :delivery_failed
  ]
  @app_identity_postures [:matched, :mismatched, :unknown]
  @binding_event_types [:observed, :bound, :rotated, :revoked, :stale, :invalidated, :feedback]
  @binding_result_statuses [:bound, :rotated, :revoked, :stale, :invalidated, :rejected]
  @proof_classes [:hermetic, :advisory, :not_applicable]
  @forbidden_public_token_keys [
    :token,
    :raw_token,
    :device_token,
    :registration_token,
    :apns_token,
    :fcm_token
  ]

  defmodule TokenEvidence do
    @moduledoc false

    @enforce_keys [
      :provider,
      :platform,
      :environment,
      :installation_ref,
      :token_ref,
      :token_fingerprint,
      :notification_status,
      :observed_at
    ]
    defstruct [
      :provider,
      :platform,
      :environment,
      :installation_ref,
      :token_ref,
      :token_fingerprint,
      :notification_status,
      :observed_at,
      :app_identity_posture,
      :correlation_id,
      metadata: %{}
    ]

    @type t :: %__MODULE__{
            provider: atom(),
            platform: atom(),
            environment: atom(),
            installation_ref: String.t(),
            token_ref: String.t(),
            token_fingerprint: String.t(),
            notification_status: atom(),
            observed_at: String.t(),
            app_identity_posture: atom() | nil,
            correlation_id: String.t() | nil,
            metadata: map()
          }
  end

  defmodule TokenBinding do
    @moduledoc false

    @enforce_keys [
      :binding_ref,
      :installation_ref,
      :provider,
      :platform,
      :environment,
      :token_ref,
      :token_fingerprint,
      :state,
      :reason,
      :bound_at,
      :last_seen_at
    ]
    defstruct [
      :binding_ref,
      :installation_ref,
      :provider,
      :platform,
      :environment,
      :token_ref,
      :token_fingerprint,
      :state,
      :reason,
      :bound_at,
      :last_seen_at,
      :subject_scope,
      :subject_ref,
      :org_ref,
      :session_ref,
      :session_version,
      :app_identity_posture,
      :superseded_at,
      :revoked_at,
      :stale_at,
      :invalidated_at,
      :audit_ref,
      metadata: %{}
    ]

    @type t :: %__MODULE__{
            binding_ref: String.t(),
            installation_ref: String.t(),
            provider: atom(),
            platform: atom(),
            environment: atom(),
            token_ref: String.t(),
            token_fingerprint: String.t(),
            state: atom(),
            reason: atom(),
            bound_at: String.t(),
            last_seen_at: String.t(),
            subject_scope: atom() | String.t() | nil,
            subject_ref: String.t() | nil,
            org_ref: String.t() | nil,
            session_ref: String.t() | nil,
            session_version: non_neg_integer() | nil,
            app_identity_posture: atom() | nil,
            superseded_at: String.t() | nil,
            revoked_at: String.t() | nil,
            stale_at: String.t() | nil,
            invalidated_at: String.t() | nil,
            audit_ref: String.t() | nil,
            metadata: map()
          }
  end

  defmodule ProviderFeedback do
    @moduledoc false

    @enforce_keys [:provider, :platform, :environment, :feedback_event, :occurred_at]
    defstruct [
      :provider,
      :platform,
      :environment,
      :feedback_event,
      :occurred_at,
      :token_ref,
      :token_fingerprint,
      :provider_evidence_ref,
      :app_identity_posture,
      :correlation_id,
      metadata: %{}
    ]

    @type t :: %__MODULE__{
            provider: atom(),
            platform: atom(),
            environment: atom(),
            feedback_event: atom(),
            occurred_at: String.t(),
            token_ref: String.t() | nil,
            token_fingerprint: String.t() | nil,
            provider_evidence_ref: String.t() | nil,
            app_identity_posture: atom() | nil,
            correlation_id: String.t() | nil,
            metadata: map()
          }
  end

  defmodule BindingEvent do
    @moduledoc false

    @enforce_keys [:event_ref, :event_type, :occurred_at]
    defstruct [
      :event_ref,
      :event_type,
      :binding_ref,
      :token_ref,
      :token_fingerprint,
      :provider,
      :platform,
      :environment,
      :installation_ref,
      :state_before,
      :state_after,
      :reason,
      :feedback_event,
      :app_identity_posture,
      :notification_status,
      :occurred_at,
      :correlation_id,
      :proof_class,
      metadata: %{}
    ]

    @type t :: %__MODULE__{event_ref: String.t(), event_type: atom(), occurred_at: String.t()}
  end

  defmodule BindingResult do
    @moduledoc false

    @enforce_keys [:status, :binding_ref]
    defstruct [
      :status,
      :binding_ref,
      :token_ref,
      :token_fingerprint,
      :state,
      :reason,
      :event_ref,
      :correlation_id,
      metadata: %{}
    ]

    @type t :: %__MODULE__{status: atom(), binding_ref: String.t()}
  end

  defmodule NotificationOpenEvidence do
    @moduledoc false

    @enforce_keys [
      :route_id,
      :open_ref,
      :binding_ref,
      :provider,
      :action_ref,
      :auth_context
    ]
    defstruct [
      :route_id,
      :open_ref,
      :binding_ref,
      :provider,
      :action_ref,
      :auth_context,
      :action_kind,
      :evaluated_at,
      metadata: %{}
    ]

    @type t :: %__MODULE__{
            route_id: String.t(),
            open_ref: String.t(),
            binding_ref: String.t(),
            provider: atom(),
            action_ref: String.t(),
            auth_context: map(),
            action_kind: atom() | nil,
            evaluated_at: String.t() | nil,
            metadata: map()
          }
  end

  defmodule OpenResolution do
    @moduledoc false

    @enforce_keys [:open_ref, :state]
    defstruct [
      :open_ref,
      :state,
      :reason,
      :resolved_at,
      metadata: %{}
    ]

    @type t :: %__MODULE__{
            open_ref: String.t(),
            state: atom(),
            reason: atom() | nil,
            resolved_at: String.t() | nil,
            metadata: map()
          }
  end

  @spec providers() :: [atom()]
  def providers, do: @providers

  @spec platforms() :: [atom()]
  def platforms, do: @platforms

  @spec environments() :: [atom()]
  def environments, do: @environments

  @spec notification_statuses() :: [atom()]
  def notification_statuses, do: @notification_statuses

  @spec binding_states() :: [atom()]
  def binding_states, do: @binding_states

  @spec binding_reasons() :: [atom()]
  def binding_reasons, do: @binding_reasons

  @spec provider_feedback_events() :: [atom()]
  def provider_feedback_events, do: @provider_feedback_events

  @spec app_identity_postures() :: [atom()]
  def app_identity_postures, do: @app_identity_postures

  @spec binding_event_types() :: [atom()]
  def binding_event_types, do: @binding_event_types

  @spec binding_result_statuses() :: [atom()]
  def binding_result_statuses, do: @binding_result_statuses

  @spec forbidden_public_token_keys() :: [atom()]
  def forbidden_public_token_keys, do: @forbidden_public_token_keys

  @spec new_token_evidence(map() | keyword()) :: {:ok, TokenEvidence.t()} | {:error, keyword()}
  def new_token_evidence(attrs),
    do:
      attrs
      |> normalize_attrs()
      |> build(TokenEvidence, &validate_token_evidence/1)

  @spec new_token_evidence!(map() | keyword()) :: TokenEvidence.t()
  def new_token_evidence!(attrs), do: unwrap!(new_token_evidence(attrs))

  @spec new_token_binding(map() | keyword()) :: {:ok, TokenBinding.t()} | {:error, keyword()}
  def new_token_binding(attrs),
    do:
      attrs
      |> normalize_attrs()
      |> build(TokenBinding, &validate_token_binding/1)

  @spec new_token_binding!(map() | keyword()) :: TokenBinding.t()
  def new_token_binding!(attrs), do: unwrap!(new_token_binding(attrs))

  @spec new_provider_feedback(map() | keyword()) ::
          {:ok, ProviderFeedback.t()} | {:error, keyword()}
  def new_provider_feedback(attrs),
    do:
      attrs
      |> normalize_attrs()
      |> build(ProviderFeedback, &validate_provider_feedback/1)

  @spec new_provider_feedback!(map() | keyword()) :: ProviderFeedback.t()
  def new_provider_feedback!(attrs), do: unwrap!(new_provider_feedback(attrs))

  @spec new_binding_event(map() | keyword()) :: {:ok, BindingEvent.t()} | {:error, keyword()}
  def new_binding_event(attrs),
    do:
      attrs
      |> normalize_attrs()
      |> build(BindingEvent, &validate_binding_event/1)

  @spec new_binding_event!(map() | keyword()) :: BindingEvent.t()
  def new_binding_event!(attrs), do: unwrap!(new_binding_event(attrs))

  @spec new_binding_result(map() | keyword()) :: {:ok, BindingResult.t()} | {:error, keyword()}
  def new_binding_result(attrs),
    do:
      attrs
      |> normalize_attrs()
      |> build(BindingResult, &validate_binding_result/1)

  @spec new_binding_result!(map() | keyword()) :: BindingResult.t()
  def new_binding_result!(attrs), do: unwrap!(new_binding_result(attrs))

  @spec new_notification_open_evidence(map() | keyword()) :: {:ok, NotificationOpenEvidence.t()} | {:error, keyword()}
  def new_notification_open_evidence(attrs),
    do:
      attrs
      |> normalize_attrs()
      |> build(NotificationOpenEvidence, &validate_notification_open_evidence/1)

  @spec new_notification_open_evidence!(map() | keyword()) :: NotificationOpenEvidence.t()
  def new_notification_open_evidence!(attrs), do: unwrap!(new_notification_open_evidence(attrs))

  @spec new_open_resolution(map() | keyword()) :: {:ok, OpenResolution.t()} | {:error, keyword()}
  def new_open_resolution(attrs),
    do:
      attrs
      |> normalize_attrs()
      |> build(OpenResolution, &validate_open_resolution/1)

  @spec new_open_resolution!(map() | keyword()) :: OpenResolution.t()
  def new_open_resolution!(attrs), do: unwrap!(new_open_resolution(attrs))

  @spec validate_token_evidence(TokenEvidence.t()) :: :ok | {:error, keyword()}
  def validate_token_evidence(%TokenEvidence{} = evidence) do
    []
    |> validate_closed(:provider, evidence.provider, @providers)
    |> validate_closed(:platform, evidence.platform, @platforms)
    |> validate_closed(:environment, evidence.environment, @environments)
    |> validate_required_string(:installation_ref, evidence.installation_ref)
    |> validate_required_string(:token_ref, evidence.token_ref)
    |> validate_required_string(:token_fingerprint, evidence.token_fingerprint)
    |> validate_closed(:notification_status, evidence.notification_status, @notification_statuses)
    |> validate_required_string(:observed_at, evidence.observed_at)
    |> validate_optional_closed(
      :app_identity_posture,
      evidence.app_identity_posture,
      @app_identity_postures
    )
    |> to_result()
  end

  def validate_token_evidence(_evidence), do: {:error, [token_evidence: :invalid_contract]}

  @spec validate_token_binding(TokenBinding.t()) :: :ok | {:error, keyword()}
  def validate_token_binding(%TokenBinding{} = binding) do
    []
    |> validate_required_string(:binding_ref, binding.binding_ref)
    |> validate_required_string(:installation_ref, binding.installation_ref)
    |> validate_closed(:provider, binding.provider, @providers)
    |> validate_closed(:platform, binding.platform, @platforms)
    |> validate_closed(:environment, binding.environment, @environments)
    |> validate_required_string(:token_ref, binding.token_ref)
    |> validate_required_string(:token_fingerprint, binding.token_fingerprint)
    |> validate_closed(:state, binding.state, @binding_states)
    |> validate_closed(:reason, binding.reason, @binding_reasons)
    |> validate_required_string(:bound_at, binding.bound_at)
    |> validate_required_string(:last_seen_at, binding.last_seen_at)
    |> validate_optional_closed(
      :app_identity_posture,
      binding.app_identity_posture,
      @app_identity_postures
    )
    |> to_result()
  end

  def validate_token_binding(_binding), do: {:error, [token_binding: :invalid_contract]}

  @spec validate_provider_feedback(ProviderFeedback.t()) :: :ok | {:error, keyword()}
  def validate_provider_feedback(%ProviderFeedback{} = feedback) do
    []
    |> validate_closed(:provider, feedback.provider, @providers)
    |> validate_closed(:platform, feedback.platform, @platforms)
    |> validate_closed(:environment, feedback.environment, @environments)
    |> validate_closed(:feedback_event, feedback.feedback_event, @provider_feedback_events)
    |> validate_required_string(:occurred_at, feedback.occurred_at)
    |> validate_optional_closed(
      :app_identity_posture,
      feedback.app_identity_posture,
      @app_identity_postures
    )
    |> to_result()
  end

  def validate_provider_feedback(_feedback), do: {:error, [provider_feedback: :invalid_contract]}

  @spec validate_binding_event(BindingEvent.t()) :: :ok | {:error, keyword()}
  def validate_binding_event(%BindingEvent{} = event) do
    []
    |> validate_required_string(:event_ref, event.event_ref)
    |> validate_closed(:event_type, event.event_type, @binding_event_types)
    |> validate_optional_closed(:provider, event.provider, @providers)
    |> validate_optional_closed(:platform, event.platform, @platforms)
    |> validate_optional_closed(:environment, event.environment, @environments)
    |> validate_optional_closed(:state_before, event.state_before, @binding_states)
    |> validate_optional_closed(:state_after, event.state_after, @binding_states)
    |> validate_optional_closed(:reason, event.reason, @binding_reasons)
    |> validate_optional_closed(:feedback_event, event.feedback_event, @provider_feedback_events)
    |> validate_optional_closed(
      :app_identity_posture,
      event.app_identity_posture,
      @app_identity_postures
    )
    |> validate_optional_closed(
      :notification_status,
      event.notification_status,
      @notification_statuses
    )
    |> validate_required_string(:occurred_at, event.occurred_at)
    |> validate_optional_closed(:proof_class, event.proof_class, @proof_classes)
    |> to_result()
  end

  def validate_binding_event(_event), do: {:error, [binding_event: :invalid_contract]}

  @spec validate_binding_result(BindingResult.t()) :: :ok | {:error, keyword()}
  def validate_binding_result(%BindingResult{} = result) do
    []
    |> validate_closed(:status, result.status, @binding_result_statuses)
    |> validate_required_string(:binding_ref, result.binding_ref)
    |> validate_optional_closed(:state, result.state, @binding_states)
    |> validate_optional_closed(:reason, result.reason, @binding_reasons)
    |> to_result()
  end

  def validate_binding_result(_result), do: {:error, [binding_result: :invalid_contract]}

  @spec validate_notification_open_evidence(NotificationOpenEvidence.t()) :: :ok | {:error, keyword()}
  def validate_notification_open_evidence(%NotificationOpenEvidence{} = evidence) do
    []
    |> validate_required_string(:route_id, evidence.route_id)
    |> validate_required_string(:open_ref, evidence.open_ref)
    |> validate_required_string(:binding_ref, evidence.binding_ref)
    |> validate_closed(:provider, evidence.provider, @providers)
    |> validate_required_string(:action_ref, evidence.action_ref)
    |> to_result()
  end

  def validate_notification_open_evidence(_evidence), do: {:error, [notification_open_evidence: :invalid_contract]}

  @spec validate_open_resolution(OpenResolution.t()) :: :ok | {:error, keyword()}
  def validate_open_resolution(%OpenResolution{} = resolution) do
    []
    |> validate_required_string(:open_ref, resolution.open_ref)
    |> to_result()
  end

  def validate_open_resolution(_resolution), do: {:error, [open_resolution: :invalid_contract]}

  @spec lifecycle_mapping() :: map()
  def lifecycle_mapping do
    %{
      active: %{state: :active, reason: :initial_bind},
      rotated: %{state: :superseded, reason: :token_rotated},
      revoked: %{state: :revoked, reason: :manual_revocation},
      stale: %{state: :stale, reason: :staleness_pruned},
      invalid: %{state: :invalid, reason: :provider_invalid_token},
      permission_denied: %{state: :revoked, reason: :permission_denied},
      environment_mismatched: %{state: :invalid, reason: :environment_mismatch},
      app_identity_mismatched: %{state: :invalid, reason: :app_identity_mismatch}
    }
  end

  @spec provider_handoff_event() :: :delivery_accepted
  def provider_handoff_event, do: :delivery_accepted

  @spec to_map(struct()) :: map()
  def to_map(%module{} = struct)
      when module in [
             TokenEvidence,
             TokenBinding,
             ProviderFeedback,
             BindingEvent,
             BindingResult,
             NotificationOpenEvidence,
             OpenResolution
           ] do
    struct
    |> Map.from_struct()
    |> Enum.reject(fn {_key, value} -> is_nil(value) end)
    |> Enum.map(fn {key, value} -> {Atom.to_string(key), stringify(value)} end)
    |> Map.new()
  end

  defp normalize_attrs(attrs) when is_list(attrs), do: attrs |> Map.new() |> normalize_attrs()

  defp normalize_attrs(attrs) when is_map(attrs) do
    attrs
    |> Enum.map(fn {key, value} -> {normalize_key(key), normalize_value(value)} end)
    |> Map.new()
  end

  defp normalize_attrs(_attrs), do: :invalid_attrs

  defp build(:invalid_attrs, _module, _validator), do: {:error, [attrs: :invalid]}

  defp build(attrs, module, validator) do
    with :ok <- reject_forbidden_token_attrs(attrs),
         {:ok, struct} <- struct_from_attrs(module, attrs),
         :ok <- validator.(struct) do
      {:ok, struct}
    end
  end

  defp struct_from_attrs(module, attrs) do
    {:ok, struct!(module, attrs)}
  rescue
    error in [ArgumentError, KeyError] -> {:error, [attrs: Exception.message(error)]}
  end

  defp reject_forbidden_token_attrs(attrs) do
    case Enum.find(@forbidden_public_token_keys, &Map.has_key?(attrs, &1)) do
      nil -> :ok
      key -> {:error, [{key, :raw_token_field_forbidden}]}
    end
  end

  defp unwrap!({:ok, struct}), do: struct

  defp unwrap!({:error, errors}),
    do: raise(ArgumentError, "invalid Chimeway contract: #{inspect(errors)}")

  defp validate_required_string(errors, key, value) when is_binary(value) do
    if byte_size(String.trim(value)) > 0, do: errors, else: [{key, :required} | errors]
  end

  defp validate_required_string(errors, key, _value), do: [{key, :required} | errors]

  defp validate_closed(errors, key, value, allowed) do
    if value in allowed, do: errors, else: [{key, {:unsupported, value, allowed}} | errors]
  end

  defp validate_optional_closed(errors, _key, nil, _allowed), do: errors

  defp validate_optional_closed(errors, key, value, allowed),
    do: validate_closed(errors, key, value, allowed)

  defp to_result([]), do: :ok
  defp to_result(errors), do: {:error, Enum.reverse(errors)}

  defp normalize_key(key) when is_atom(key), do: key

  defp normalize_key(key) when is_binary(key) do
    try do
      String.to_existing_atom(key)
    rescue
      ArgumentError -> key
    end
  end

  defp normalize_key(key), do: key

  defp normalize_value(value) when is_binary(value) do
    case value do
      "apns" -> :apns
      "fcm" -> :fcm
      "ios" -> :ios
      "android" -> :android
      "sandbox" -> :sandbox
      "production" -> :production
      "development" -> :development
      "unknown" -> :unknown
      "granted" -> :granted
      "denied" -> :denied
      "restricted" -> :restricted
      _ -> value
    end
  end

  defp normalize_value(value), do: value

  defp stringify(value) when is_atom(value), do: Atom.to_string(value)
  defp stringify(value) when is_list(value), do: Enum.map(value, &stringify/1)

  defp stringify(value) when is_map(value),
    do: Map.new(value, fn {key, value} -> {to_string(key), stringify(value)} end)

  defp stringify(value), do: value
end