# frozen_string_literal: true

require "helper"

class TestInternals < Minitest::Test
  include Helper::Client

  def test_falls_back_to_resp2_when_hello_is_unsupported
    # Simulate a server without RESP3 support (e.g. Redis < 6): reject HELLO so redis-client
    # raises UnsupportedServer, and verify the client transparently reconnects as RESP2.
    commands = {
      hello: ->(*_) { "-ERR unknown command 'HELLO'" },
      ping: ->(*_) { "+PONG" },
    }
    RedisMock.start(commands) do |port|
      redis = Redis.new(port: port, protocol: 3)
      assert_equal "PONG", redis.ping
      assert_equal 2, redis._client.protocol
    ensure
      redis&.close
    end
  end

  def test_warns_once_when_falling_back_to_resp2
    commands = {
      hello: ->(*_) { "-ERR unknown command 'HELLO'" },
      ping: ->(*_) { "+PONG" },
    }
    RedisMock.start(commands) do |port|
      redis = Redis.new(port: port, protocol: 3)
      results = nil
      _out, err = capture_io do
        results = [redis.ping, redis.ping] # second call is already RESP2: must not warn again
      end

      assert_equal %w[PONG PONG], results
      assert_match(/falling back to RESP2/, err)
      assert_equal 1, err.scan("falling back to RESP2").size, "expected exactly one fallback warning"
    ensure
      redis&.close
    end
  end

  def test_pipelined_falls_back_to_resp2_when_hello_is_unsupported
    # All client types fall back through the same path now: the resp3-unsupported error reaches
    # Redis#with_protocol_fallback untranslated and @client is rebuilt as RESP2. pipelined flows
    # through #synchronize, so it falls back just like a single command.
    commands = {
      hello: ->(*_) { "-ERR unknown command 'HELLO'" },
      ping: ->(*_) { "+PONG" },
    }
    RedisMock.start(commands) do |port|
      redis = Redis.new(port: port, protocol: 3)

      result = redis.pipelined { |pipeline| pipeline.ping }

      assert_equal ["PONG"], result
      assert_equal 2, redis._client.protocol
    ensure
      redis&.close
    end
  end

  def test_inherit_socket_is_preserved_after_resp2_fallback
    # The fallback rebuilds @client; inherit_socket lives outside @options (it's applied via
    # inherit_socket!), so the rebuild must re-apply it or fork safety is silently lost after a
    # downgrade.
    commands = {
      hello: ->(*_) { "-ERR unknown command 'HELLO'" },
      ping: ->(*_) { "+PONG" },
    }
    RedisMock.start(commands) do |port|
      redis = Redis.new(port: port, protocol: 3, inherit_socket: true)

      assert_equal "PONG", redis.ping
      assert_equal 2, redis._client.protocol
      assert redis._client.instance_variable_get(:@inherit_socket),
             "inherit_socket should be re-applied after the RESP3->RESP2 fallback rebuilds @client"
    ensure
      redis&.close
    end
  end

  def test_subscribe_falls_back_to_resp2_when_hello_is_unsupported
    # subscribe opens the pub/sub socket via @client.pubsub (ensure_connected), not a command path.
    # _subscription routes that through #synchronize so subscribe-as-first-operation against an old
    # server still falls back to RESP2. The mock rejects HELLO, then speaks RESP2.
    read_command = lambda do |session|
      line = session.gets
      next nil if line.nil?

      Array.new(line[1..-3].to_i) do
        bytes = session.gets[1..-3].to_i
        arg = session.read(bytes)
        session.read(2) # discard \r\n
        arg
      end
    end

    handler = lambda do |session|
      subscribed = nil
      while (argv = read_command.call(session))
        case argv.first.to_s.upcase
        when "HELLO"
          session.write("-ERR unknown command 'HELLO'\r\n")
        when "SUBSCRIBE"
          subscribed = argv[1]
          session.write("*3\r\n$9\r\nsubscribe\r\n$#{subscribed.bytesize}\r\n#{subscribed}\r\n:1\r\n")
          session.write("*3\r\n$7\r\nmessage\r\n$#{subscribed.bytesize}\r\n#{subscribed}\r\n$2\r\nhi\r\n")
        when "UNSUBSCRIBE"
          chan = argv[1] || subscribed # `unsubscribe` with no args means unsubscribe from all
          session.write("*3\r\n$11\r\nunsubscribe\r\n$#{chan.bytesize}\r\n#{chan}\r\n:0\r\n")
        else
          session.write("+OK\r\n")
        end
      end
    end

    RedisMock.start_with_handler(handler) do |port|
      redis = Redis.new(port: port, protocol: 3)
      messages = []
      redis.subscribe("chan") do |on|
        on.message do |_channel, message|
          messages << message
          redis.unsubscribe
        end
      end

      assert_equal ["hi"], messages
      assert_equal 2, redis._client.protocol
    ensure
      redis&.close
    end
  end

  def test_send_blocking_command_translates_redis_client_errors
    # Blocking commands must translate RedisClient errors to Redis errors like non-blocking ones.
    # The risk path: a raw RedisClient::Error escapes with_protocol_fallback (e.g. a non-translating
    # plain client, or build_client raising during the RESP2 rebuild). Use the plain-RedisClient
    # path so the error reaches send_blocking_command untranslated.
    commands = { blpop: ->(*_) { "-ERR something went wrong" } }
    RedisMock.start(commands) do |port|
      redis = Redis.new(port: port, protocol: 3)
      redis.instance_variable_set(:@client, RedisClient.config(port: port, protocol: 2).new_client)

      assert_raises(Redis::CommandError) { redis.blpop("key", timeout: 1) }
    ensure
      redis&.close
    end
  end

  def test_without_reconnect_falls_back_to_resp2_when_hello_is_unsupported
    # without_reconnect must go through the same fallback path as everything else: disable_reconnection
    # connects eagerly, so a pre-HELLO server has to downgrade to RESP2 rather than raise.
    commands = {
      hello: ->(*_) { "-ERR unknown command 'HELLO'" },
      ping: ->(*_) { "+PONG" },
    }
    RedisMock.start(commands) do |port|
      redis = Redis.new(port: port, protocol: 3)

      result = redis.without_reconnect { redis.ping }

      assert_equal "PONG", result
      assert_equal 2, redis._client.protocol
    ensure
      redis&.close
    end
  end

  def test_keeps_resp3_when_hello_succeeds
    commands = {
      hello: ->(*_) { "%1\r\n$7\r\nversion\r\n$5\r\n7.4.0\r\n" },
      ping: ->(*_) { "+PONG" },
    }
    RedisMock.start(commands) do |port|
      redis = Redis.new(port: port, protocol: 3)
      assert_equal "PONG", redis.ping
      assert_equal 3, redis._client.protocol
    ensure
      redis&.close
    end
  end

  def test_large_payload
    # see: https://github.com/redis/redis-rb/issues/962
    # large payloads will trigger write_nonblock to write a portion
    # of the payload in connection/ruby.rb _write_to_socket

    # We use a larger timeout for TruffleRuby
    # https://github.com/redis/redis-rb/pull/1128#issuecomment-1218490684
    r = init(_new_client(timeout: TIMEOUT * 5))
    large = "\u3042" * 4_000_000
    r.setex("foo", 10, large)
    result = r.get("foo")
    assert_equal result, large
  end

  def test_recovers_from_failed_commands
    # See https://github.com/redis/redis-rb/issues#issue/28

    assert_raises(Redis::CommandError) do
      r.command_that_doesnt_exist
    end

    r.info
  end

  def test_raises_on_protocol_errors
    redis_mock(ping: ->(*_) { "foo" }) do |redis|
      assert_raises(Redis::ProtocolError) do
        redis.ping
      end
    end
  end

  def test_redis_connected?
    fresh_client = _new_client
    assert !fresh_client.connected?

    fresh_client.ping
    assert fresh_client.connected?

    fresh_client.quit
    assert !fresh_client.connected?
  end

  def test_timeout
    Redis.new(OPTIONS.merge(timeout: 0))
  end

  # `CLIENT INFO` replies as one space-separated line of `key=value` pairs. Parsing it into fields
  # lets the assertions be exact: `assert_includes info, "lib-name=redis-rb"` would also accept
  # `lib-name=redis-rb-5.4.1` or `lib-name=redis-rb()`. `CLIENT SETINFO` rejects values containing
  # spaces, so splitting on space is safe.
  def client_info_fields(client)
    client.client(:info).split(" ").to_h { |field| field.split("=", 2) }
  end

  # The last `CLIENT SETINFO <attribute>` value in the prelude is the one the server ends up with.
  def prelude_setinfo(config, attribute)
    command = config.connection_prelude.reverse.find { |c| c[1] == "SETINFO" && c[2] == attribute }
    command && command[3]
  end

  def test_lib_name_set_via_client_setinfo
    target_version "7.2.0" do
      fresh_client = _new_client
      fresh_client.ping
      fields = client_info_fields(fresh_client)

      assert_equal "redis-rb", fields["lib-name"]
      assert_equal Redis::VERSION, fields["lib-ver"]
    end
  end

  def test_lib_name_includes_downstream_driver_info
    target_version "7.2.0" do
      fresh_client = _new_client(driver_info: "my-gem-1.0")
      fresh_client.ping
      fields = client_info_fields(fresh_client)

      assert_equal "redis-rb(my-gem-1.0)", fields["lib-name"]
      assert_equal Redis::VERSION, fields["lib-ver"]
    end
  end

  def test_lib_name_reported_for_every_driver_info_shape
    # The default case, and each shape `redis-client` accepts for `driver_info`. A downstream
    # `driver_info:` must never be able to replace redis-rb's own identity, only extend it.
    {
      nil => "redis-rb",
      "" => "redis-rb",
      [] => "redis-rb",
      # The officially recommended suffix format, `<name>_v<version>` with `;` between suffixes.
      "my-gem_v1.0.0" => "redis-rb(my-gem_v1.0.0)",
      ["my-gem_v1.0.0", "other_v2.0"] => "redis-rb(my-gem_v1.0.0;other_v2.0)",
      # Free-form values are passed through: the format above is a recommendation, not enforced.
      "my-gem-1.0" => "redis-rb(my-gem-1.0)",
      # `CLIENT SETINFO` rejects bytes outside `!`..`~` and redis-client swallows the error, which
      # would leave `lib-name` empty on the server. Parentheses would corrupt the `redis-rb(...)`
      # delimiters. Both are sanitized so redis-rb stays identifiable no matter what a downstream
      # library passes.
      "my gem 1.0" => "redis-rb(my_gem_1.0)",
      "sneaky)lib(1.0" => "redis-rb(sneaky_lib_1.0)",
      ["my gem", "v 1.0"] => "redis-rb(my_gem;v_1.0)",
      "\t \n" => "redis-rb",
      # Underscores the caller actually passed survive; only invalid runs are dropped at the edges.
      "_x_" => "redis-rb(_x_)",
      "(x)" => "redis-rb(x)",
      # Invalid bytes must not raise, whether in a binary string or an invalid UTF-8 one; they are
      # scrubbed like any other invalid character.
      "bad\xFFgem_v1.0" => "redis-rb(bad_gem_v1.0)",
      "bad\xFFgem_v1.0".dup.force_encoding(Encoding::UTF_8) => "redis-rb(bad_gem_v1.0)",
    }.each do |driver_info, expected|
      config = Redis::Client.config(**(driver_info.nil? ? {} : { driver_info: driver_info }))

      assert_equal expected, prelude_setinfo(config, "LIB-NAME"), "for driver_info: #{driver_info.inspect}"
      assert_match(/\A[!-~]+\z/, prelude_setinfo(config, "LIB-NAME"), "for driver_info: #{driver_info.inspect}")
      assert_equal Redis::VERSION, prelude_setinfo(config, "LIB-VER"), "for driver_info: #{driver_info.inspect}"
    end
  end

  def test_lib_name_rejected_by_the_server_is_tolerated
    # Redis < 7.2 has no CLIENT SETINFO; redis-client deliberately swallows exactly that error, so
    # the connection must survive and stay usable. Simulated by a server erroring on CLIENT.
    commands = {
      hello: ->(*_) { "-ERR unknown command 'HELLO'" }, # also exercises the RESP3->RESP2 fallback
      client: ->(*_) { "-ERR Unknown subcommand or wrong number of arguments for 'SETINFO'" },
      ping: ->(*_) { "+PONG" },
    }
    RedisMock.start(commands) do |port|
      redis = Redis.new(port: port, driver_info: "my-gem_v1.0.0", reconnect_attempts: 0)

      assert_equal "PONG", redis.ping
    ensure
      redis&.close
    end
  end

  def test_lib_name_rejects_invalid_driver_info_types
    # Anything that is not nil/false/String/Array is passed through for redis-client to reject,
    # matching its documented contract.
    assert_raises(ArgumentError) do
      Redis::Client.config(driver_info: :symbol).connection_prelude
    end
  end

  def test_lib_name_marker_follows_the_official_suffix_convention
    # The marker is what redis-client interpolates into its own (transient) pair,
    # `lib-name=redis-client(redis-rb_v<version>)` — the identity the server keeps if the override
    # ever fails to apply. The Redis docs recommend suffixes match
    # `(?<custom-name>[ -~]+)[ -~]v(?<custom-version>[\d\.]+)`; see
    # https://redis.io/docs/latest/commands/client-setinfo/
    marker = Redis::LibIdentity.driver_info
    match = /\A(?<custom_name>[ -~]+)[ -~]v(?<custom_version>[\d.]+)\z/.match(marker)

    refute_nil match, "#{marker.inspect} does not follow the <name>_v<version> suffix convention"
    assert_equal "redis-rb", match[:custom_name]
    assert_equal Redis::VERSION, match[:custom_version]
  end

  def test_lib_name_can_be_disabled_with_driver_info_false
    # The opt-out for peers that can't tolerate unknown commands in the handshake: `false` gates
    # off both redis-rb's SETINFO pair and redis-client's own, restoring the pre-identification
    # prelude (empty under RESP2 with no auth and db 0; just the handshake under RESP3).
    config = Redis::Client.config(protocol: 2, driver_info: false)

    assert_empty config.connection_prelude

    resp3 = Redis::Client.config(driver_info: false)

    assert_equal [["HELLO", "3"]], resp3.connection_prelude
  end

  def test_lib_name_reported_for_sentinel_clients
    # `RedisClient::SentinelConfig` is not a `RedisClient::Config` subclass, so it needs its own
    # prepend. Nothing else in the suite would notice if that were dropped.
    refute_operator RedisClient::SentinelConfig, :<, RedisClient::Config

    config = Redis::Client.sentinel(name: "mymaster", sentinels: [{ host: "127.0.0.1", port: 26_379 }])

    assert_equal "redis-rb", prelude_setinfo(config, "LIB-NAME")
    assert_equal Redis::VERSION, prelude_setinfo(config, "LIB-VER")

    downstream = Redis::Client.sentinel(
      name: "mymaster", sentinels: [{ host: "127.0.0.1", port: 26_379 }], driver_info: "my-gem-1.0"
    )

    assert_equal "redis-rb(my-gem-1.0)", prelude_setinfo(downstream, "LIB-NAME")

    # The connections to the sentinel processes themselves (master resolution, failover polling)
    # are plain RedisClient::Config objects carrying the same driver_info, so they identify too.
    config.sentinels.each do |sentinel_config|
      assert_equal "redis-rb", prelude_setinfo(sentinel_config, "LIB-NAME")
    end
  end

  def test_lib_name_does_not_affect_plain_redis_client_configs
    # Loading redis-rb must not change what applications using `redis-client` directly report.
    plain = RedisClient.config(port: PORT)
    refute(
      plain.connection_prelude.any? { |c| c[0] == "CLIENT" && c[1] == "SETINFO" },
      "a plain redis-client config must not issue CLIENT SETINFO"
    )

    # Including when their own `driver_info` happens to start with redis-rb's marker: recognising
    # the marker by prefix alone would silently replace their identity with ours.
    {
      "other-gem-1.0" => "redis-client(other-gem-1.0)",
      "redis-rb-extras-1.0" => "redis-client(redis-rb-extras-1.0)",
      "redis-rb_viewer-1.0" => "redis-client(redis-rb_viewer-1.0)",
      "redis-rb_v#{Redis::VERSION}-fork" => "redis-client(redis-rb_v#{Redis::VERSION}-fork)",
    }.each do |driver_info, expected|
      config = RedisClient.config(port: PORT, driver_info: driver_info)

      assert_equal expected, prelude_setinfo(config, "LIB-NAME"), "for driver_info: #{driver_info.inspect}"
      assert_equal RedisClient::VERSION, prelude_setinfo(config, "LIB-VER"), "for driver_info: #{driver_info.inspect}"
    end
  end

  def test_time
    # Test that the difference between the time that Ruby reports and the time
    # that Redis reports is minimal (prevents the test from being racy).
    rv = r.time

    redis_usec = rv[0] * 1_000_000 + rv[1]
    ruby_usec = Integer(Time.now.to_f * 1_000_000)

    assert((ruby_usec - redis_usec).abs < 500_000)
  end

  def test_connection_timeout
    opts = OPTIONS.merge(host: "10.255.255.254", connect_timeout: 0.1, timeout: 5.0)
    start_time = Time.now
    assert_raises Redis::CannotConnectError do
      Redis.new(opts).ping
    end
    assert((Time.now - start_time) <= opts[:timeout])
  end

  def test_missing_socket
    opts = { path: '/missing.sock' }
    assert_raises Redis::CannotConnectError do
      Redis.new(opts).ping
    end
  end

  def close_on_ping(seq, options = {}, &block)
    @request = 0

    command = lambda do
      idx = @request
      @request += 1

      rv = "+%d" % idx
      rv = nil if seq.include?(idx)
      rv
    end

    redis_mock({ ping: command }, { timeout: 0.1 }.merge(options), &block)
  end

  def test_retry_by_default
    close_on_ping([0]) do |redis|
      assert_equal "1", redis.ping
    end
  end

  def test_dont_retry_when_wrapped_in_without_reconnect
    close_on_ping([0]) do |redis|
      assert_raises Redis::ConnectionError do
        redis.without_reconnect do
          redis.ping
        end
      end
    end
  end

  def test_retry_only_once_when_read_raises_econnreset
    close_on_ping([0, 1]) do |redis|
      assert_raises Redis::ConnectionError do
        redis.ping
      end

      assert !redis._client.connected?
    end
  end

  def test_retry_with_custom_reconnect_attempts
    close_on_ping([0, 1], reconnect_attempts: 2) do |redis|
      assert_equal "2", redis.ping
    end
  end

  def test_retry_with_custom_reconnect_attempts_can_still_fail
    close_on_ping([0, 1, 2], reconnect_attempts: 2) do |redis|
      assert_raises Redis::ConnectionError do
        redis.ping
      end

      assert !redis._client.connected?
    end
  end

  def test_retry_with_custom_reconnect_attempts_and_exponential_backoff
    close_on_ping([0, 1, 2], reconnect_attempts: [0.01, 0.02, 0.04]) do |redis|
      redis._client.config.expects(:sleep).with(0.01).returns(true)
      redis._client.config.expects(:sleep).with(0.02).returns(true)
      redis._client.config.expects(:sleep).with(0.04).returns(true)

      assert_equal "3", redis.ping
    end
  end

  def test_retry_pipeline_first_command
    close_on_ping([0]) do |redis|
      results = redis.pipelined do |pipeline|
        pipeline.ping
      end
      assert_equal ["1"], results
    end
  end

  def close_on_connection(seq, &block)
    @n = 0

    read_command = lambda do |session|
      Array.new(session.gets[1..-3].to_i) do
        bytes = session.gets[1..-3].to_i
        arg = session.read(bytes)
        session.read(2) # Discard \r\n
        arg
      end
    end

    # Consume and ack the whole connection prelude before deciding whether to go silent, so that
    # the simulated failure lands on the payload write rather than while connecting. The prelude
    # shape depends on the protocol (RESP2 opens with SELECT, RESP3 with HELLO) and on
    # CLIENT SETINFO, so derive its length from a config built the same way as the mock's client.
    # redis-client accepts any non-error reply to these.
    prelude_size = Redis::Client.config(**OPTIONS, protocol: PROTOCOL).connection_prelude.size

    handler = lambda do |session|
      n = @n
      @n += 1

      prelude_size.times do
        read_command.call(session)
        session.write("+OK\r\n")
      end
      unless seq.include?(n)
        session.write("+#{n}\r\n") while read_command.call(session)
      end
    end

    redis_mock_with_handler(handler, &block)
  end

  def test_retry_on_write_error_by_default
    close_on_connection([0]) do |redis|
      assert_equal "1", redis._client.call_v(["x" * 128 * 1024])
    end
  end

  def test_dont_retry_on_write_error_when_wrapped_in_without_reconnect
    # FIXME(RESP3): the RESP2-shaped write-error mock in close_on_connection doesn't reproduce the
    # same failure point under RESP3's longer HELLO-first prelude, so the simulated connection-0
    # failure isn't surfaced here (the retry sibling test does pass under RESP3). Needs a
    # RESP3-aware write-error mock; the no-retry semantics themselves are protocol-independent.
    skip("close_on_connection write-error mock is RESP2-shaped") if PROTOCOL == 3

    close_on_connection([0]) do |redis|
      assert_raises Redis::ConnectionError do
        redis.without_reconnect do
          redis._client.call_v(["x" * 128 * 1024])
        end
      end
    end
  end

  def test_connecting_to_unix_domain_socket
    Redis.new(OPTIONS.merge(path: ENV.fetch("REDIS_SOCKET_PATH"))).ping
  end

  def test_bubble_timeout_without_retrying
    serv = TCPServer.new("127.0.0.1", 0)
    port = serv.addr[1]

    # Complete the RESP3 connection handshake/prelude (we default to RESP3) so the connection is
    # established, then never reply to PING so the read timeout happens on the command itself.
    server_thread = Thread.new do
      session = serv.accept
      io = RedisClient::RubyConnection::BufferedIO.new(session, read_timeout: 5, write_timeout: 5)
      loop do
        command = RedisClient::RESP3.load(io)
        case command.first.upcase
        when "HELLO"
          session.write("%1\r\n$5\r\nproto\r\n:3\r\n")
        when "PING"
          sleep # never reply, so the client read times out on the command
        else
          session.write("+OK\r\n") # ack the rest of the prelude (CLIENT SETINFO, ...)
        end
      end
    rescue StandardError
      nil
    ensure
      session&.close
    end

    # reconnect_attempts: 0 so the read timeout bubbles immediately instead of being retried.
    redis = Redis.new(port: port, timeout: 0.1, reconnect_attempts: 0)

    assert_raises(Redis::TimeoutError) do
      redis.ping
    end
  ensure
    server_thread&.kill
    serv&.close
  end

  def test_client_options
    redis = Redis.new(OPTIONS.merge(host: "host", port: 1234, db: 1))

    assert_equal "host", redis._client.host
    assert_equal 1234, redis._client.port
    assert_equal 1, redis._client.db
  end

  def test_resolves_localhost
    Redis.new(OPTIONS.merge(host: 'localhost')).ping
  end

  class << self
    def af_family_supported(af_type)
      hosts = {
        Socket::AF_INET => "127.0.0.1",
        Socket::AF_INET6 => "::1"
      }

      begin
        s = Socket.new(af_type, Socket::SOCK_STREAM, 0)
        begin
          tries = 5
          begin
            sa = Socket.pack_sockaddr_in(Random.rand(1024..64_099), hosts[af_type])
            s.bind(sa)
          rescue Errno::EADDRINUSE => e
            # On JRuby (9.1.15.0), if IPv6 is globally disabled on the system,
            # we get an EADDRINUSE with belows message.
            return if e.message =~ /Protocol family unavailable/

            tries -= 1
            retry if tries > 0

            raise
          end
          yield
        rescue Errno::EADDRNOTAVAIL
        ensure
          s.close
        end
      rescue Errno::ESOCKTNOSUPPORT
      end
    end
  end

  def af_test(host)
    commands = {
      ping: ->(*_) { "+pong" }
    }

    redis_mock(commands, host: host, &:ping)
  end

  af_family_supported(Socket::AF_INET) do
    def test_connect_ipv4
      af_test("127.0.0.1")
    end
  end

  af_family_supported(Socket::AF_INET6) do
    def test_connect_ipv6
      af_test("::1")
    end
  end

  def test_can_be_duped_to_create_a_new_connection
    r2 = r.dup
    r2.ping

    # dup must open its own independent connection rather than share r's socket. Asserting on the
    # per-connection CLIENT ID is deterministic; the previous check against the global
    # connected_clients counter was racy, since other clients opening/closing on the shared server
    # could offset the expected +1 between the two INFO reads.
    refute_equal r.call("CLIENT", "ID"), r2.call("CLIENT", "ID")
  ensure
    r2&.close
  end

  def test_reconnect_on_readonly_errors
    tcp_server = TCPServer.new("127.0.0.1", 0)
    tcp_server.setsockopt(Socket::SOL_SOCKET, Socket::SO_REUSEADDR, true)
    port = tcp_server.addr[1]

    server_thread = Thread.new do
      session = tcp_server.accept
      io = RedisClient::RubyConnection::BufferedIO.new(session, read_timeout: 1, write_timeout: 1)
      loop do
        command = RedisClient::RESP3.load(io)
        case command.first.upcase
        when "HELLO"
          # Accept the RESP3 handshake (we default to RESP3) by replying with a minimal map.
          session.write("%1\r\n$5\r\nproto\r\n:3\r\n")
        when "PING"
          session.write("+PONG\r\n")
        when "SET"
          session.write("-READONLY You can't write against a read only replica.\r\n")
        else
          # Ack the rest of the connection prelude (CLIENT SETINFO, etc.).
          session.write("+OK\r\n")
        end
      end
    rescue StandardError
      nil
    ensure
      session&.close
    end

    redis = Redis.new(host: "127.0.0.1", port: port, timeout: 2, reconnect_attempts: 0)
    assert_equal "PONG", redis.ping

    assert_raises Redis::ReadOnlyError do
      redis.set("foo", "bar")
    end

    refute_predicate redis, :connected?
  ensure
    server_thread&.kill
  end
end
