defmodule Mobilizon.Federation.ActivityStream.Converter.Event do
  @moduledoc """
  Event converter.

  This module allows to convert events from ActivityStream format to our own
  internal one, and back.
  """

  alias Cldr.DateTime.Formatter

  alias Mobilizon.Actors.Actor
  alias Mobilizon.Addresses.Address
  alias Mobilizon.Events.Categories
  alias Mobilizon.Events.Event, as: EventModel
  alias Mobilizon.Events.EventOptions
  alias Mobilizon.Medias.Media

  alias Mobilizon.Federation.ActivityStream.{Converter, Convertible}
  alias Mobilizon.Federation.ActivityStream.Converter.Address, as: AddressConverter
  alias Mobilizon.Federation.ActivityStream.Converter.EventMetadata, as: EventMetadataConverter
  alias Mobilizon.Federation.ActivityStream.Converter.Media, as: MediaConverter
  alias Mobilizon.Service.TimezoneDetector
  alias Mobilizon.Web.Endpoint

  import Mobilizon.Federation.ActivityPub.Utils, only: [get_url: 1]

  import Mobilizon.Federation.ActivityStream.Converter.Utils,
    only: [
      fetch_tags: 1,
      fetch_mentions: 1,
      build_tags: 1,
      maybe_fetch_actor_and_attributed_to_id: 1,
      process_pictures: 2,
      get_address: 1,
      fetch_actor: 1,
      visibility_public?: 1
    ]

  import Mobilizon.Service.Metadata.Utils,
    only: [
      datetime_to_string: 3,
      render_address!: 1
    ]

  require Logger

  @behaviour Converter

  defimpl Convertible, for: EventModel do
    alias Mobilizon.Federation.ActivityStream.Converter.Event, as: EventConverter

    defdelegate model_to_as(event), to: EventConverter
  end

  @online_address_name "Website"
  @banner_picture_name "Banner"
  @ap_public "https://www.w3.org/ns/activitystreams#Public"

  @doc """
  Converts an AP object data to our internal data structure.
  """
  @impl Converter
  @spec as_to_model_data(map) :: map() | {:error, atom()}
  def as_to_model_data(object) do
    case maybe_fetch_actor_and_attributed_to_id(object) do
      {:ok, %Actor{id: actor_id}, attributed_to} ->
        address = get_address(object["location"])
        tags = fetch_tags(object["tag"])
        mentions = fetch_mentions(object["tag"])
        visibility = get_visibility(object)
        options = get_options(object, address)
        metadata = get_metdata(object)
        contacts = get_contacts(object)

        [description: description, picture_id: picture_id, medias: medias] =
          process_pictures(object, actor_id)

        %{
          title: object["name"],
          description: description,
          organizer_actor_id: actor_id,
          attributed_to_id: if(is_nil(attributed_to), do: nil, else: attributed_to.id),
          picture_id: picture_id,
          medias: medias,
          begins_on: object["startTime"],
          ends_on: object["endTime"],
          category: Categories.get_category(object["category"]),
          visibility: visibility,
          join_options: Map.get(object, "joinMode", "free"),
          local: local?(object["id"]),
          external_participation_url: object["externalParticipationUrl"],
          options: options,
          metadata: metadata,
          # Remove fallback in MBZ 5.x
          status:
            object
            |> Map.get("status", Map.get(object, "ical:status", "CONFIRMED"))
            |> String.downcase(),
          online_address: object |> Map.get("attachment", []) |> get_online_address(),
          phone_address: object["phoneAddress"],
          draft: object["draft"] == true,
          url: object["id"],
          uuid: object["uuid"],
          tags: tags,
          mentions: mentions,
          physical_address_id: if(address, do: address.id, else: nil),
          updated_at: object["updated"],
          publish_at: object["published"],
          language: object["inLanguage"],
          contacts: contacts
        }

      {:error, err} ->
        {:error, err}
    end
  end

  @doc """
  Convert an event struct to an ActivityStream representation.
  """
  @impl Converter
  @spec model_to_as(EventModel.t()) :: map
  def model_to_as(%EventModel{} = event) do
    {to, cc} =
      if event.visibility == :public,
        do: {[@ap_public], [event.organizer_actor.followers_url]},
        else: {[attributed_to_or_default(event).followers_url], [@ap_public]}

    participant_count = Mobilizon.Events.count_participant_participants(event.id)

    %{
      "type" => "Event",
      "to" => to,
      "cc" => cc,
      "attributedTo" => attributed_to_or_default(event).url,
      "name" => event.title,
      "actor" =>
        if(Ecto.assoc_loaded?(event.organizer_actor), do: event.organizer_actor.url, else: nil),
      "uuid" => event.uuid,
      "category" => event.category,
      "content" => event.description,
      "published" => (event.publish_at || event.inserted_at) |> date_to_string(),
      "updated" => event.updated_at |> date_to_string(),
      "mediaType" => "text/html",
      "startTime" => event.begins_on |> shift_tz(event.options.timezone) |> date_to_string(),
      "joinMode" => to_string(event.join_options),
      "externalParticipationUrl" => event.external_participation_url,
      "endTime" => event.ends_on |> shift_tz(event.options.timezone) |> date_to_string(),
      "tag" => event.tags |> build_tags(),
      "maximumAttendeeCapacity" => event.options.maximum_attendee_capacity,
      "remainingAttendeeCapacity" =>
        remaining_attendee_capacity(event.options, participant_count),
      "participantCount" => participant_count,
      "repliesModerationOption" => event.options.comment_moderation,
      "commentsEnabled" => event.options.comment_moderation == :allow_all,
      "anonymousParticipationEnabled" => event.options.anonymous_participation,
      "attachment" => Enum.map(event.metadata, &EventMetadataConverter.metadata_to_as/1),
      "draft" => event.draft,
      # TODO: Remove me in MBZ 5.x
      "ical:status" => event.status |> to_string |> String.upcase(),
      "status" => event.status |> to_string |> String.upcase(),
      "id" => event.url,
      "url" => event.url,
      "inLanguage" => event.language,
      "timezone" => event.options.timezone,
      "contacts" => Enum.map(event.contacts, & &1.url),
      "isOnline" => event.options.is_online,
      "summary" => event_summary(event)
    }
    |> maybe_add_physical_address(event)
    |> maybe_add_event_picture(event)
    |> maybe_add_online_address(event)
    |> maybe_add_inline_media(event)
  end

  @spec attributed_to_or_default(EventModel.t()) :: Actor.t()
  defp attributed_to_or_default(%EventModel{} = event) do
    if(is_nil(event.attributed_to) or not Ecto.assoc_loaded?(event.attributed_to),
      do: nil,
      else: event.attributed_to
    ) ||
      event.organizer_actor
  end

  # Get only elements that we have in EventOptions
  @spec get_options(map, Address.t() | nil) :: map
  defp get_options(object, address) do
    %{
      maximum_attendee_capacity: object["maximumAttendeeCapacity"],
      anonymous_participation: object["anonymousParticipationEnabled"],
      comment_moderation:
        Map.get(
          object,
          "repliesModerationOption",
          if(Map.get(object, "commentsEnabled", true), do: :allow_all, else: :closed)
        ),
      timezone: calculate_timezone(object, address),
      is_online: object["isOnline"] == true
    }
  end

  defp calculate_timezone(%{"timezone" => timezone}, %Address{geom: geom}) do
    TimezoneDetector.detect(
      timezone,
      geom,
      "Etc/UTC"
    )
  end

  defp calculate_timezone(_object, nil), do: nil

  defp calculate_timezone(_object, %Address{geom: nil}), do: nil

  defp calculate_timezone(_object, %Address{geom: geom}) do
    TimezoneDetector.detect(
      nil,
      geom,
      "Etc/UTC"
    )
  end

  defp get_metdata(%{"attachment" => attachments}) do
    attachments
    |> Enum.filter(&(&1["type"] == "PropertyValue"))
    |> Enum.map(&EventMetadataConverter.as_to_metadata/1)
  end

  defp get_metdata(_), do: []

  defp get_visibility(object),
    do: if(visibility_public?(object["to"]), do: :public, else: :unlisted)

  @spec date_to_string(DateTime.t() | nil) :: String.t()
  defp date_to_string(nil), do: nil
  defp date_to_string(%DateTime{} = date), do: DateTime.to_iso8601(date)

  @spec shift_tz(DateTime.t(), String.t() | nil) :: DateTime.t()
  defp shift_tz(%DateTime{} = date, timezone) when is_binary(timezone) do
    DateTime.shift_zone!(date, timezone)
  end

  defp shift_tz(datetime, _tz), do: datetime

  defp get_online_address(attachments) do
    Enum.find_value(attachments, fn attachment ->
      case attachment do
        %{
          "type" => "Link",
          "href" => url,
          "mediaType" => "text/html",
          "name" => @online_address_name
        } ->
          url

        _ ->
          nil
      end
    end)
  end

  @spec maybe_add_physical_address(map(), EventModel.t()) :: map()
  defp maybe_add_physical_address(res, %EventModel{
         physical_address: %Address{} = physical_address
       }) do
    Map.put(res, "location", AddressConverter.model_to_as(physical_address))
  end

  defp maybe_add_physical_address(res, %EventModel{physical_address: _}), do: res

  @spec maybe_add_event_picture(map(), EventModel.t()) :: map()
  defp maybe_add_event_picture(res, %EventModel{picture: %Media{} = picture}) do
    Map.update(
      res,
      "attachment",
      [],
      &(&1 ++
          [
            picture
            |> MediaConverter.model_to_as()
            |> Map.put("name", @banner_picture_name)
          ])
    )
  end

  defp maybe_add_event_picture(res, %EventModel{picture: _}), do: res

  @spec maybe_add_online_address(map(), EventModel.t()) :: map()
  defp maybe_add_online_address(res, %EventModel{online_address: online_address})
       when is_binary(online_address) do
    Map.update(
      res,
      "attachment",
      [],
      &(&1 ++
          [
            %{
              "type" => "Link",
              "href" => online_address,
              "mediaType" => "text/html",
              "name" => @online_address_name
            }
          ])
    )
  end

  defp maybe_add_online_address(res, %EventModel{online_address: _}), do: res

  @spec maybe_add_inline_media(map(), EventModel.t()) :: map()
  defp maybe_add_inline_media(res, %EventModel{media: media}) do
    medias = Enum.map(media, &MediaConverter.model_to_as/1)

    Map.update(
      res,
      "attachment",
      [],
      &(&1 ++ medias)
    )
  end

  @spec local?(String.t()) :: boolean()
  defp local?(url) do
    %URI{host: url_domain} = URI.parse(url)
    %URI{host: local_domain} = URI.parse(Endpoint.url())
    url_domain == local_domain
  end

  @spec get_contacts(map()) :: list(Actor.t())
  defp get_contacts(object) do
    object
    |> Map.get("contacts", [])
    |> Enum.map(&get_contact/1)
    |> Enum.filter(&match?({:ok, _}, &1))
    |> Enum.map(fn {:ok, contact} -> contact end)
  end

  defp get_contact(contact) do
    contact
    |> get_url()
    |> fetch_actor()
  end

  @spec remaining_attendee_capacity(map(), integer()) :: integer() | nil
  defp remaining_attendee_capacity(
         %{maximum_attendee_capacity: maximum_attendee_capacity},
         participant_count
       )
       when is_integer(maximum_attendee_capacity) and maximum_attendee_capacity > 0 do
    maximum_attendee_capacity - participant_count
  end

  defp remaining_attendee_capacity(
         %{maximum_attendee_capacity: _},
         _participant_count
       ),
       do: nil

  def event_summary(%EventModel{
        begins_on: begins_on,
        physical_address: address,
        options: %EventOptions{timezone: timezone},
        language: language
      }) do
    begins_on = build_begins_on(begins_on, timezone)

    begins_on
    |> datetime_to_string(language || "en", :long)
    |> (&[&1]).()
    |> add_timezone(begins_on)
    |> maybe_build_address(address)
    |> Enum.join(" - ")
  end

  @spec build_begins_on(DateTime.t(), String.t() | nil) :: DateTime.t()
  defp build_begins_on(begins_on, nil), do: begins_on

  defp build_begins_on(begins_on, timezone) do
    case DateTime.shift_zone(begins_on, timezone) do
      {:ok, begins_on} -> begins_on
      {:error, _err} -> begins_on
    end
  end

  defp add_timezone(elements, %DateTime{} = begins_on) do
    elements ++ [Formatter.zone_gmt(begins_on)]
  end

  @spec maybe_build_address(list(String.t()), Address.t() | nil) :: list(String.t())
  defp maybe_build_address(elements, %Address{} = address) do
    elements ++ [render_address!(address)]
  rescue
    # If the address is not renderable
    e in ArgumentError ->
      require Logger
      Logger.error(Exception.format(:error, e, __STACKTRACE__))
      elements
  end

  defp maybe_build_address(elements, _address), do: elements
end