# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only

defmodule Pleroma.Web.ApiSpec.StreamingOperation do
  alias OpenApiSpex.Operation
  alias OpenApiSpex.Response
  alias OpenApiSpex.Schema
  alias Pleroma.Web.ApiSpec.NotificationOperation
  alias Pleroma.Web.ApiSpec.Schemas.Chat
  alias Pleroma.Web.ApiSpec.Schemas.Conversation
  alias Pleroma.Web.ApiSpec.Schemas.FlakeID
  alias Pleroma.Web.ApiSpec.Schemas.Status

  require Pleroma.Constants

  @spec open_api_operation(atom) :: Operation.t()
  def open_api_operation(action) do
    operation = String.to_existing_atom("#{action}_operation")
    apply(__MODULE__, operation, [])
  end

  @spec streaming_operation() :: Operation.t()
  def streaming_operation do
    %Operation{
      tags: ["Timelines"],
      summary: "Establish streaming connection",
      description: """
      Receive statuses in real-time via WebSocket.

      You can specify the access token on the query string or through the `sec-websocket-protocol` header. Using
      the query string to authenticate is considered unsafe and should not be used unless you have to (e.g. to maintain
      your client's compatibility with Mastodon).

      You may specify a stream on the query string. If you do so and you are connecting to a stream that requires logged-in users,
      you must specify the access token at the time of the connection (i.e. via query string or header).

      Otherwise, you have the option to authenticate after you have established the connection through client-sent events.

      The "Request body" section below describes what events clients can send through WebSocket, and the "Responses" section
      describes what events server will send through WebSocket.
      """,
      security: [%{"oAuth" => ["read:statuses", "read:notifications"]}],
      operationId: "WebsocketHandler.streaming",
      parameters:
        [
          Operation.parameter(:connection, :header, %Schema{type: :string}, "connection header",
            required: true
          ),
          Operation.parameter(:upgrade, :header, %Schema{type: :string}, "upgrade header",
            required: true
          ),
          Operation.parameter(
            :"sec-websocket-key",
            :header,
            %Schema{type: :string},
            "sec-websocket-key header",
            required: true
          ),
          Operation.parameter(
            :"sec-websocket-version",
            :header,
            %Schema{type: :string},
            "sec-websocket-version header",
            required: true
          )
        ] ++ stream_params() ++ access_token_params(),
      requestBody: request_body("Client-sent events", client_sent_events()),
      responses: %{
        101 => switching_protocols_response(),
        200 =>
          Operation.response(
            "Server-sent events",
            "application/json",
            server_sent_events()
          )
      }
    }
  end

  defp stream_params do
    stream_specifier()
    |> Enum.map(fn {name, schema} ->
      Operation.parameter(name, :query, schema, get_schema(schema).description)
    end)
  end

  defp access_token_params do
    [
      Operation.parameter(:access_token, :query, token(), token().description),
      Operation.parameter(:"sec-websocket-protocol", :header, token(), token().description)
    ]
  end

  defp switching_protocols_response do
    %Response{
      description: "Switching protocols",
      headers: %{
        "connection" => %OpenApiSpex.Header{required: true},
        "upgrade" => %OpenApiSpex.Header{required: true},
        "sec-websocket-accept" => %OpenApiSpex.Header{required: true}
      }
    }
  end

  defp server_sent_events do
    %Schema{
      oneOf: [
        update_event(),
        status_update_event(),
        notification_event(),
        chat_update_event(),
        follow_relationships_update_event(),
        conversation_event(),
        delete_event(),
        pleroma_respond_event()
      ]
    }
  end

  defp stream do
    %Schema{
      type: :array,
      title: "Stream",
      description: """
      The stream identifier.
      The first item is the name of the stream. If the stream needs a differentiator, the second item will be the corresponding identifier.
      Currently, for the following stream types, there is a second element in the array:

      - `list`: The second element is the id of the list, as a string.
      - `hashtag`: The second element is the name of the hashtag.
      - `public:remote:media` and `public:remote`: The second element is the domain of the corresponding instance.
      """,
      maxItems: 2,
      minItems: 1,
      items: %Schema{type: :string},
      example: ["hashtag", "mew"]
    }
  end

  defp get_schema(%Schema{} = schema), do: schema
  defp get_schema(schema), do: schema.schema

  defp server_sent_event_helper(name, description, type, payload, opts \\ []) do
    payload_type = Keyword.get(opts, :payload_type, :json)
    has_stream = Keyword.get(opts, :has_stream, true)

    stream_properties =
      if has_stream do
        %{stream: stream()}
      else
        %{}
      end

    stream_example = if has_stream, do: %{"stream" => get_schema(stream()).example}, else: %{}

    stream_required = if has_stream, do: [:stream], else: []

    payload_schema =
      if payload_type == :json do
        %Schema{
          title: "Event payload",
          description: "JSON-encoded string of #{get_schema(payload).title}",
          allOf: [payload]
        }
      else
        payload
      end

    payload_example =
      if payload_type == :json do
        get_schema(payload).example |> Jason.encode!()
      else
        get_schema(payload).example
      end

    %Schema{
      type: :object,
      title: name,
      description: description,
      required: [:event, :payload] ++ stream_required,
      properties:
        %{
          event: %Schema{
            title: "Event type",
            description: "Type of the event.",
            type: :string,
            required: true,
            enum: [type]
          },
          payload: payload_schema
        }
        |> Map.merge(stream_properties),
      example:
        %{
          "event" => type,
          "payload" => payload_example
        }
        |> Map.merge(stream_example)
    }
  end

  defp update_event do
    server_sent_event_helper("New status", "A newly-posted status.", "update", Status)
  end

  defp status_update_event do
    server_sent_event_helper("Edit", "A status that was just edited", "status.update", Status)
  end

  defp notification_event do
    server_sent_event_helper(
      "Notification",
      "A new notification.",
      "notification",
      NotificationOperation.notification()
    )
  end

  defp follow_relationships_update_event do
    server_sent_event_helper(
      "Follow relationships update",
      "An update to follow relationships.",
      "pleroma:follow_relationships_update",
      %Schema{
        type: :object,
        title: "Follow relationships update",
        required: [:state, :follower, :following],
        properties: %{
          state: %Schema{
            type: :string,
            description: "Follow state of the relationship.",
            enum: ["follow_pending", "follow_accept", "follow_reject", "unfollow"]
          },
          follower: %Schema{
            type: :object,
            description: "Information about the follower.",
            required: [:id, :follower_count, :following_count],
            properties: %{
              id: FlakeID,
              follower_count: %Schema{type: :integer},
              following_count: %Schema{type: :integer}
            }
          },
          following: %Schema{
            type: :object,
            description: "Information about the following person.",
            required: [:id, :follower_count, :following_count],
            properties: %{
              id: FlakeID,
              follower_count: %Schema{type: :integer},
              following_count: %Schema{type: :integer}
            }
          }
        },
        example: %{
          "state" => "follow_pending",
          "follower" => %{
            "id" => "someUser1",
            "follower_count" => 1,
            "following_count" => 1
          },
          "following" => %{
            "id" => "someUser2",
            "follower_count" => 1,
            "following_count" => 1
          }
        }
      }
    )
  end

  defp chat_update_event do
    server_sent_event_helper(
      "Chat update",
      "A new chat message.",
      "pleroma:chat_update",
      Chat
    )
  end

  defp conversation_event do
    server_sent_event_helper(
      "Conversation update",
      "An update about a conversation",
      "conversation",
      Conversation
    )
  end

  defp delete_event do
    server_sent_event_helper(
      "Delete",
      "A status that was just deleted.",
      "delete",
      %Schema{
        type: :string,
        title: "Status id",
        description: "Id of the deleted status",
        allOf: [FlakeID],
        example: "some-opaque-id"
      },
      payload_type: :string,
      has_stream: false
    )
  end

  defp pleroma_respond_event do
    server_sent_event_helper(
      "Server response",
      "A response to a client-sent event.",
      "pleroma:respond",
      %Schema{
        type: :object,
        title: "Results",
        required: [:result, :type],
        properties: %{
          result: %Schema{
            type: :string,
            title: "Result of the request",
            enum: ["success", "error", "ignored"]
          },
          error: %Schema{
            type: :string,
            title: "Error code",
            description: "An error identifier. Only appears if `result` is `error`."
          },
          type: %Schema{
            type: :string,
            description: "Type of the request."
          }
        },
        example: %{"result" => "success", "type" => "pleroma:authenticate"}
      },
      has_stream: false
    )
  end

  defp client_sent_events do
    %Schema{
      oneOf: [
        subscribe_event(),
        unsubscribe_event(),
        authenticate_event()
      ]
    }
  end

  defp request_body(description, schema, opts \\ []) do
    %OpenApiSpex.RequestBody{
      description: description,
      content: %{
        "application/json" => %OpenApiSpex.MediaType{
          schema: schema,
          example: opts[:example],
          examples: opts[:examples]
        }
      }
    }
  end

  defp client_sent_event_helper(name, description, type, properties, opts) do
    required = opts[:required] || []

    %Schema{
      type: :object,
      title: name,
      required: [:type] ++ required,
      description: description,
      properties:
        %{
          type: %Schema{type: :string, enum: [type], description: "Type of the event."}
        }
        |> Map.merge(properties),
      example: opts[:example]
    }
  end

  defp subscribe_event do
    client_sent_event_helper(
      "Subscribe",
      "Subscribe to a stream.",
      "subscribe",
      stream_specifier(),
      required: [:stream],
      example: %{"type" => "subscribe", "stream" => "list", "list" => "1"}
    )
  end

  defp unsubscribe_event do
    client_sent_event_helper(
      "Unsubscribe",
      "Unsubscribe from a stream.",
      "unsubscribe",
      stream_specifier(),
      required: [:stream],
      example: %{
        "type" => "unsubscribe",
        "stream" => "public:remote:media",
        "instance" => "example.org"
      }
    )
  end

  defp authenticate_event do
    client_sent_event_helper(
      "Authenticate",
      "Authenticate via an access token.",
      "pleroma:authenticate",
      %{
        token: token()
      },
      required: [:token]
    )
  end

  defp token do
    %Schema{
      type: :string,
      description: "An OAuth access token with corresponding permissions.",
      example: "some token"
    }
  end

  defp stream_specifier do
    %{
      stream: %Schema{
        type: :string,
        description: "The name of the stream.",
        enum:
          Pleroma.Constants.public_streams() ++
            [
              "public:remote",
              "public:remote:media",
              "user",
              "user:pleroma_chat",
              "user:notification",
              "direct",
              "list",
              "hashtag"
            ]
      },
      list: %Schema{
        type: :string,
        title: "List id",
        description: "The id of the list. Required when `stream` is `list`.",
        example: "some-id"
      },
      tag: %Schema{
        type: :string,
        title: "Hashtag name",
        description: "The name of the hashtag. Required when `stream` is `hashtag`.",
        example: "mew"
      },
      instance: %Schema{
        type: :string,
        title: "Domain name",
        description:
          "Domain name of the instance. Required when `stream` is `public:remote` or `public:remote:media`.",
        example: "example.org"
      }
    }
  end
end