defmodule Tenant do
  @moduledoc """
  This is the main module of Tenant.

  The main objective of it is to make a little bit easier to manage tenants
  through postgres db schemas or equivalents, executing queries and commands
  inside and outside the tenant without much boilerplate code.

  ## Using the tenant

  This module has useful functions to manage your tenants, like `create/1`,
  `rename/2` and `drop/1`, but if you're trying to apply the tenant to a
  query, changeset or schema, stay with your application `Repo`, sending the
  prefix. Like this:

      Repo.all(User, prefix: Tenant.to_prefix("my_tenant"))

  It's a good idea to call `Tenant.to_prefix` on your tenant name, although is
  not required. Because, if you configured a `tenant_prefix`, this function will
  return the prefixed one.
  """

  alias Ecto.Adapters.SQL
  alias Ecto.Migrator

  @doc """
  Returns a `%Tenant.Config{}` struct with all the args loaded from the app
  configuration.
  """
  def config, do: struct(Tenant.Config, Application.get_all_env(:tenant))

  @doc """
  Returns the list of reserved tenants.

  By default, there are some limitations for the name of a tenant depending on
  the database, like "public" or anything that start with "pg_".

  You also can configure your own reserved tenant names if you want with:

      config :tenant, reserved_tenants: ["www", "api", ~r/^db\d+$/]

  Notice that you can use regexes, and they will be applied to the tenant
  names.
  """
  def reserved_tenants do
    [
      nil,
      "public",
      "information_schema",
      "performance_schema",
      "sys",
      "mysql",
      ~r/^pg_/
      | config().reserved_tenants
    ]
  end

  @doc """
  Returns if the given `tenant` is reserved or not.

  The function `to_prefix/1` will be applied to the tenant.
  """
  def reserved_tenant?(tenant) when is_map(tenant) do
    tenant
    |> tenant_field()
    |> reserved_tenant?()
  end

  def reserved_tenant?(tenant) do
    do_reserved_tenant?(tenant) or
      tenant
      |> to_prefix()
      |> do_reserved_tenant?()
  end

  defp do_reserved_tenant?(prefix) do
    Enum.any?(reserved_tenants(), fn i ->
      if is_bitstring(prefix) and is_struct(i, Regex) do
        Regex.match?(i, prefix)
      else
        i == prefix
      end
    end)
  end

  @doc """
  Creates the given `tenant` on the given `repo`.

  Returns `{:ok, tenant}` if successful or `{:error, reason}` otherwise.

  Besides creating the database itself, this function also loads their
  structure executing all migrations from inside
  `priv/YOUR_REPO/tenant_migrations` folder. By calling `create_schema/3`
  sending `migrate/2` as the `func` callback.

  See `migrate/2` for more details about the migration running.

  ### Ecto 3 migrations, tenant and transactions

  So on Ecto 3, migrations were changed to run on async tasks. Because of
  that it's not possible anymore to run `Tenant.create/2` inside of a
  transaction anymore.

  But there is a way to achieve the same results using `create_schema/3`
  and `migrate/2`. Here is an example using transaction:

      Repo.transaction(fn ->
        {:ok, _} = Tenant.create("tenant")
        User.insert!(%{name: "Demo user 1"})
        User.insert!(%{name: "Demo user 2"})
      end)

  And here is how you could achieve the same results on success or fail:

      Tenant.create_schema("tenant", Repo, fn(tenant, repo) ->
        Repo.transaction(fn ->
          {:ok, _} = Tenant.migrate(tenant, repo)
          User.insert!(%{name: "Demo user 1"})
          User.insert!(%{name: "Demo user 2"})

          # the `create_schema/3` function must return `{:ok, "tenant"}`
          # if succeeded, and `Repo.transaction` transforms the function results
          # on this tuple
          tenant
        end)
      end)

  So, if the function given to `create_schema/3` returns an error tuple, it will
  rollback the created schema and return that tuple to you. Check out
  `create_schema/3` docs for more details.
  """
  def create(tenant, repo \\ config().repo) do
    create_schema(tenant, repo, &migrate(&1, &2))
  end

  @doc """
  Creates the `tenant` schema/database on the given `repo`.

  Returns `{:ok, tenant}` if successful or `{:error, reason}` otherwise.

  After creating it successfully, the given `func` callback is called with
  the `tenant` and the `repo` as arguments. The `func` must return
  `{:ok, any}` if successful or `{:error, reason}` otherwise. In the case
  the `func` fails, this func will rollback the created schema and
  fail with the same `reason`.

  The function `to_prefix/1` will be applied to the `tenant`.
  """
  def create_schema(tenant, repo \\ config().repo, func \\ nil) do
    if reserved_tenant?(tenant) do
      {:error, reserved_message(tenant)}
    else
      sql =
        case repo.__adapter__() do
          Ecto.Adapters.MyXQL -> "CREATE DATABASE `#{to_prefix(tenant)}`"
          Ecto.Adapters.Postgres -> "CREATE SCHEMA \"#{to_prefix(tenant)}\""
        end

      case SQL.query(repo, sql, []) do
        {:ok, _} ->
          with {:ok, _} <- add_to_tenants_table(tenant, repo),
               {:ok, _} <- exec_func(func, tenant, repo) do
            {:ok, tenant}
          else
            {:error, reason} ->
              drop(tenant, repo)
              {:error, error_message(reason)}
          end

        {:error, reason} ->
          {:error, error_message(reason)}
      end
    end
  end

  defp error_message(msg) do
    if is_exception(msg) do
      Exception.message(msg)
    else
      msg
    end
  end

  defp add_to_tenants_table(tenant, repo) do
    case repo.__adapter__() do
      Ecto.Adapters.MyXQL ->
        if Tenant.config().tenant_table == :"information_schema.schemata" do
          {:ok, :skipped}
        else
          sql = "INSERT INTO #{Tenant.config().tenant_table} (name) VALUES (?)"
          SQL.query(repo, sql, [to_prefix(tenant)])
        end

      Ecto.Adapters.Postgres ->
        {:ok, :skipped}
    end
  end

  defp remove_from_tenants_table(tenant, repo) do
    case repo.__adapter__() do
      Ecto.Adapters.MyXQL ->
        if Tenant.config().tenant_table == :"information_schema.schemata" do
          {:ok, :skipped}
        else
          SQL.query(repo, "DELETE FROM #{Tenant.config().tenant_table} WHERE NAME = ?", [tenant])
        end

      Ecto.Adapters.Postgres ->
        {:ok, :skipped}
    end
  end

  defp exec_func(nil, tenant, _) do
    {:ok, tenant}
  end

  defp exec_func(func, tenant, repo) when is_function(func) do
    case func.(tenant, repo) do
      {:ok, _} -> {:ok, tenant}
      {:error, msg} -> {:error, msg}
    end
  end

  @doc """
  Drops the given tenant on the given `repo`.

  Returns `{:ok, tenant}` if successful or `{:error, reason}` otherwise.

  The function `to_prefix/1` will be applied to the `tenant`.
  """
  def drop(tenant, repo \\ config().repo) do
    if reserved_tenant?(tenant) do
      {:error, reserved_message(tenant)}
    else
      sql =
        case repo.__adapter__() do
          Ecto.Adapters.MyXQL -> "DROP DATABASE `#{to_prefix(tenant)}`"
          Ecto.Adapters.Postgres -> "DROP SCHEMA \"#{to_prefix(tenant)}\" CASCADE"
        end

      with {:ok, _} <- SQL.query(repo, sql, []),
           {:ok, _} <- remove_from_tenants_table(to_prefix(tenant), repo) do
        {:ok, tenant}
      else
        {:error, exception} ->
          {:error, error_message(exception)}
      end
    end
  end

  @doc """
  Renames the `old_tenant` to the `new_tenant` on the given `repo`.

  Returns `{:ok, new_tenant}` if successful or `{:error, reason}` otherwise.

  The function `to_prefix/1` will be applied to the `old_tenant` and
  `new_tenant`.
  """
  def rename(old_tenant, new_tenant, repo \\ config().repo) do
    if reserved_tenant?(new_tenant) do
      {:error, reserved_message(new_tenant)}
    else
      case repo.__adapter__() do
        Ecto.Adapters.MyXQL ->
          {:error, "you cannot rename tenants in a MySQL database."}

        Ecto.Adapters.Postgres ->
          sql = """
          ALTER SCHEMA \"#{to_prefix(old_tenant)}\"
          RENAME TO \"#{to_prefix(new_tenant)}\"
          """

          case SQL.query(repo, sql, []) do
            {:ok, _} ->
              {:ok, new_tenant}

            {:error, message} ->
              {:error, error_message(message)}
          end
      end
    end
  end

  @doc """
  Returns all the tenants on the given `repo`.
  """
  def all(repo \\ config().repo, opts \\ []) do
    sql =
      case repo.__adapter__() do
        Ecto.Adapters.MyXQL ->
          column_name =
            if Tenant.config().tenant_table == :"information_schema.schemata",
              do: "schema_name",
              else: "name"

          "SELECT #{column_name} FROM `#{config().tenant_table}`"

        Ecto.Adapters.Postgres ->
          """
          SELECT schema_name
          FROM information_schema.schemata
          """
      end

    %{rows: result} = SQL.query!(repo, sql, [], opts)
    prefix = config().tenant_prefix

    result
    |> List.flatten()
    |> Enum.filter(fn tenant_name ->
      !reserved_tenant?(tenant_name) and
        (prefix == nil or String.starts_with?(tenant_name, prefix))
    end)
    |> Enum.map(fn tenant_name ->
      case prefix do
        nil -> tenant_name
        prefix -> String.replace_prefix(tenant_name, prefix, "")
      end
    end)
  end

  @doc """
  Returns if the given `tenant` exists or not on the given `repo`.

  The function `to_prefix/1` will be applied to the `tenant`.
  """
  def exists?(tenant, repo \\ config().repo) do
    if reserved_tenant?(tenant) do
      false
    else
      sql =
        case repo.__adapter__() do
          Ecto.Adapters.MyXQL ->
            "SELECT COUNT(*) FROM `#{config().tenant_table}` WHERE name = ?"

          Ecto.Adapters.Postgres ->
            """
            SELECT COUNT(*)
            FROM information_schema.schemata
            WHERE schema_name = $1
            """
        end

      %{rows: [[count]]} = SQL.query!(repo, sql, [to_prefix(tenant)])
      count == 1
    end
  end

  @doc """
  Migrates the given `tenant` on your `repo`.

  Returns `{:ok, migrated_versions}` if successful or `{:error, reason}` otherwise.

  The function `to_prefix/1` will be applied to the `tenant`.
  """
  def migrate(tenant, repo \\ config().repo) do
    Code.compiler_options(ignore_module_conflict: true)

    try do
      migrated_versions =
        Migrator.run(
          repo,
          migrations_path(repo),
          :up,
          all: true,
          prefix: to_prefix(tenant)
        )

      {:ok, migrated_versions}
    rescue
      exception ->
        {:error, error_message(exception)}
    after
      Code.compiler_options(ignore_module_conflict: false)
    end
  end

  @doc """
  Returns the path for the tenant migrations on your `repo`.
  """
  def migrations_path(repo \\ config().repo) do
    repo
    |> Migrator.migrations_path()
    |> Path.join("..")
    |> Path.join(config().migrations_path)
    |> Path.expand()
  end

  @doc """
  Returns the `tenant` name with the given `prefix`.

  If the `prefix` is omitted, the `tenant_prefix` configuration from
  `Tenant.Config` will be used.

  The `tenant` can be a string, a map or a struct. For a string it will
  be used as the tenant name to concat the prefix. For a map or a struct, it
  will get the `tenant_field/0` from it to concat the prefix.
  """
  def to_prefix(tenant, prefix \\ config().tenant_prefix)

  def to_prefix(tenant, prefix) when is_map(tenant) do
    tenant
    |> tenant_field()
    |> to_prefix(prefix)
  end

  def to_prefix(tenant, nil), do: tenant
  def to_prefix(tenant, prefix), do: "#{prefix}#{tenant}"

  @doc """
  Returns the value of the configured tenant field on the given `map`.
  """
  def tenant_field(map) do
    Map.get(map, config().tenant_field)
  end

  defp reserved_message(tenant) do
    """
    You cannot create the schema because #{inspect(tenant)} is a reserved
    tenant
    """
  end
end
