defmodule Ecto.Adapters.ClickHouse.Migration do
  @moduledoc false
  alias Ecto.Migration.{Table, Reference, Index, Constraint}
  @conn Ecto.Adapters.ClickHouse.Connection

  @dialyzer :no_improper_lists

  defguardp is_create(command) when command in [:create, :create_if_not_exists]
  defguardp is_drop(command) when command in [:drop, :drop_if_exists]

  @spec execute_ddl(Ecto.Adapter.Migration.command()) :: iodata
  def execute_ddl({command, %Table{} = table, columns}) when is_create(command) do
    [
      [
        create(command, table),
        ?\s,
        table(table),
        " (",
        columns(columns),
        pk(table, columns),
        ") ",
        engine(table),
        comment(table)
      ]
    ]
  end

  def execute_ddl({command, %Table{} = table, _mode}) when is_drop(command) do
    [
      [drop(command, table), ?\s, table(table)]
    ]
  end

  def execute_ddl({:alter, %Table{} = table, changes}) do
    Enum.map(changes, fn change ->
      ["ALTER TABLE ", table(table), ?\s | column_change(change)]
    end)
  end

  def execute_ddl({command, %Index{} = index}) when is_create(command) do
    if index.unique do
      raise ArgumentError, "ClickHouse does not support UNIQUE INDEX"
    end

    if index.concurrently do
      raise ArgumentError, "ClickHouse does not support CREATE INDEX CONCURRENTLY"
    end

    [
      [
        "ALTER TABLE ",
        table(index),
        ?\s,
        add(command, index),
        ?\s,
        @conn.quote_name(index.name),
        " (",
        index_expr(index),
        ?) | index_options(index)
      ]
    ]
  end

  def execute_ddl({command, %Index{} = index, _mode}) when is_drop(command) do
    if index.unique do
      raise ArgumentError, "ClickHouse does not support UNIQUE INDEX"
    end

    if index.concurrently do
      raise ArgumentError, "ClickHouse does not support DROP INDEX CONCURRENTLY"
    end

    [
      ["ALTER TABLE ", table(index), ?\s, drop(command, index), ?\s, @conn.quote_name(index.name)]
    ]
  end

  def execute_ddl({command, %Constraint{} = constraint}) when is_create(command) do
    if constraint.comment do
      raise "ClickHouse does not support comments on constraints"
    end

    if constraint.validate do
      raise "ClickHouse does not support CHECK constraints with validation on creation"
    end

    if constraint.exclude do
      raise "ClickHouse does not support exclusion constraints"
    end

    unless constraint.check do
      raise "ClickHouse supports only CHECK constraints"
    end

    [
      [
        "ALTER TABLE ",
        table(constraint),
        ?\s,
        add(command, constraint),
        ?\s,
        @conn.quote_name(constraint.name),
        " CHECK (",
        constraint.check,
        ?)
      ]
    ]
  end

  def execute_ddl({command, %Constraint{} = constraint, _mode}) when is_drop(command) do
    [
      [
        "ALTER TABLE ",
        table(constraint),
        ?\s,
        drop(command, constraint),
        ?\s,
        @conn.quote_name(constraint.name)
      ]
    ]
  end

  def execute_ddl({:rename, %Table{} = current_table, %Table{} = new_table}) do
    cluster = cluster(current_table)
    new_cluster = cluster(new_table)

    unless cluster == new_cluster do
      raise ArgumentError, """
      RENAME TABLE requires CLUSTER to be the same for both tables: current=#{inspect(cluster)}, new=#{inspect(new_cluster)}
      """
    end

    [
      [
        "RENAME TABLE ",
        @conn.quote_table(current_table.prefix, current_table.name),
        " TO " | table(new_table)
      ]
    ]
  end

  def execute_ddl({:rename, %Table{} = table, column_name, new_column_name}) do
    [
      [
        "ALTER TABLE ",
        table(table),
        " RENAME COLUMN ",
        @conn.quote_name(column_name),
        " TO ",
        @conn.quote_name(new_column_name)
      ]
    ]
  end

  def execute_ddl(string) when is_binary(string) do
    [string]
  end

  def execute_ddl(list) when is_list(list) do
    raise ArgumentError, "ClickHouse adapter does not support lists in execute_ddl"
  end

  defp create(:create, %Table{}), do: "CREATE TABLE"
  defp create(:create_if_not_exists, %Table{}), do: "CREATE TABLE IF NOT EXISTS"
  defp drop(:drop, %Table{}), do: "DROP TABLE"
  defp drop(:drop_if_exists, %Table{}), do: "DROP TABLE IF EXISTS"
  defp drop(:drop, %Index{}), do: "DROP INDEX"
  defp drop(:drop_if_exists, %Index{}), do: "DROP INDEX IF EXISTS"
  defp drop(:drop, %Constraint{}), do: "DROP CONSTRAINT"
  defp drop(:drop_if_exists, %Constraint{}), do: "DROP CONSTRAINT IF EXISTS"
  defp add(:create, %Index{}), do: "ADD INDEX"
  defp add(:create_if_not_exists, %Index{}), do: "ADD INDEX IF NOT EXISTS"
  defp add(:create, %Constraint{}), do: "ADD CONSTRAINT"
  defp add(:create_if_not_exists, %Constraint{}), do: "ADD CONSTRAINT IF NOT EXISTS"

  def table(%Table{} = table) do
    if cluster = cluster(merge_table_options_with_defaults(table.options)) do
      [@conn.quote_table(table.prefix, table.name), " ON CLUSTER ", @conn.quote_name(cluster)]
    else
      @conn.quote_table(table.prefix, table.name)
    end
  end

  def table(%Index{} = index) do
    if cluster = cluster(merge_table_options_with_defaults(index.options)) do
      [@conn.quote_table(index.prefix, index.table), " ON CLUSTER ", @conn.quote_name(cluster)]
    else
      @conn.quote_table(index.prefix, index.table)
    end
  end

  # TODO ON CLUSTER (can't right now since constraint doesn't have :options)
  def table(%Constraint{} = constraint) do
    @conn.quote_table(constraint.prefix, constraint.table)
  end

  @cluster_options [:cluster, :on_cluster]
  defp cluster(%{options: options}), do: cluster(options)

  defp cluster(options) when is_list(options) do
    clusters =
      Enum.filter(options, fn option ->
        case option do
          {k, _} when k in @cluster_options -> true
          _ -> false
        end
      end)

    case clusters do
      [] ->
        nil

      [{_, v}] ->
        v

      [_ | _] ->
        raise ArgumentError, "multiple cluster options were provided: " <> inspect(clusters)
    end
  end

  defp cluster(_other), do: nil

  defp columns(columns) do
    @conn.intersperse_map(columns, ?,, &column_definition/1)
  end

  defp pk(%Table{} = table, columns) do
    if find_engine(table) in ["TinyLog", "Memory"], do: [], else: pk_definition(columns)
  end

  defp find_engine(%Table{} = table) do
    table.engine || Application.get_env(:ecto_ch, :default_table_engine) || "TinyLog"
  end

  defp default_table_options do
    List.wrap(Application.get_env(:ecto_ch, :default_table_options))
  end

  defp merge_table_options_with_defaults(options) do
    Keyword.merge(default_table_options(), List.wrap(options))
  end

  defp engine(%Table{} = table) do
    ["ENGINE=", find_engine(table) | engine_options(table.options)]
  end

  defp engine_options(options) when is_binary(options), do: [?\s | options]

  defp engine_options(options) when is_list(options) or is_nil(options) do
    merge_table_options_with_defaults(options)
    |> Keyword.drop(@cluster_options)
    |> Enum.map(&option_expr/1)
  end

  defp index_options(%Index{} = index), do: index_options(index.options)
  defp index_options(options) when is_binary(options), do: [?\s | options]

  defp index_options(options) when is_list(options) do
    options |> Keyword.drop(@cluster_options) |> Enum.map(&option_expr/1)
  end

  defp index_options(nil), do: []

  defp option_expr({k, v}) do
    k = to_string(k) |> String.split("_") |> Enum.map(&String.upcase/1) |> Enum.intersperse(?\s)
    [?\s, k, ?\s, to_string(v)]
  end

  defp comment(%Table{} = table) do
    if comment = table.comment do
      [" COMMENT ", @conn.quote_name(comment, ?')]
    else
      []
    end
  end

  defp pk_definition(columns) do
    pk_columns =
      Enum.filter(columns, fn {_, _, _, opts} ->
        case Keyword.get(opts, :primary_key, false) do
          true = t -> t
          false = f -> f
        end
      end)

    case pk_columns do
      [] = empty ->
        empty

      pk_columns ->
        pk_expr =
          pk_columns
          |> Enum.map(fn {_, name, type, _} ->
            if type in [:serial, :bigserial] do
              raise ArgumentError,
                    "type #{inspect(type)} is not supported as ClickHouse does not support AUTOINCREMENT"
            end

            @conn.quote_name(name)
          end)
          |> Enum.intersperse(?,)

        [",PRIMARY KEY (", pk_expr, ?)]
    end
  end

  defp column_definition({:add, _name, %Reference{}, _opts}) do
    raise ArgumentError, "ClickHouse does not support FOREIGN KEY"
  end

  defp column_definition({:add, name, type, opts}) do
    [@conn.quote_name(name), ?\s, column_type(type) | column_options(type, opts)]
  end

  # TODO collate support?
  defp column_options(type, opts) do
    default = Keyword.fetch(opts, :default)
    null = Keyword.get(opts, :null)
    [default_expr(default, type), null_expr(null)]
  end

  defp column_change({:add, _name, %Reference{}, _opts}) do
    raise ArgumentError, "ClickHouse does not support FOREIGN KEY"
  end

  defp column_change({:add, name, type, opts}) do
    [
      "ADD COLUMN ",
      @conn.quote_name(name),
      ?\s,
      column_type(type)
      | column_options(type, opts)
    ]
  end

  defp column_change({:modify, _name, %Reference{}, _opts}) do
    raise ArgumentError, "ClickHouse does not support FOREIGN KEY"
  end

  defp column_change({:modify, name, type, opts}) do
    [
      "MODIFY COLUMN ",
      @conn.quote_name(name),
      ?\s,
      column_type(type),
      modify_default(name, type, opts)
      | modify_null(name, opts)
    ]
  end

  defp column_change({:remove, name}) do
    ["DROP COLUMN " | @conn.quote_name(name)]
  end

  # TODO
  defp column_change({:remove, name, _type, _opts}) do
    column_change({:remove, name})
  end

  defp modify_null(_name, opts) do
    case Keyword.get(opts, :null) do
      nil -> []
      val -> null_expr(val)
    end
  end

  defp modify_default(name, type, opts) do
    case Keyword.fetch(opts, :default) do
      {:ok, _val} = ok ->
        [" ADD ", default_expr(ok, type), " FOR ", @conn.quote_name(name)]

      :error ->
        []
    end
  end

  defp null_expr(true), do: " NULL"
  defp null_expr(false), do: " NOT NULL"
  defp null_expr(_), do: []

  @dialyzer {:no_improper_lists, default_expr: 2}
  defp default_expr({:ok, nil}, _type) do
    " DEFAULT NULL"
  end

  defp default_expr({:ok, literal}, _type) when is_binary(literal) do
    [" DEFAULT '", @conn.escape_string(literal), ?']
  end

  defp default_expr({:ok, literal}, _type) when is_number(literal) do
    [" DEFAULT " | to_string(literal)]
  end

  defp default_expr({:ok, {:fragment, expr}}, _type) do
    [" DEFAULT " | expr]
  end

  defp default_expr({:ok, true}, _type) do
    " DEFAULT 1"
  end

  defp default_expr({:ok, false}, _type) do
    " DEFAULT 0"
  end

  defp default_expr({:ok, list}, _type) when is_list(list) do
    raise ArgumentError,
          "ClickHouse adapter does not support lists in :default, " <>
            "use fragments instead"
  end

  defp default_expr({:ok, map}, _type) when is_map(map) do
    raise ArgumentError,
          "ClickHouse adapter does not support maps in :default, " <>
            "use fragments instead"
  end

  defp default_expr(:error, _), do: []

  defp index_expr(%Index{} = index), do: @conn.intersperse_map(index.columns, ?,, &index_expr/1)
  defp index_expr(literal) when is_binary(literal), do: literal
  defp index_expr(literal), do: @conn.quote_name(literal)

  defp column_type(type) when type in [:serial, :bigserial] do
    raise ArgumentError,
          "type #{inspect(type)} is not supported as ClickHouse does not support AUTOINCREMENT"
  end

  defp column_type(:id) do
    raise ArgumentError, "type :id is ambiguous, use a literal (e.g. :Int64 or :UInt64) instead"
  end

  defp column_type(:numeric) do
    raise ArgumentError, "type :numeric is not supported"
  end

  defp column_type(:time) do
    raise ArgumentError, "type :time is not supported"
  end

  defp column_type(:map) do
    raise ArgumentError,
          ~s[type :map is ambiguous, use a literal (e.g. :JSON or :"Map(String, UInt8)") instead]
  end

  defp column_type(:decimal) do
    raise ArgumentError,
          ~s[type :decimal is ambiguous, use a literal (e.g. :"Decimal(p, s)") instead]
  end

  defp column_type(:uuid), do: "UUID"
  defp column_type(:boolean), do: "Bool"
  defp column_type(:integer), do: "Int32"
  defp column_type(:bigint), do: "Int64"

  defp column_type(type) when type in [:string, :binary, :binary_id] do
    "String"
  end

  defp column_type(:float), do: "Float64"

  defp column_type({:array, type}) do
    ["Array(", column_type(type), ?)]
  end

  # TODO DateTime('UTC')
  defp column_type(:utc_datetime), do: "DateTime"
  defp column_type(:utc_datetime_usec), do: "DateTime64(6)"
  defp column_type(:naive_datetime), do: "DateTime"
  defp column_type(:naive_datetime_usec), do: "DateTime64(6)"
  defp column_type(type), do: Atom.to_string(type)
end
