# SPDX-FileCopyrightText: 2020 Zach Daniel
#
# SPDX-License-Identifier: MIT

defmodule AshPostgres.MigrationGenerator.Operation do
  @moduledoc false

  defmodule Helper do
    @moduledoc false
    def join(list),
      do:
        list
        |> List.flatten()
        |> Enum.reject(&is_nil/1)
        |> Enum.join(", ")
        |> String.replace(", )", ")")

    def maybe_add_default("nil"), do: nil
    def maybe_add_default(value), do: "default: #{value}"

    def maybe_add_primary_key(true), do: "primary_key: true"
    def maybe_add_primary_key(_), do: nil

    def maybe_add_null(false), do: "null: false"
    def maybe_add_null(_), do: nil

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

    def in_quotes(nil), do: nil
    def in_quotes(value), do: "\"#{value}\""

    def as_atom(value) when is_atom(value), do: Macro.inspect_atom(:remote_call, value)
    # sobelow_skip ["DOS.StringToAtom"]
    def as_atom(value), do: Macro.inspect_atom(:remote_call, String.to_atom(value))

    def option(key, value) when key in [:nulls_distinct, "nulls_distinct"] do
      if !value do
        "#{as_atom(key)}: #{inspect(value)}"
      end
    end

    def option(key, value) do
      if value do
        "#{as_atom(key)}: #{inspect(value)}"
      end
    end

    def on_delete(%{on_delete: {:nilify, columns}}) when is_list(columns) do
      "on_delete: {:nilify, #{inspect(columns)}}"
    end

    def on_delete(%{on_delete: on_delete}) when on_delete in [:delete, :nilify] do
      "on_delete: :#{on_delete}_all"
    end

    def on_delete(%{on_delete: on_delete}) when is_atom(on_delete) and not is_nil(on_delete) do
      "on_delete: :#{on_delete}"
    end

    def on_delete(_), do: nil

    def on_update(%{on_update: on_update}) when on_update in [:update, :nilify] do
      "on_update: :#{on_update}_all"
    end

    def on_update(%{on_update: on_update}) when is_atom(on_update) and not is_nil(on_update) do
      "on_update: :#{on_update}"
    end

    def on_update(_), do: nil

    def reference_type(
          %{type: :integer},
          %{destination_attribute_generated: true, destination_attribute_default: "nil"}
        ) do
      :bigint
    end

    def reference_type(%{type: :integer, default: "nil", generated?: true}, _) do
      ":bigserial"
    end

    def reference_type(%{type: type}, _) do
      type
    end

    def with_match(reference, source_attribute \\ nil)

    def with_match(
          %{
            primary_key?: false,
            destination_attribute: reference_attribute,
            multitenancy: %{strategy: :attribute, attribute: destination_attribute}
          } = reference,
          source_attribute
        )
        when not is_nil(source_attribute) and reference_attribute != destination_attribute do
      with_targets =
        [{as_atom(source_attribute), as_atom(destination_attribute)}]
        |> Enum.into(reference.match_with || %{})
        |> with_targets()

      # We can only have match: :full here, this gets validated by a Transformer
      join([with_targets, "match: :full"])
    end

    def with_match(reference, _) do
      with_targets = with_targets(reference.match_with)
      match_type = match_type(reference.match_type)

      if with_targets != nil or match_type != nil do
        join([with_targets, match_type])
      else
        nil
      end
    end

    def with_targets(targets) when is_map(targets) do
      targets_string =
        targets
        |> Enum.map_join(", ", fn {source, destination} -> "#{source}: :#{destination}" end)

      "with: [#{targets_string}]"
    end

    def with_targets(_), do: nil

    def match_type(type) when type in [:simple, :partial, :full] do
      "match: :#{type}"
    end

    def match_type(_), do: nil

    def index_keys(keys, all_tenants?, multitenancy) do
      if multitenancy.strategy == :attribute and not all_tenants? do
        Enum.uniq([multitenancy.attribute | keys])
      else
        keys
      end
    end

    def maybe_add_precision(nil), do: nil
    def maybe_add_precision(precision), do: "precision: #{precision}"

    def maybe_add_scale(nil), do: nil
    def maybe_add_scale(scale), do: "scale: #{scale}"
  end

  defmodule CreateTable do
    @moduledoc false
    defstruct [:table, :schema, :multitenancy, :old_multitenancy, :repo]
  end

  defmodule AddAttribute do
    @moduledoc false
    defstruct [:attribute, :table, :schema, :multitenancy, :old_multitenancy]

    import Helper

    def up(%{
          multitenancy: %{strategy: :attribute, attribute: source_attribute},
          attribute:
            %{
              references:
                %{
                  table: table,
                  destination_attribute: reference_attribute,
                  schema: destination_schema,
                  multitenancy: %{strategy: :attribute}
                } = reference
            } = attribute
        }) do
      with_match = with_match(reference, source_attribute)

      size =
        if attribute[:size] do
          "size: #{attribute[:size]}"
        end

      [
        "add #{inspect(attribute.source)}",
        "references(:#{as_atom(table)}",
        [
          "column: #{inspect(reference_attribute)}",
          with_match,
          "name: #{inspect(reference.name)}",
          "type: #{inspect(reference_type(attribute, reference))}",
          option("prefix", destination_schema),
          on_delete(reference),
          on_update(reference),
          size,
          maybe_add_precision(attribute[:precision]),
          maybe_add_scale(attribute[:scale])
        ],
        ")",
        maybe_add_default(attribute.default),
        maybe_add_primary_key(attribute.primary_key?),
        maybe_add_null(attribute.allow_nil?)
      ]
      |> join()
    end

    def up(%{
          multitenancy: %{strategy: :context},
          attribute:
            %{
              references:
                %{
                  table: table,
                  destination_attribute: destination_attribute,
                  schema: destination_schema,
                  multitenancy: %{strategy: :attribute}
                } = reference
            } = attribute
        }) do
      with_match = with_match(reference)

      size =
        if attribute[:size] do
          "size: #{attribute[:size]}"
        end

      [
        "add #{inspect(attribute.source)}",
        "references(:#{as_atom(table)}",
        [
          "column: #{inspect(destination_attribute)}",
          with_match,
          option("prefix", destination_schema),
          "name: #{inspect(reference.name)}",
          "type: #{inspect(reference_type(attribute, reference))}",
          size,
          maybe_add_precision(attribute[:precision]),
          maybe_add_scale(attribute[:scale]),
          on_delete(reference),
          on_update(reference)
        ],
        ")",
        maybe_add_default(attribute.default),
        maybe_add_primary_key(attribute.primary_key?),
        maybe_add_null(attribute.allow_nil?)
      ]
      |> join()
    end

    def up(%{
          multitenancy: %{strategy: :attribute},
          attribute:
            %{
              references: %{
                multitenancy: %{strategy: :context}
              }
            } = attribute
        }) do
      size =
        if attribute.size do
          "size: #{attribute.size}"
        end

      [
        "add #{inspect(attribute.source)}",
        inspect(attribute.type),
        maybe_add_default(attribute.default),
        maybe_add_primary_key(attribute.primary_key?),
        size,
        maybe_add_precision(attribute[:precision]),
        maybe_add_scale(attribute[:scale]),
        maybe_add_null(attribute.allow_nil?)
      ]
      |> join()
    end

    def up(%{
          multitenancy: %{strategy: nil},
          attribute:
            %{
              references: %{
                multitenancy: %{strategy: :context}
              }
            } = attribute
        }) do
      size =
        if attribute.size do
          "size: #{attribute.size}"
        end

      [
        "add #{inspect(attribute.source)}",
        inspect(attribute.type),
        maybe_add_default(attribute.default),
        maybe_add_primary_key(attribute.primary_key?),
        size,
        maybe_add_precision(attribute[:precision]),
        maybe_add_scale(attribute[:scale]),
        maybe_add_null(attribute.allow_nil?)
      ]
      |> join()
    end

    def up(%{
          multitenancy: %{strategy: :context},
          attribute:
            %{
              references:
                %{
                  multitenancy: %{strategy: :context},
                  table: table,
                  destination_attribute: destination_attribute
                } = reference
            } = attribute
        }) do
      with_match = with_match(reference)

      size =
        if attribute[:size] do
          "size: #{attribute[:size]}"
        end

      [
        "add #{inspect(attribute.source)}",
        "references(:#{as_atom(table)}",
        [
          "column: #{inspect(destination_attribute)}",
          with_match,
          "name: #{inspect(reference.name)}",
          "type: #{inspect(reference_type(attribute, reference))}",
          "prefix: prefix()",
          size,
          maybe_add_precision(attribute[:precision]),
          maybe_add_scale(attribute[:scale]),
          on_delete(reference),
          on_update(reference)
        ],
        ")",
        maybe_add_default(attribute.default),
        maybe_add_primary_key(attribute.primary_key?),
        maybe_add_null(attribute.allow_nil?)
      ]
      |> join()
    end

    def up(%{
          multitenancy: %{strategy: :context},
          schema: schema,
          attribute:
            %{
              references:
                %{
                  table: table,
                  schema: destination_schema,
                  destination_attribute: destination_attribute
                } = reference
            } = attribute
        }) do
      with_match = with_match(reference)

      size =
        if attribute[:size] do
          "size: #{attribute[:size]}"
        end

      destination_schema =
        if schema != destination_schema do
          destination_schema
        end

      [
        "add #{inspect(attribute.source)}",
        "references(:#{as_atom(table)}",
        [
          "column: #{inspect(destination_attribute)}",
          with_match,
          "name: #{inspect(reference.name)}",
          "type: #{inspect(reference_type(attribute, reference))}",
          option("prefix", destination_schema),
          size,
          maybe_add_precision(attribute[:precision]),
          maybe_add_scale(attribute[:scale]),
          on_delete(reference),
          on_update(reference)
        ],
        ")",
        maybe_add_default(attribute.default),
        maybe_add_primary_key(attribute.primary_key?),
        maybe_add_null(attribute.allow_nil?)
      ]
      |> join()
    end

    def up(%{
          attribute:
            %{
              references:
                %{
                  table: table,
                  schema: destination_schema,
                  destination_attribute: destination_attribute
                } = reference
            } = attribute
        }) do
      with_match = with_match(reference)

      size =
        if attribute[:size] do
          "size: #{attribute[:size]}"
        end

      [
        "add #{inspect(attribute.source)}",
        "references(:#{as_atom(table)}",
        [
          "column: #{inspect(destination_attribute)}",
          with_match,
          "name: #{inspect(reference.name)}",
          "type: #{inspect(reference_type(attribute, reference))}",
          option("prefix", destination_schema),
          size,
          maybe_add_precision(attribute[:precision]),
          maybe_add_scale(attribute[:scale]),
          on_delete(reference),
          on_update(reference)
        ],
        ")",
        maybe_add_default(attribute.default),
        maybe_add_primary_key(attribute.primary_key?),
        maybe_add_null(attribute.allow_nil?)
      ]
      |> join()
    end

    def up(%{attribute: %{type: :bigint, default: "nil", generated?: true} = attribute}) do
      [
        "add #{inspect(attribute.source)}",
        ":bigserial",
        maybe_add_null(attribute.allow_nil?),
        maybe_add_primary_key(attribute.primary_key?)
      ]
      |> join()
    end

    def up(%{attribute: %{type: :integer, default: "nil", generated?: true} = attribute}) do
      [
        "add #{inspect(attribute.source)}",
        ":serial",
        maybe_add_null(attribute.allow_nil?),
        maybe_add_primary_key(attribute.primary_key?)
      ]
      |> join()
    end

    def up(%{attribute: attribute}) do
      size =
        if attribute[:size] do
          "size: #{attribute[:size]}"
        end

      [
        "add #{inspect(attribute.source)}",
        "#{inspect(attribute.type)}",
        maybe_add_null(attribute.allow_nil?),
        maybe_add_default(attribute.default),
        size,
        maybe_add_precision(attribute[:precision]),
        maybe_add_scale(attribute[:scale]),
        maybe_add_primary_key(attribute.primary_key?)
      ]
      |> join()
    end

    def down(
          %{
            attribute: attribute,
            table: table,
            multitenancy: multitenancy
          } = op
        ) do
      AshPostgres.MigrationGenerator.Operation.RemoveAttribute.up(%{
        op
        | attribute: attribute,
          table: table,
          multitenancy: multitenancy
      })
    end
  end

  defmodule AlterDeferrability do
    @moduledoc false
    defstruct [:table, :schema, :references, :direction, no_phase: true]

    defp prefix_name(name, prefix) do
      if prefix do
        "#{prefix}.#{name}"
      else
        name
      end
    end

    def up(%{
          direction: :up,
          schema: schema,
          table: table,
          references: %{name: name, deferrable: true}
        }) do
      "execute(\"ALTER TABLE #{prefix_name(table, schema)} ALTER CONSTRAINT #{name} DEFERRABLE INITIALLY IMMEDIATE\");"
    end

    def up(%{
          direction: :up,
          schema: schema,
          table: table,
          references: %{name: name, deferrable: :initially}
        }) do
      "execute(\"ALTER TABLE #{prefix_name(table, schema)} ALTER CONSTRAINT #{name} DEFERRABLE INITIALLY DEFERRED\");"
    end

    def up(%{direction: :up, schema: schema, table: table, references: %{name: name}}) do
      "execute(\"ALTER TABLE #{prefix_name(table, schema)} ALTER CONSTRAINT #{name} NOT DEFERRABLE\");"
    end

    def up(_), do: ""

    def down(%{direction: :down} = data), do: up(%{data | direction: :up})
    def down(_), do: ""
  end

  defmodule AlterAttribute do
    @moduledoc false
    defstruct [
      :old_attribute,
      :new_attribute,
      :table,
      :schema,
      :multitenancy,
      :old_multitenancy
    ]

    import Helper

    defp alter_opts(attribute, old_attribute) do
      primary_key =
        cond do
          attribute.primary_key? and !old_attribute.primary_key? ->
            ", primary_key: true"

          old_attribute.primary_key? and !attribute.primary_key? ->
            ", primary_key: false"

          true ->
            nil
        end

      default =
        if attribute.default != old_attribute.default do
          if is_nil(attribute.default) do
            ", default: nil"
          else
            ", default: #{attribute.default}"
          end
        end

      null =
        if attribute.allow_nil? != old_attribute.allow_nil? do
          ", null: #{attribute.allow_nil?}"
        end

      precision =
        if Map.get(attribute, :precision) != Map.get(old_attribute, :precision) do
          if attribute.precision do
            ", precision: #{attribute.precision}"
          end
        end

      scale =
        if Map.get(attribute, :scale) != Map.get(old_attribute, :scale) do
          if attribute.scale do
            ", scale: #{attribute.scale}"
          end
        end

      "#{null}#{default}#{precision}#{scale}#{primary_key}"
    end

    def up(%{
          multitenancy: multitenancy,
          old_attribute: old_attribute,
          new_attribute: attribute,
          schema: schema
        }) do
      type_or_reference =
        if AshPostgres.MigrationGenerator.has_reference?(multitenancy, attribute) and
             Map.get(old_attribute, :references) != Map.get(attribute, :references) do
          reference(multitenancy, attribute, schema)
        else
          if attribute.type == :bigint and attribute.default == "nil" and attribute.generated? do
            ":bigserial"
          else
            inspect(attribute.type)
          end
        end

      "modify #{inspect(attribute.source)}, #{type_or_reference}#{alter_opts(attribute, old_attribute)}"
    end

    defp reference(
           %{strategy: :context},
           %{
             references:
               %{
                 multitenancy: %{strategy: :context},
                 table: table,
                 destination_attribute: destination_attribute
               } = reference
           } = attribute,
           _schema
         ) do
      with_match = with_match(reference)

      size =
        if attribute[:size] do
          "size: #{attribute[:size]}"
        end

      join([
        "references(:#{as_atom(table)}, column: #{inspect(destination_attribute)}",
        with_match,
        "name: #{inspect(reference.name)}",
        "type: #{inspect(reference_type(attribute, reference))}",
        size,
        maybe_add_precision(attribute[:precision]),
        maybe_add_scale(attribute[:scale]),
        "prefix: prefix()",
        on_delete(reference),
        on_update(reference),
        ")"
      ])
    end

    defp reference(
           %{strategy: :attribute, attribute: source_attribute},
           %{
             references:
               %{
                 multitenancy: %{strategy: :attribute},
                 table: table,
                 schema: destination_schema,
                 destination_attribute: reference_attribute
               } = reference
           } = attribute,
           schema
         ) do
      destination_schema =
        if schema != destination_schema do
          destination_schema
        end

      with_match = with_match(reference, source_attribute)

      size =
        if attribute[:size] do
          "size: #{attribute[:size]}"
        end

      join([
        "references(:#{as_atom(table)}, column: #{inspect(reference_attribute)}",
        with_match,
        "name: #{inspect(reference.name)}",
        "type: #{inspect(reference_type(attribute, reference))}",
        size,
        maybe_add_precision(attribute[:precision]),
        maybe_add_scale(attribute[:scale]),
        option("prefix", destination_schema),
        on_delete(reference),
        on_update(reference),
        ")"
      ])
    end

    defp reference(
           %{strategy: :context},
           %{
             references:
               %{
                 table: table,
                 destination_attribute: destination_attribute,
                 schema: destination_schema
               } = reference
           } = attribute,
           schema
         ) do
      with_match = with_match(reference)

      size =
        if attribute[:size] do
          "size: #{attribute[:size]}"
        end

      destination_schema =
        if schema != destination_schema do
          destination_schema
        end

      join([
        "references(:#{as_atom(table)}, column: #{inspect(destination_attribute)}",
        with_match,
        "name: #{inspect(reference.name)}",
        "type: #{inspect(reference_type(attribute, reference))}",
        size,
        option("prefix", destination_schema),
        on_delete(reference),
        on_update(reference),
        ")"
      ])
    end

    defp reference(
           _,
           %{
             references:
               %{
                 table: table,
                 destination_attribute: destination_attribute,
                 schema: destination_schema
               } = reference
           } = attribute,
           schema
         ) do
      with_match = with_match(reference)

      destination_schema =
        if schema != destination_schema do
          destination_schema
        end

      size =
        if attribute[:size] do
          "size: #{attribute[:size]}"
        end

      join([
        "references(:#{as_atom(table)}, column: #{inspect(destination_attribute)}",
        with_match,
        "name: #{inspect(reference.name)}",
        "type: #{inspect(reference_type(attribute, reference))}",
        size,
        maybe_add_precision(attribute[:precision]),
        maybe_add_scale(attribute[:scale]),
        option("prefix", destination_schema),
        on_delete(reference),
        on_update(reference),
        ")"
      ])
    end

    def down(op) do
      up(%{
        op
        | old_attribute: op.new_attribute,
          new_attribute: Map.put(op.old_attribute, :source, op.new_attribute.source),
          old_multitenancy: op.multitenancy,
          multitenancy: op.old_multitenancy
      })
    end
  end

  defmodule DropForeignKey do
    @moduledoc false
    # We only run this migration in one direction, based on the input
    # This is because the creation of a foreign key is handled by `references/3`
    # We only need to drop it before altering an attribute with `references/3`
    defstruct [:attribute, :schema, :table, :multitenancy, :direction, no_phase: true]

    import Helper

    def up(%{table: table, schema: schema, attribute: %{references: reference}, direction: :up}) do
      "drop constraint(:#{as_atom(table)}, #{join([inspect(reference.name), option("prefix", schema)])})"
    end

    def up(_) do
      ""
    end

    def down(%{
          table: table,
          schema: schema,
          attribute: %{references: reference},
          direction: :down
        }) do
      "drop constraint(:#{as_atom(table)}, #{join([inspect(reference.name), option("prefix", schema)])})"
    end

    def down(_) do
      ""
    end
  end

  defmodule RenameAttribute do
    @moduledoc false
    defstruct [
      :old_attribute,
      :new_attribute,
      :table,
      :schema,
      :multitenancy,
      :old_multitenancy,
      no_phase: true
    ]

    import Helper

    def up(%{
          old_attribute: old_attribute,
          new_attribute: new_attribute,
          schema: schema,
          table: table
        }) do
      table_statement = join([":#{as_atom(table)}", option("prefix", schema)])

      "rename table(#{table_statement}), #{inspect(old_attribute.source)}, to: #{inspect(new_attribute.source)}"
    end

    def down(
          %{
            old_attribute: old_attribute,
            new_attribute: new_attribute
          } = data
        ) do
      up(%{data | new_attribute: old_attribute, old_attribute: new_attribute})
    end
  end

  defmodule RemoveAttribute do
    @moduledoc false
    defstruct [:attribute, :schema, :table, :multitenancy, :old_multitenancy, commented?: true]

    def up(%{attribute: attribute, commented?: true}) do
      """
      # Attribute removal has been commented out to avoid data loss. See the migration generator documentation for more
      # If you uncomment this, be sure to also uncomment the corresponding attribute *addition* in the `down` migration
      # remove #{inspect(attribute.source)}
      """
    end

    def up(%{attribute: attribute}) do
      "remove #{inspect(attribute.source)}"
    end

    def down(%{attribute: attribute, multitenancy: multitenancy, commented?: true}) do
      prefix = """
      # This is the `down` migration of the statement:
      #
      #     remove #{inspect(attribute.source)}
      #
      """

      contents =
        %AshPostgres.MigrationGenerator.Operation.AddAttribute{
          attribute: attribute,
          multitenancy: multitenancy
        }
        |> AshPostgres.MigrationGenerator.Operation.AddAttribute.up()
        |> String.split("\n")
        |> Enum.map_join("\n", &"# #{&1}")

      prefix <> "\n" <> contents
    end

    def down(%{attribute: attribute, multitenancy: multitenancy, table: table, schema: schema}) do
      AshPostgres.MigrationGenerator.Operation.AddAttribute.up(
        %AshPostgres.MigrationGenerator.Operation.AddAttribute{
          attribute: attribute,
          table: table,
          schema: schema,
          multitenancy: multitenancy
        }
      )
    end
  end

  defmodule AddUniqueIndex do
    @moduledoc false
    defstruct [
      :identity,
      :table,
      :schema,
      :multitenancy,
      :old_multitenancy,
      no_phase: true,
      concurrently: false
    ]

    import Helper

    def up(%{
          identity: %{
            name: name,
            keys: keys,
            nils_distinct?: nils_distinct?,
            where: where,
            base_filter: base_filter,
            index_name: index_name,
            all_tenants?: all_tenants?
          },
          table: table,
          schema: schema,
          multitenancy: multitenancy
        }) do
      keys = index_keys(keys, all_tenants?, multitenancy)

      index_name = index_name || "#{table}_#{name}_index"

      cond do
        base_filter && where ->
          where = "(#{where}) AND (#{base_filter})"

          "create unique_index(:#{as_atom(table)}, [#{Enum.map_join(keys, ", ", &inspect/1)}], #{join(["name: \"#{index_name}\"", option("prefix", schema), option("nulls_distinct", nils_distinct?), option("where", where)])})"

        base_filter ->
          base_filter = "(#{base_filter})"

          "create unique_index(:#{as_atom(table)}, [#{Enum.map_join(keys, ", ", &inspect/1)}], where: \"#{base_filter}\", #{join(["name: \"#{index_name}\"", option("prefix", schema), option("nulls_distinct", nils_distinct?)])})"

        where ->
          where = "(#{where})"

          "create unique_index(:#{as_atom(table)}, [#{Enum.map_join(keys, ", ", &inspect/1)}], #{join(["name: \"#{index_name}\"", option("prefix", schema), option("nulls_distinct", nils_distinct?), option("where", where)])})"

        true ->
          "create unique_index(:#{as_atom(table)}, [#{Enum.map_join(keys, ", ", &inspect/1)}], #{join(["name: \"#{index_name}\"", option("prefix", schema), option("nulls_distinct", nils_distinct?)])})"
      end
    end

    def down(%{
          identity: %{name: name, keys: keys, index_name: index_name, all_tenants?: all_tenants?},
          table: table,
          schema: schema,
          multitenancy: multitenancy
        }) do
      keys = index_keys(keys, all_tenants?, multitenancy)

      index_name = index_name || "#{table}_#{name}_index"

      "drop_if_exists unique_index(:#{as_atom(table)}, [#{Enum.map_join(keys, ", ", &inspect/1)}], #{join(["name: \"#{index_name}\"", option("prefix", schema)])})"
    end
  end

  defmodule AddCustomStatement do
    @moduledoc false
    defstruct [:statement, :table, no_phase: true]

    def up(%{statement: %{up: up, code?: false}}) do
      """
      execute(\"\"\"
      #{String.trim(up)}
      \"\"\")
      """
    end

    def up(%{statement: %{up: up, code?: true}}) do
      up
    end

    def down(%{statement: %{down: down, code?: false}}) do
      """
      execute(\"\"\"
      #{String.trim(down)}
      \"\"\")
      """
    end

    def down(%{statement: %{down: down, code?: true}}) do
      down
    end
  end

  defmodule RemoveCustomStatement do
    @moduledoc false
    defstruct [:statement, :table, no_phase: true]

    def up(%{statement: statement, table: table}) do
      AddCustomStatement.down(%AddCustomStatement{statement: statement, table: table})
    end

    def down(%{statement: statement, table: table}) do
      AddCustomStatement.up(%AddCustomStatement{statement: statement, table: table})
    end
  end

  defmodule AddCustomIndex do
    @moduledoc false
    defstruct [:table, :schema, :index, :base_filter, :multitenancy, no_phase: true]
    import Helper

    def up(%{
          index: index,
          table: table,
          schema: schema,
          base_filter: base_filter,
          multitenancy: multitenancy
        }) do
      keys = index_keys(index.fields, index.all_tenants?, multitenancy)

      index =
        case {index.where, base_filter} do
          {_where, nil} -> index
          {nil, base_filter} -> %{index | where: base_filter}
          {where, base_filter} -> %{index | where: base_filter <> " AND " <> where}
        end

      opts =
        join([
          option(:name, index.name),
          option(:unique, index.unique),
          option(:concurrently, index.concurrently),
          option(:using, index.using),
          option(:prefix, index.prefix),
          option(:where, index.where),
          option(:include, index.include),
          option(:nulls_distinct, index.nulls_distinct),
          option(:prefix, schema)
        ])

      if opts == "" do
        "create index(:#{as_atom(table)}, [#{Enum.map_join(keys, ", ", &inspect/1)}])"
      else
        "create index(:#{as_atom(table)}, [#{Enum.map_join(keys, ", ", &inspect/1)}], #{opts})"
      end
    end

    def down(%{schema: schema, index: index, table: table, multitenancy: multitenancy}) do
      keys = index_keys(index.fields, index.all_tenants?, multitenancy)

      opts =
        join([
          option(:name, index.name),
          option(:prefix, schema)
        ])

      if opts == "" do
        "drop_if_exists index(:#{as_atom(table)}, [#{Enum.map_join(keys, ", ", &inspect/1)}])"
      else
        "drop_if_exists index(:#{as_atom(table)}, [#{Enum.map_join(keys, ", ", &inspect/1)}], #{opts})"
      end
    end
  end

  defmodule AddReferenceIndex do
    @moduledoc false
    defstruct [:table, :schema, :source, :multitenancy, no_phase: true]
    import Helper

    def up(%{
          source: source,
          table: table,
          schema: schema,
          multitenancy: multitenancy
        }) do
      keys =
        case multitenancy do
          %{strategy: :attribute, attribute: attribute} when attribute != source ->
            [attribute, source]

          _ ->
            [source]
        end

      opts =
        join([
          option(:prefix, schema)
        ])

      if opts == "" do
        "create index(:#{as_atom(table)}, [#{Enum.map_join(keys, ", ", &inspect/1)}])"
      else
        "create index(:#{as_atom(table)}, [#{Enum.map_join(keys, ", ", &inspect/1)}], #{opts})"
      end
    end

    def down(%{schema: schema, source: source, table: table, multitenancy: multitenancy}) do
      keys =
        case multitenancy do
          %{strategy: :attribute, attribute: attribute} when attribute != source ->
            [attribute, source]

          _ ->
            [source]
        end

      opts =
        join([
          option(:prefix, schema)
        ])

      if opts == "" do
        "drop_if_exists index(:#{as_atom(table)}, [#{Enum.map_join(keys, ", ", &inspect/1)}])"
      else
        "drop_if_exists index(:#{as_atom(table)}, [#{Enum.map_join(keys, ", ", &inspect/1)}], #{opts})"
      end
    end
  end

  defmodule AddPrimaryKey do
    @moduledoc false
    defstruct [:schema, :table, :keys, no_phase: true]

    def up(%{schema: schema, table: table, keys: keys, multitenancy: multitenancy}) do
      keys = Enum.join(keys, ", ")

      cond do
        multitenancy.strategy == :context ->
          """
          execute("ALTER TABLE \\\"\#{prefix()}\\\".\\\"#{table}\\\" ADD PRIMARY KEY (#{keys})")
          """

        schema ->
          """
          execute("ALTER TABLE \\\"#{schema}.#{table}\\\" ADD PRIMARY KEY (#{keys})")
          """

        true ->
          """
          execute("ALTER TABLE \\\"#{table}\\\" ADD PRIMARY KEY (#{keys})")
          """
      end
    end

    def down(_) do
      ""
    end
  end

  defmodule AddPrimaryKeyDown do
    @moduledoc false
    defstruct [:schema, :table, :keys, :remove_old?, no_phase: true]

    def up(_) do
      ""
    end

    def down(%{
          schema: schema,
          table: table,
          remove_old?: remove_old?,
          keys: keys,
          multitenancy: multitenancy
        }) do
      keys = Enum.join(keys, ", ")

      cond do
        multitenancy.strategy == :context ->
          remove_old =
            if remove_old? do
              """
              execute("ALTER TABLE \\\"\#{prefix()}\\\".\\\"#{table}\\\" DROP constraint #{table}_pkey")
              """
            end

          """
          #{remove_old}
          execute("ALTER TABLE \\\"\#{prefix()}\\\".\\\"#{table}\\\" ADD PRIMARY KEY (#{keys})")
          """

        not is_nil(schema) ->
          remove_old =
            if remove_old? do
              """
              execute("ALTER TABLE \\\"#{schema}.#{table}\\\" DROP constraint #{table}_pkey")
              """
            end

          """
          #{remove_old}
          execute("ALTER TABLE \\\"#{schema}.#{table}\\\" ADD PRIMARY KEY (#{keys})")
          """

        true ->
          remove_old =
            if remove_old? do
              """
              execute("ALTER TABLE \\\"#{table}\\\" DROP constraint #{table}_pkey")
              """
            end

          """
          #{remove_old}
          execute("ALTER TABLE \\\"#{table}\\\" ADD PRIMARY KEY (#{keys})")
          """
      end
    end
  end

  defmodule RemovePrimaryKey do
    @moduledoc false
    defstruct [:schema, :table, no_phase: true]

    def up(%{schema: schema, table: table, old_multitenancy: multitenancy}) do
      cond do
        multitenancy.strategy == :context ->
          "drop constraint(#{inspect(table)}, \"#{table}_pkey\", prefix: prefix())"

        schema ->
          "drop constraint(#{inspect(table)}, \"#{table}_pkey\", prefix: \"#{schema}\")"

        true ->
          "drop constraint(#{inspect(table)}, \"#{table}_pkey\")"
      end
    end

    def down(_) do
      ""
    end
  end

  defmodule RemovePrimaryKeyDown do
    @moduledoc false
    defstruct [:schema, :table, commented?: false, no_phase: true]

    def up(_) do
      ""
    end

    def down(%{schema: schema, table: table, commented?: commented?, multitenancy: multitenancy}) do
      comment =
        if commented? do
          """
          # Primary key removal is dropped because a corresponding attribute removal
          # has been commented out. If you uncomment this, uncomment the attribute removal and vice versa.
          """
        else
          ""
        end

      cond do
        multitenancy.strategy == :context ->
          "#{comment}drop constraint(#{inspect(table)}, \"#{table}_pkey\", prefix: prefix())"

        schema ->
          "#{comment}drop constraint(#{inspect(table)}, \"#{table}_pkey\", prefix: \"#{schema}\")"

        true ->
          "#{comment}drop constraint(#{inspect(table)}, \"#{table}_pkey\")"
      end
    end
  end

  defmodule RemoveCustomIndex do
    @moduledoc false
    defstruct [
      :schema,
      :table,
      :index,
      :base_filter,
      :multitenancy,
      :old_multitenancy,
      no_phase: true
    ]

    import Helper

    def up(operation) do
      AddCustomIndex.down(%{operation | multitenancy: operation.old_multitenancy})
    end

    def down(operation) do
      AddCustomIndex.up(%{operation | multitenancy: operation.old_multitenancy})
    end
  end

  defmodule RemoveReferenceIndex do
    @moduledoc false
    defstruct [:schema, :table, :source, :multitenancy, :old_multitenancy, no_phase: true]
    import Helper

    def up(operation) do
      AddReferenceIndex.down(%{operation | multitenancy: operation.old_multitenancy})
    end

    def down(operation) do
      AddReferenceIndex.up(%{operation | multitenancy: operation.old_multitenancy})
    end
  end

  defmodule RenameUniqueIndex do
    @moduledoc false
    defstruct [
      :new_identity,
      :old_identity,
      :table,
      :schema,
      :multitenancy,
      :old_multitenancy,
      no_phase: true
    ]

    defp prefix_name(name, prefix) do
      if prefix do
        "#{prefix}.#{name}"
      else
        name
      end
    end

    def up(%{
          old_identity: %{index_name: old_index_name, name: old_name},
          new_identity: %{index_name: new_index_name},
          schema: schema,
          table: table
        }) do
      old_index_name = old_index_name || "#{table}_#{old_name}_index"

      "execute(\"ALTER INDEX #{prefix_name(old_index_name, schema)} " <>
        "RENAME TO #{prefix_name(new_index_name, schema)}\")\n"
    end

    def down(%{
          old_identity: %{index_name: old_index_name, name: old_name},
          new_identity: %{index_name: new_index_name},
          schema: schema,
          table: table
        }) do
      old_index_name = old_index_name || "#{table}_#{old_name}_index"

      "execute(\"ALTER INDEX #{prefix_name(new_index_name, schema)} " <>
        "RENAME TO #{prefix_name(old_index_name, schema)}\")\n"
    end
  end

  defmodule RemoveUniqueIndex do
    @moduledoc false
    defstruct [:identity, :schema, :table, :multitenancy, :old_multitenancy, no_phase: true]

    import Helper

    def up(operation) do
      AddUniqueIndex.down(%{operation | multitenancy: operation.old_multitenancy})
    end

    def down(operation) do
      AddUniqueIndex.up(%{operation | multitenancy: operation.old_multitenancy})
    end
  end

  defmodule AddCheckConstraint do
    @moduledoc false
    defstruct [:table, :schema, :constraint, :multitenancy, :old_multitenancy, no_phase: true]

    import Helper

    def up(%{
          schema: schema,
          constraint: %{
            name: name,
            check: check,
            base_filter: base_filter
          },
          table: table
        }) do
      prefix = if schema, do: ", " <> option(:prefix, schema), else: ""

      if base_filter do
        ~s'''
        create constraint(:#{as_atom(table)}, :#{as_atom(name)}, check: """
          (#{check}) OR NOT (#{base_filter})
        """#{prefix})
        '''
      else
        ~s'''
        create constraint(:#{as_atom(table)}, :#{as_atom(name)}, check: """
          #{check}
        """#{prefix})
        '''
      end
    end

    def down(%{
          constraint: %{name: name},
          schema: schema,
          table: table
        }) do
      "drop_if_exists constraint(:#{as_atom(table)}, #{join([":#{as_atom(name)}", option(:prefix, schema)])})"
    end
  end

  defmodule RemoveCheckConstraint do
    @moduledoc false
    defstruct [:table, :schema, :constraint, :multitenancy, :old_multitenancy, no_phase: true]

    import Helper

    def up(%{constraint: %{name: name}, schema: schema, table: table}) do
      "drop_if_exists constraint(:#{as_atom(table)}, #{join([":#{as_atom(name)}", option(:prefix, schema)])})"
    end

    def down(%{
          constraint: %{
            name: name,
            check: check,
            base_filter: base_filter
          },
          schema: schema,
          table: table
        }) do
      prefix = if schema, do: ", " <> option(:prefix, schema), else: ""

      if base_filter do
        ~s'''
        create constraint(:#{as_atom(table)}, :#{as_atom(name)}, check: """
          #{base_filter} AND #{check}
        """#{prefix})
        '''
      else
        ~s'''
        create constraint(:#{as_atom(table)}, :#{as_atom(name)}, check: """
          #{check}
        """#{prefix})
        '''
      end
    end
  end
end
