defmodule Mobilizon.Admin do
  @moduledoc """
  The Admin context.
  """

  import Ecto.Query
  import EctoEnum
  import Mobilizon.Web.Gettext, only: [dgettext: 2]

  alias Mobilizon.Actors.Actor
  alias Mobilizon.{Admin, Users}
  alias Mobilizon.Admin.ActionLog
  alias Mobilizon.Admin.{Setting, SettingMedia}
  alias Mobilizon.Medias.Media
  alias Mobilizon.Storage.{Page, Repo}
  alias Mobilizon.Users.User

  defenum(ActionLogAction, [
    "update",
    "create",
    "delete",
    "suspend",
    "unsuspend"
  ])

  alias Ecto.Multi

  @doc """
  Creates a action_log.
  """
  @spec create_action_log(map) :: {:ok, ActionLog.t()} | {:error, Ecto.Changeset.t()}
  def create_action_log(attrs \\ %{}) do
    %ActionLog{}
    |> ActionLog.changeset(attrs)
    |> Repo.insert()
  end

  @doc """
  Returns the list of action logs.
  """
  @spec list_action_logs(integer | nil, integer | nil) :: Page.t(ActionLog.t())
  def list_action_logs(page \\ nil, limit \\ nil) do
    list_action_logs_query()
    |> Page.build_page(page, limit)
  end

  @doc """
  Log an admin action
  """
  @spec log_action(Actor.t(), String.t(), struct()) ::
          {:ok, ActionLog.t()} | {:error, Ecto.Changeset.t() | :user_not_moderator}
  def log_action(%Actor{user_id: user_id, id: actor_id}, action, target) do
    %User{role: role} = Users.get_user!(user_id)

    if role in [:administrator, :moderator] do
      Admin.create_action_log(%{
        "actor_id" => actor_id,
        "target_type" => to_string(target.__struct__),
        "target_id" => target.id,
        "action" => action,
        "changes" => stringify_struct(target)
      })
    else
      {:error, :user_not_moderator}
    end
  end

  @spec list_action_logs_query :: Ecto.Query.t()
  defp list_action_logs_query do
    from(r in ActionLog, preload: [:actor], order_by: [desc: :id])
  end

  defp stringify_struct(%_{} = struct) do
    association_fields = struct.__struct__.__schema__(:associations)

    struct
    |> Map.from_struct()
    |> Map.drop(association_fields ++ [:__meta__])
  end

  defp stringify_struct(struct), do: struct

  @spec get_all_admin_settings :: map()
  def get_all_admin_settings do
    medias =
      SettingMedia
      |> Repo.all()
      |> Repo.preload(:media)
      |> Enum.map(fn %SettingMedia{group: group, name: name, media: media} ->
        {group, name, media}
      end)

    values =
      Setting
      |> Repo.all()
      |> Enum.map(fn %Setting{group: group, name: name, value: value} ->
        {group, name, get_setting_value(value)}
      end)

    all_settings = Enum.concat(values, medias)

    Enum.reduce(
      all_settings,
      %{},
      # For each {group,name,value}
      fn {group, name, value}, acc ->
        # We update the %{group: map} in the accumulator
        {_, new_acc} =
          Map.get_and_update(
            acc,
            group,
            # We put the %{name: value} into the %{group: map}
            fn group_map ->
              {
                group_map,
                Map.put(group_map || %{}, name, value)
              }
            end
          )

        new_acc
      end
    )
  end

  @spec get_admin_setting_value(String.t(), String.t(), String.t() | nil) ::
          String.t() | boolean() | nil | map() | list()
  def get_admin_setting_value(group, name, fallback \\ nil)
      when is_binary(group) and is_binary(name) do
    case Repo.get_by(Setting, group: group, name: name) do
      nil ->
        fallback

      %Setting{value: ""} ->
        fallback

      %Setting{value: nil} ->
        fallback

      %Setting{value: value} ->
        get_setting_value(value)
    end
  end

  @spec get_setting_value(String.t() | nil) :: map() | list() | nil | boolean() | String.t()
  def get_setting_value(nil), do: nil

  def get_setting_value(value) do
    case Jason.decode(value) do
      {:ok, val} ->
        val

      {:error, _} ->
        case value do
          "true" -> true
          "false" -> false
          value -> value
        end
    end
  end

  @spec get_admin_setting_media(String.t(), String.t(), String.t() | nil) ::
          {:ok, Media.t()} | {:error, :not_found} | nil
  def get_admin_setting_media(group, name, fallback \\ nil)
      when is_binary(group) and is_binary(name) do
    case SettingMedia
         |> where(group: ^group)
         |> where(name: ^name)
         |> preload(:media)
         |> Repo.one() do
      nil ->
        fallback

      %SettingMedia{media: media} ->
        media

      %SettingMedia{} ->
        fallback
    end
  end

  @spec save_settings(String.t(), map()) :: {:ok, any} | {:error, any}
  def save_settings(group, args) do
    {medias, values} = Map.split(args, [:instance_logo, :instance_favicon, :default_picture])

    multi =
      Multi.new()
      |> do_save_media_setting(group, medias)
      |> do_save_value_setting(group, values)
      |> Repo.transaction()

    case multi do
      {:ok, result} ->
        {:ok, result}

      {:error, _err, %Ecto.Changeset{} = err, _} ->
        {:error, err}
    end
  end

  @spec do_save_value_setting(Ecto.Multi.t(), String.t(), map()) :: Ecto.Multi.t()
  defp do_save_value_setting(transaction, _group, args) when args == %{}, do: transaction

  defp do_save_value_setting(transaction, group, args) do
    key = hd(Map.keys(args))
    {val, rest} = Map.pop(args, key)

    changeset =
      %Setting{}
      |> Setting.changeset(%{
        group: group,
        name: Atom.to_string(key),
        value: convert_to_string(val)
      })
      |> maybe_validate_external_links(key, val)

    transaction =
      Multi.insert(
        transaction,
        key,
        changeset,
        on_conflict: :replace_all,
        conflict_target: [:group, :name]
      )

    do_save_value_setting(transaction, group, rest)
  end

  @spec do_save_media_setting(Ecto.Multi.t(), String.t(), map()) :: Ecto.Multi.t()
  defp do_save_media_setting(transaction, _group, args) when args == %{}, do: transaction

  defp do_save_media_setting(transaction, group, args) do
    key = hd(Map.keys(args))
    {val, rest} = Map.pop(args, key)

    transaction =
      case val do
        val ->
          Multi.insert(
            transaction,
            key,
            SettingMedia.changeset(%SettingMedia{}, %{
              group: group,
              name: Atom.to_string(key),
              media: val
            }),
            on_conflict: :replace_all,
            conflict_target: [:group, :name]
          )
      end

    do_save_media_setting(transaction, group, rest)
  end

  def clear_settings(group) do
    Multi.new()
    |> Multi.delete_all(:settings, Setting |> where([s], s.group == ^group))
    |> Multi.delete_all(:settings_medias, SettingMedia |> where([s], s.group == ^group))
    |> Repo.transaction()
  end

  @spec convert_to_string(any()) :: String.t()
  defp convert_to_string(val) do
    case val do
      val when is_list(val) -> Jason.encode!(val)
      val -> to_string(val)
    end
  end

  defp maybe_validate_external_links(changeset, :external_links, external_links) do
    external_links
    |> List.wrap()
    |> Enum.reduce(changeset, fn link, cs ->
      case validate_external_link(link) do
        {:ok, _} -> cs
        {:error, msg} -> Ecto.Changeset.add_error(cs, :external_links, msg)
      end
    end)
  end

  defp maybe_validate_external_links(changeset, _key, _val), do: changeset

  defp validate_external_link(%{:url => url, :label => label} = link) do
    uri = URI.parse(url)

    cond do
      is_nil(label) or String.trim(label) == "" ->
        {:error, dgettext("errors", "External link label cannot be blank")}

      String.length(label) < 2 ->
        {:error, dgettext("errors", "External link label must be at least 2 characters")}

      String.length(label) > 256 ->
        {:error, dgettext("errors", "External link label must be at most 256 characters")}

      not (uri.scheme in ["http", "https"] and is_binary(uri.host) and uri.host != "") ->
        {:error, dgettext("errors", "External link URL must be a valid http/https URL")}

      true ->
        {:ok, link}
    end
  end
end