# frozen_string_literal: true

module Gitlab
  module Database
    module Migrations
      module ConstraintsHelpers
        include LockRetriesHelpers
        include TimeoutHelpers

        # https://www.postgresql.org/docs/current/sql-syntax-lexical.html#SQL-SYNTAX-IDENTIFIERS
        MAX_IDENTIFIER_NAME_LENGTH = 63

        def self.check_constraint_exists?(table, constraint_name, connection:)
          table_name, schema_name = table.to_s.split('.').reverse
          schema_name ||= connection.current_schema

          # Constraint names are unique per table in Postgres, not per schema
          # Two tables can have constraints with the same name, so we filter by
          # the table name in addition to using the constraint_name

          check_sql = <<~SQL
            SELECT COUNT(*)
            FROM pg_catalog.pg_constraint con
              INNER JOIN pg_catalog.pg_class rel
                ON rel.oid = con.conrelid
              INNER JOIN pg_catalog.pg_namespace nsp
                ON nsp.oid = con.connamespace
            WHERE con.contype = 'c'
            AND con.conname = #{connection.quote(constraint_name)}
            AND nsp.nspname = #{connection.quote(schema_name)}
            AND rel.relname = #{connection.quote(table_name)}
          SQL

          connection.select_value(check_sql.squish) > 0
        end

        # Returns the name for a check constraint
        #
        # type:
        # - Any value, as long as it is unique
        # - Constraint names are unique per table in Postgres, and, additionally,
        #   we can have multiple check constraints over a column
        #   So we use the (table, column, type) triplet as a unique name
        # - e.g. we use 'max_length' when adding checks for text limits
        #        or 'not_null' when adding a NOT NULL constraint
        #
        def check_constraint_name(table, column, type)
          identifier = "#{table}_#{column}_check_#{type}"
          # Check concurrent_foreign_key_name() for info on why we use a hash
          hashed_identifier = Digest::SHA256.hexdigest(identifier).first(10)

          "check_#{hashed_identifier}"
        end

        def check_constraint_exists?(table, constraint_name)
          ConstraintsHelpers.check_constraint_exists?(table, constraint_name, connection: connection)
        end

        # Adds a check constraint to a table
        #
        # This method is the generic helper for adding any check constraint
        # More specialized helpers may use it (e.g. add_text_limit or add_not_null)
        #
        # This method only requires minimal locking:
        # - The constraint is added using NOT VALID
        #   This allows us to add the check constraint without validating it
        # - The check will be enforced for new data (inserts) coming in
        # - If `validate: true` the constraint is also validated
        #   Otherwise, validate_check_constraint() can be used at a later stage
        # - Check comments on add_concurrent_foreign_key for more info
        #
        # table  - The table the constraint will be added to
        # check  - The check clause to add
        #          e.g. 'char_length(name) <= 5' or 'store IS NOT NULL'
        # constraint_name - The name of the check constraint (otherwise auto-generated)
        #                   Should be unique per table (not per column)
        # validate - Whether to validate the constraint in this call
        #
        def add_check_constraint(table, check, constraint_name, validate: true)
          # Transactions would result in ALTER TABLE locks being held for the
          # duration of the transaction, defeating the purpose of this method.
          validate_not_in_transaction!(:add_check_constraint)

          validate_check_constraint_name!(constraint_name)

          if check_constraint_exists?(table, constraint_name)
            warning_message = <<~MESSAGE
              Check constraint was not created because it exists already
              (this may be due to an aborted migration or similar)
              table: #{table}, check: #{check}, constraint name: #{constraint_name}
            MESSAGE

            Gitlab::AppLogger.warn warning_message
          else
            # Only add the constraint without validating it
            # Even though it is fast, ADD CONSTRAINT requires an EXCLUSIVE lock
            # Use with_lock_retries to make sure that this operation
            # will not timeout on tables accessed by many processes
            with_lock_retries do
              execute <<~SQL
              ALTER TABLE #{table}
              ADD CONSTRAINT #{constraint_name}
              CHECK ( #{check} )
              NOT VALID;
              SQL
            end
          end

          validate_check_constraint(table, constraint_name) if validate
        end

        def validate_check_constraint(table, constraint_name)
          validate_check_constraint_name!(constraint_name)

          unless check_constraint_exists?(table, constraint_name)
            raise missing_schema_object_message(table, "check constraint", constraint_name)
          end

          disable_statement_timeout do
            # VALIDATE CONSTRAINT only requires a SHARE UPDATE EXCLUSIVE LOCK
            # It only conflicts with other validations and creating indexes
            execute("ALTER TABLE #{table} VALIDATE CONSTRAINT #{constraint_name};")
          end
        end

        def remove_check_constraint(table, constraint_name)
          # This is technically not necessary, but aligned with add_check_constraint
          # and allows us to continue use with_lock_retries here
          validate_not_in_transaction!(:remove_check_constraint)

          validate_check_constraint_name!(constraint_name)

          # DROP CONSTRAINT requires an EXCLUSIVE lock
          # Use with_lock_retries to make sure that this will not timeout
          with_lock_retries do
            execute <<-SQL
            ALTER TABLE #{table}
            DROP CONSTRAINT IF EXISTS #{constraint_name}
            SQL
          end
        end

        # Copies all check constraints for the old column to the new column.
        #
        # table - The table containing the columns.
        # old - The old column.
        # new - The new column.
        # schema - The schema the table is defined for
        #          If it is not provided, then the current_schema is used
        def copy_check_constraints(table, old, new, schema: nil)
          raise 'copy_check_constraints can not be run inside a transaction' if transaction_open?

          raise "Column #{old} does not exist on #{table}" unless column_exists?(table, old)

          raise "Column #{new} does not exist on #{table}" unless column_exists?(table, new)

          table_with_schema = schema.present? ? "#{schema}.#{table}" : table

          check_constraints_for(table, old, schema: schema).each do |check_c|
            validate = !(check_c["constraint_def"].end_with? "NOT VALID")

            # Normalize:
            # - Old constraint definitions:
            #    '(char_length(entity_path) <= 5500)'
            # - Definitionss from pg_get_constraintdef(oid):
            #    'CHECK ((char_length(entity_path) <= 5500))'
            # - Definitions from pg_get_constraintdef(oid, pretty_bool):
            #    'CHECK (char_length(entity_path) <= 5500)'
            # - Not valid constraints: 'CHECK (...) NOT VALID'
            # to a single format that we can use:
            #    '(char_length(entity_path) <= 5500)'
            check_definition = check_c["constraint_def"]
                                .sub(/^\s*(CHECK)?\s*\({0,2}/, '(')
                                .sub(/\){0,2}\s*(NOT VALID)?\s*$/, ')')

            constraint_name = if check_definition == "(#{old} IS NOT NULL)"
                                not_null_constraint_name(table_with_schema, new)
                              elsif check_definition.start_with? "(char_length(#{old}) <="
                                text_limit_name(table_with_schema, new)
                              else
                                check_constraint_name(table_with_schema, new, 'copy_check_constraint')
                              end

            add_check_constraint(
              table_with_schema,
              check_definition.gsub(old.to_s, new.to_s),
              constraint_name,
              validate: validate
            )
          end
        end

        # Migration Helpers for adding limit to text columns
        def add_text_limit(table, column, limit, constraint_name: nil, validate: true)
          add_check_constraint(
            table,
            "char_length(#{column}) <= #{limit}",
            text_limit_name(table, column, name: constraint_name),
            validate: validate
          )
        end

        def validate_text_limit(table, column, constraint_name: nil)
          validate_check_constraint(table, text_limit_name(table, column, name: constraint_name))
        end

        def remove_text_limit(table, column, constraint_name: nil)
          remove_check_constraint(table, text_limit_name(table, column, name: constraint_name))
        end

        def check_text_limit_exists?(table, column, constraint_name: nil)
          check_constraint_exists?(table, text_limit_name(table, column, name: constraint_name))
        end

        # Migration Helpers for managing not null constraints
        def add_not_null_constraint(table, column, constraint_name: nil, validate: true)
          if column_is_nullable?(table, column)
            add_check_constraint(
              table,
              "#{column} IS NOT NULL",
              not_null_constraint_name(table, column, name: constraint_name),
              validate: validate
            )
          else
            warning_message = <<~MESSAGE
              NOT NULL check constraint was not created:
              column #{table}.#{column} is already defined as `NOT NULL`
            MESSAGE

            Gitlab::AppLogger.warn warning_message
          end
        end

        def validate_not_null_constraint(table, column, constraint_name: nil)
          validate_check_constraint(
            table,
            not_null_constraint_name(table, column, name: constraint_name)
          )
        end

        def remove_not_null_constraint(table, column, constraint_name: nil)
          remove_check_constraint(
            table,
            not_null_constraint_name(table, column, name: constraint_name)
          )
        end

        def check_not_null_constraint_exists?(table, column, constraint_name: nil)
          check_constraint_exists?(
            table,
            not_null_constraint_name(table, column, name: constraint_name)
          )
        end

        def add_multi_column_not_null_constraint(
          table, *columns, limit: 1, operator: '=', constraint_name: nil, validate: true
        )
          raise 'Expected multiple columns, use add_not_null_constraint for a single column' unless columns.size > 1

          add_check_constraint(
            table,
            "num_nonnulls(#{columns.sort.join(', ')}) #{operator} #{limit}",
            multi_column_not_null_constraint_name(table, columns, name: constraint_name),
            validate: validate
          )
        end

        def validate_multi_column_not_null_constraint(table, *columns, constraint_name: nil)
          validate_check_constraint(
            table,
            multi_column_not_null_constraint_name(table, columns, name: constraint_name)
          )
        end

        def remove_multi_column_not_null_constraint(table, *columns, constraint_name: nil)
          remove_check_constraint(table, multi_column_not_null_constraint_name(table, columns, name: constraint_name))
        end

        def rename_constraint(table_name, old_name, new_name)
          execute <<~SQL
            ALTER TABLE #{quote_table_name(table_name)}
            RENAME CONSTRAINT #{quote_column_name(old_name)} TO #{quote_column_name(new_name)}
          SQL
        end

        def drop_constraint(table_name, constraint_name, cascade: false)
          execute <<~SQL
            ALTER TABLE #{quote_table_name(table_name)} DROP CONSTRAINT #{quote_column_name(constraint_name)} #{cascade_statement(cascade)}
          SQL
        end

        def switch_constraint_names(table_name, constraint_a, constraint_b)
          validate_not_in_transaction!(:switch_constraint_names)

          with_lock_retries do
            rename_constraint(table_name, constraint_a, :temp_name_for_renaming)
            rename_constraint(table_name, constraint_b, constraint_a)
            rename_constraint(table_name, :temp_name_for_renaming, constraint_b)
          end
        end

        def validate_check_constraint_name!(constraint_name)
          return unless constraint_name.to_s.length > MAX_IDENTIFIER_NAME_LENGTH

          raise "The maximum allowed constraint name is #{MAX_IDENTIFIER_NAME_LENGTH} characters"
        end

        def text_limit_name(table, column, name: nil)
          name.presence || check_constraint_name(table, column, 'max_length')
        end

        private

        def validate_not_in_transaction!(method_name, modifier = nil)
          return unless transaction_open?

          raise <<~ERROR
            #{["`#{method_name}`", modifier].compact.join(' ')} cannot be run inside a transaction.

            You can disable transactions by calling `disable_ddl_transaction!` in the body of
            your migration class
          ERROR
        end

        # Returns an ActiveRecord::Result containing the check constraints
        # defined for the given column.
        #
        # If the schema is not provided, then the current_schema is used
        def check_constraints_for(table, column, schema: nil)
          check_sql = <<~SQL
            SELECT
              ccu.table_schema as schema_name,
              ccu.table_name as table_name,
              ccu.column_name as column_name,
              con.conname as constraint_name,
              pg_get_constraintdef(con.oid) as constraint_def
            FROM pg_catalog.pg_constraint con
              INNER JOIN pg_catalog.pg_class rel
                ON rel.oid = con.conrelid
              INNER JOIN pg_catalog.pg_namespace nsp
                ON nsp.oid = con.connamespace
              INNER JOIN information_schema.constraint_column_usage ccu
                ON con.conname = ccu.constraint_name
                      AND nsp.nspname = ccu.constraint_schema
                      AND rel.relname = ccu.table_name
            WHERE  nsp.nspname = #{connection.quote(schema.presence || current_schema)}
              AND rel.relname = #{connection.quote(table)}
              AND ccu.column_name = #{connection.quote(column)}
              AND con.contype = 'c'
            ORDER BY constraint_name
          SQL

          connection.exec_query(check_sql)
        end

        def cascade_statement(cascade)
          cascade ? 'CASCADE' : ''
        end

        def not_null_constraint_name(table, column, name: nil)
          name.presence || check_constraint_name(table, column, 'not_null')
        end

        def multi_column_not_null_constraint_name(table, columns, name: nil)
          name.presence || check_constraint_name(table, columns.sort.join('_'), 'num_nonnulls')
        end

        def missing_schema_object_message(table, type, name)
          <<~MESSAGE
            Could not find #{type} "#{name}" on table "#{table}" which was referenced during the migration.
            This issue could be caused by the database schema straying from the expected state.

            To resolve this issue, please verify:
              1. all previous migrations have completed
              2. the database objects used in this migration match the Rails definition in schema.rb or structure.sql

          MESSAGE
        end
      end
    end
  end
end
