defmodule Tds.Ecto do
  @moduledoc """
  Adapter module for MSSQL.

  It uses `tds` for communicating to the database
  and manages a connection pool with `poolboy`.

  ## Features

    * Full query support (including joins, preloads and associations)
    * Support for transactions
    * Support for data migrations
    * Support for ecto.create and ecto.drop operations
    * Support for transactional tests via `Ecto.Adapters.SQL`

  ## Options

  Mssql options split in different categories described
  below. All options should be given via the repository
  configuration.

  ### Compile time options
  Those options should be set in the config file and require
  recompilation in order to make an effect.
    * `:adapter` - The adapter name, in this case, `Tds.Ecto`
    * `:timeout` - The default timeout to use on queries, defaults to `5000`

  ### Repo options
    * `:filter_null_on_unique_indexes` - Allows unique indexes to filter out null and only match on NOT NULL values

  ### Connection options

    * `:hostname` - Server hostname
    * `:port` - Server port (default: 1433)
    * `:username` - Username
    * `:password` - User password
    * `:parameters` - Keyword list of connection parameters
    * `:ssl` - Set to true if ssl should be used (default: false)
    * `:ssl_opts` - A list of ssl options, see Erlang's `ssl` docs

  ### Pool options

    * `:size` - The number of connections to keep in the pool
    * `:max_overflow` - The maximum overflow of connections (see poolboy docs)
    * `:lazy` - If false all connections will be started immediately on Repo startup (default: true)

  ### Storage options

    * `:encoding` - the database encoding (default: "UTF8")
    * `:template` - the template to create the database from
    * `:lc_collate` - the collation order
    * `:lc_ctype` - the character classification

  """
  require Tds
  use Ecto.Adapters.SQL, :tds
  @behaviour Ecto.Adapter.Storage

  ## Custom MSSQL types

  @doc false
  def autogenerate(:binary_id) do
    Tds.UUID.bingenerate()
  end
  def autogenerate(type), do: super(type)

  @doc false
  def loaders(:map, type), do: [&json_decode/1, type]
  def loaders({:map, _}, type), do: [&json_decode/1, type]
  def loaders(:boolean, type), do: [&bool_decode/1, type]
  def loaders(:binary_id, type), do: [&Tds.UUID.load/1, type]
  def loaders({:embed, _} = type, _),
    do: [&json_decode/1, &Ecto.Adapters.SQL.load_embed(type, &1)]
  def loaders(_, type), do: [type]

  def dumpers({:embed, _} = type, _), do: [&Ecto.Adapters.SQL.dump_embed(type, &1)]
  def dumpers(:binary_id, type), do: [type, &Tds.UUID.dump/1]
  def dumpers(_, type), do: [type]

  defp bool_decode(<<0>>), do: {:ok, false}
  defp bool_decode(<<1>>), do: {:ok, true}
  defp bool_decode(0), do: {:ok, false}
  defp bool_decode(1), do: {:ok, true}
  defp bool_decode(x) when is_boolean(x), do: {:ok, x}

  defp json_decode(x) when is_binary(x), do: {:ok, json_library().decode!(x)}
  defp json_decode(x), do: {:ok, x}

  defp json_library(), do: Application.get_env(:ecto, :json_library)

  # Storage API
  @doc false
  def storage_up(opts) do
    database =
      Keyword.fetch!(opts, :database) || raise ":database is nil in repository configuration"

    command =
      ~s(CREATE DATABASE [#{database}])
      |> concat_if(opts[:lc_collate], &"COLLATE=#{&1}")

    case run_query(Keyword.put(opts, :database, "master"), command) do
      {:ok, _} ->
        :ok

      {:error, %Tds.Error{mssql: %{number: 1801}}} ->
        {:error, :already_up}

      {:error, error} ->
        {:error, Exception.message(error)}
    end
  end

  defp concat_if(content, nil, _fun), do: content
  defp concat_if(content, value, fun), do: content <> " " <> fun.(value)

  @doc false
  def storage_down(opts) do
    database =
      Keyword.fetch!(opts, :database) || raise ":database is nil in repository configuration"

    case run_query(Keyword.put(opts, :database, "master"), "DROP DATABASE [#{database}]") do
      {:ok, _} ->
        :ok

      {:error, %Tds.Error{mssql: %{number: 3701}}} ->
        {:error, :already_down}

      {:error, error} ->
        {:error, Exception.message(error)}
    end
  end

  def select_versions(table, opts) do
    case run_query(opts, ~s(SELECT version FROM [#{table}] ORDER BY version)) do
      {:ok, %{rows: rows}} -> {:ok, Enum.map(rows, &hd/1)}
      {:error, _} = error -> error
    end
  end

  def insert_all(
        repo,
        %{source: {prefix, source}} = schema,
        header,
        rows,
        {_, conflict_params, _} = on_conflict,
        returning,
        opts
      ) do
    # remove any autogenerated fields with explicit identity insert since they can't be
    # inserted as values (DEFAULT, ...) or VALUES (NULL, ...)
    # so below will remove them before unzip them for insert statement
    # this works only for primary keys
    {rows, header} =
      case Map.get(schema, :autogenerate_id) do
        {field, type} when type in [:id, :serial, :bigserial] ->
          h = Enum.reject(header, &(&1 == field))

          r =
            rows
            |> Enum.map(
              &Enum.reject(&1, fn
                {^field, _} -> true
                _ -> false
              end)
            )

          {r, h}

        _ ->
          {rows, header}
      end

    {rows, params} = Ecto.Adapters.SQL.unzip_inserts(header, rows)
    sql = Tds.Ecto.Connection.insert(prefix, source, header, rows, on_conflict, returning)

    %{rows: rows, num_rows: num} =
      Ecto.Adapters.SQL.query!(repo, sql, Enum.reverse(params) ++ conflict_params, opts)

    {num, rows}
  end

  defp run_query(opts, sql_command) do
    {:ok, _} = Application.ensure_all_started(:tds)

    hostname = Keyword.get(opts, :hostname) || System.get_env("MSSQLHOST") || "localhost"
    timeout = Keyword.get(opts, :timeout, 15_000)

    opts =
      opts
      |> Keyword.delete(:name)
      |> Keyword.put(:hostname, hostname)
      |> Keyword.put(:pool, DBConnection.Connection)
      |> Keyword.put(:backoff_type, :stop)

    {:ok, pid} = Task.Supervisor.start_link()

    task =
      Task.Supervisor.async_nolink(pid, fn ->
        {:ok, conn} = Tds.start_link(opts)
        value = Tds.Ecto.Connection.execute(conn, sql_command, [], opts)
        GenServer.stop(conn)
        value
      end)

    case Task.yield(task, timeout) || Task.shutdown(task) do
      {:ok, {:ok, result}} ->
        {:ok, result}

      {:ok, {:error, error}} ->
        {:error, error}

      {:exit, {%{__struct__: struct} = error, _}}
      when struct in [Tds.Error, DBConnection.Error] ->
        {:error, error}

      {:exit, reason} ->
        {:error, RuntimeError.exception(Exception.format_exit(reason))}

      nil ->
        {:error, RuntimeError.exception("command timed out")}
    end
  end

  def supports_ddl_transaction? do
    true
  end
end
