# frozen_string_literal: true

require 'fileutils'
require 'forwardable'
require 'json'

require_relative 'postgresql'

module GDK
  class PostgresqlUpgrader
    extend Forwardable

    POSTGRESQL_VERSIONS = %w[16 14 13 12 11 10 9.6].freeze

    def_delegators :postgresql, :current_data_dir, :current_version, :upgrade_needed?

    def initialize(target_version = GDK::Postgresql.target_version_major)
      @target_version = target_version
    end

    def upgrade!
      check!

      unless upgrade_needed?(target_version)
        GDK::Output.success "'#{current_data_dir}' is already compatible with PostgreSQL #{target_version}."
        return
      end

      begin
        gdk_stop
        init_db_in_target_path
        pgvector_setup
        rename_current_data_dir
        pg_upgrade
        promote_new_db
        gdk_reconfigure
        pg_replica_upgrade('replica')
        pg_replica_upgrade('replica_2')
      rescue StandardError => e
        GDK::Output.error("An error occurred: #{e}", e)
        GDK::Output.warn 'Rolling back..'
        rename_current_data_dir_back
        GDK::Output.warn "Upgrade failed. Rolled back to the original PostgreSQL #{current_version}."
        raise e
      end

      GDK::Output.success "Upgraded '#{current_data_dir}' from PostgreSQL #{current_version} to #{target_version}."
    end

    def bin_path_or_fallback
      dir = begin
        bin_path
      rescue StandardError
        # Fallback to whatever pg_config is in the PATH
        pg_config_discover
      end

      unless dir
        GDK::Output.warn 'Unable to determine PostgreSQL bin directory. Using fallback.'
        return nil
      end

      dir
    end

    def bin_path(version = target_version)
      raise "Invalid PostgreSQL version #{version}" unless available_versions.key?(version)

      available_versions[version]
    end

    private

    attr_reader :target_version

    def check!
      GDK::Output.info "Available PostgreSQL versions: #{available_versions}"

      GDK::Output.abort "Unable to find target PostgreSQL version #{target_version}" unless available_versions.include?(target_version)
      GDK::Output.abort "Unable to find current PostgreSQL version #{current_version}" unless available_versions.include?(current_version)
    end

    def postgresql
      @postgresql ||= GDK::Postgresql.new
    end

    def renamed_current_data_dir
      @renamed_current_data_dir ||= "#{current_data_dir}.#{current_version}.#{Time.now.to_i}"
    end

    def target_path
      @target_path ||= "#{current_data_dir}.#{target_version}.#{Time.now.to_i}"
    end

    def gdk_stop
      run!('gdk stop', config.gdk_root)
    end

    def init_db_in_target_path
      cmd = "#{initdb_bin(target_version)} --locale=C -E utf-8 #{target_path}"
      GDK::Output.info "Initializing '#{target_path}' for PostgreSQL #{target_version}.."
      run_in_tmp!(cmd)
    end

    def rename_current_data_dir
      GDK::Output.info "Renaming #{current_data_dir} to #{renamed_current_data_dir}"
      FileUtils.mv(current_data_dir, renamed_current_data_dir)
    end

    def rename_current_data_dir_back
      return unless File.exist?(renamed_current_data_dir)

      GDK::Output.info "Renaming #{renamed_current_data_dir} to #{current_data_dir}"
      FileUtils.mv(renamed_current_data_dir, current_data_dir)
    end

    def pgvector_setup
      return unless config.pgvector.enabled? || config.gitlab.rails.databases.embedding.enabled?

      GDK::Output.info "Running 'make pgvector-clean pgvector-setup'.."
      run!('make pgvector-clean pgvector-setup', config.gdk_root)
    end

    def pg_upgrade
      cmd = "#{pg_upgrade_bin(target_version)} \
      --old-bindir #{bin_path(current_version)} \
      --new-bindir #{bin_path(target_version)} \
      --old-datadir #{renamed_current_data_dir} \
      --new-datadir #{target_path}"

      GDK::Output.info "Upgrading '#{renamed_current_data_dir}' (PostgreSQL #{current_version}) to '#{target_path}' PostgreSQL #{target_version}.."
      run_in_tmp!(cmd)
    end

    def remove_secondary_data?(replica_name)
      return false unless config.postgresql.public_send(replica_name).enabled? # rubocop:disable GitlabSecurity/PublicSend

      GDK::Output.warn("We're about to remove the old '#{replica_name}' database data because we will be replacing it with the primary database data.")

      return true if ENV.fetch('PG_AUTO_UPDATE', 'false') == 'true' || !GDK::Output.interactive?

      GDK::Output.prompt('Are you sure? [y/N]').match?(/\Ay(?:es)*\z/i)
    end

    def pg_replica_upgrade(replica_name)
      return true unless remove_secondary_data?(replica_name)

      pg_primary_dir = config.gdk_root.join('postgresql')
      pg_secondary_data_dir = config.gdk_root.join("postgresql-#{replica_name.tr('_', '-')}/data")

      GDK::Output.info 'Removing the old secondary database data...'
      run!("rm -rf #{pg_secondary_data_dir}", config.gdk_root)

      replication_user = config.postgresql.replication_user

      GDK::Output.info 'Copying data from primary to secondary...'
      cmd = "pg_basebackup -R -h #{pg_primary_dir} -D #{pg_secondary_data_dir} -P -U #{replication_user} --wal-method=fetch"
      run!(cmd, config.gdk_root)
    end

    def promote_new_db
      GDK::Output.info "Promoting newly-creating database from '#{target_path}' to '#{current_data_dir}'"
      FileUtils.mv(target_path, current_data_dir)
    end

    def gdk_reconfigure
      GDK::Output.info "Running 'gdk reconfigure'.."
      run!('gdk reconfigure', config.gdk_root)
    end

    def initdb_bin(version)
      File.join(bin_path(version), 'initdb')
    end

    def pg_upgrade_bin(version)
      File.join(bin_path(version), 'pg_upgrade')
    end

    def available_versions
      @available_versions ||=
        if GDK::Dependencies.asdf_available?
          asdf_available_versions
        elsif GDK::Dependencies.mise_available?
          mise_available_versions
        elsif GDK::Dependencies.homebrew_available?
          brew_cellar_available_versions.transform_keys(&:to_i)
        elsif GDK::Dependencies.linux_apt_available?
          apt_available_versions
        else
          raise 'Only Homebrew, asdf, mise, and apt based Linux systems supported.'
        end
    end

    def asdf_available_versions
      lines = run(%w[asdf list postgres])
      return {} if lines.empty?

      current_asdf_data_dir = ENV.fetch('ASDF_DATA_DIR', "#{Dir.home}/.asdf")
      versions = lines.split.map { |x| Gem::Version.new(x) }.sort.reverse

      asdf_package_paths(current_asdf_data_dir, versions)
    end

    def mise_available_versions
      lines = run(%w[mise list --current --installed --json postgres])
      return {} if lines.empty?

      JSON
        .parse(lines)
        .each { |x| x['version'] = Gem::Version.new(x.fetch('version')) }
        .sort_by { |x| x['version'] }
        .reverse
        .to_h { |x| [x.fetch('version').canonical_segments.first, x.fetch('install_path') << '/bin'] }
    end

    def asdf_package_paths(current_asdf_data_dir, versions)
      versions.each_with_object({}) do |version, paths|
        major_version, minor_version = version.canonical_segments[0..1]

        # We only care about the latest version
        next if paths.key?(major_version)

        paths[major_version] = "#{current_asdf_data_dir}/installs/postgres/#{major_version}.#{minor_version}/bin"
      end
    end

    def brew_cellar_available_versions
      POSTGRESQL_VERSIONS.each_with_object({}) do |version, paths|
        brew_cellar_pg = run(%W[brew --cellar postgresql@#{version}])

        next if brew_cellar_pg.empty?

        brew_cellar_pg_bin = Dir.glob(File.join(brew_cellar_pg, '/*/bin'))

        paths[version] = brew_cellar_pg_bin.last if brew_cellar_pg_bin.any?
      end
    end

    def apt_available_versions
      versions = POSTGRESQL_VERSIONS.map { |ver| "postgresql-#{ver}" }
      lines = run(%w[apt search -o APT::Cache::Search::Version=1 ^postgresql-[0-9]*$])

      return {} if lines.empty?

      available_packages = Set.new

      lines.split("\n").each do |line|
        package_data = line.strip.split
        available_packages << package_data.first.strip
      end

      postgresql_packages = available_packages & versions

      postgresql_packages.each_with_object({}) do |package, paths|
        version = package.gsub(/^postgresql-/, '').to_i
        pg_path = "/usr/lib/postgresql/#{version}/bin"
        paths[version] = pg_path if Dir.exist?(pg_path)
      end
    end

    def config
      GDK.config
    end

    def run(cmd)
      Shellout.new(cmd).try_run
    end

    def run!(cmd, chdir)
      sh = Shellout.new(cmd, chdir: chdir)
      sh.try_run

      return true if sh.success?

      GDK::Output.puts(sh.read_stdout)
      GDK::Output.puts(sh.read_stderr)

      raise "'#{cmd}' failed."
    end

    def run_in_tmp!(cmd)
      run!(cmd, config.gdk_root.join('tmp'))
    end

    def pg_config_discover
      Shellout.new(%w[pg_config --bindir], chdir: GDK.root).run
    rescue Errno::ENOENT
      nil
    end
  end
end
