# frozen_string_literal: true

require "json"

module QuicProQuo
  # High-level quicproquo client for Ruby.
  #
  # Wraps +libquicproquo_ffi+ via the +ffi+ gem.
  #
  #   client = QuicProQuo::Client.new("127.0.0.1:5001", ca_cert: "ca.pem")
  #   client.login("alice", "secret")
  #   client.send("bob", "hello")
  #   messages = client.receive(timeout_ms: 5000)
  #   client.disconnect
  #
  # Or use the block form for automatic cleanup:
  #
  #   QuicProQuo::Client.open("127.0.0.1:5001", ca_cert: "ca.pem") do |c|
  #     c.login("alice", "secret")
  #     c.send("bob", "hello")
  #   end
  class Client
    # Connect to a quicproquo server.
    #
    # @param server [String] Server address as +host:port+.
    # @param ca_cert [String] Path to PEM-encoded CA certificate.
    # @param server_name [String] TLS SNI server name (defaults to host).
    # @raise [ConnectionError] if the connection fails.
    def initialize(server, ca_cert:, server_name: nil)
      sn = server_name || server.split(":").first
      @handle = FFIBindings.qpq_connect(server, ca_cert, sn)
      raise ConnectionError, "qpq_connect failed for #{server}" if @handle.null?
    end

    # Connect and yield the client, disconnecting when the block returns.
    def self.open(server, **opts)
      client = new(server, **opts)
      begin
        yield client
      ensure
        client.disconnect
      end
    end

    # Authenticate with OPAQUE credentials.
    #
    # @param username [String]
    # @param password [String]
    # @raise [AuthError] on bad credentials.
    def login(username, password)
      check_connected!
      code = FFIBindings.qpq_login(@handle, username, password)
      check_status!(code)
    end

    # Send a message to a recipient by username.
    #
    # @param recipient [String] Recipient username.
    # @param message [String] Message payload (UTF-8 string or binary).
    def send(recipient, message)
      check_connected!
      msg_bytes = message.encode("BINARY")
      buf = FFI::MemoryPointer.new(:uint8, msg_bytes.bytesize)
      buf.put_bytes(0, msg_bytes)
      code = FFIBindings.qpq_send(@handle, recipient, buf, msg_bytes.bytesize)
      check_status!(code)
    end

    # Receive pending messages, blocking up to +timeout_ms+ milliseconds.
    #
    # @param timeout_ms [Integer] Timeout in milliseconds (default 5000).
    # @return [Array<String>] Message strings (UTF-8).
    def receive(timeout_ms: 5000)
      check_connected!
      out_ptr = FFI::MemoryPointer.new(:pointer)
      code = FFIBindings.qpq_receive(@handle, timeout_ms, out_ptr)
      check_status!(code)

      json_ptr = out_ptr.read_pointer
      return [] if json_ptr.null?

      json_str = json_ptr.read_string
      FFIBindings.qpq_free_string(json_ptr)
      JSON.parse(json_str)
    end

    # Disconnect from the server and release resources.
    def disconnect
      return if @handle.nil? || @handle.null?

      FFIBindings.qpq_disconnect(@handle)
      @handle = nil
    end

    # Whether the client is connected.
    def connected?
      !@handle.nil? && !@handle.null?
    end

    private

    def check_connected!
      raise NotConnectedError, "not connected" unless connected?
    end

    def check_status!(code)
      return if code == FFIBindings::QPQ_OK

      msg = FFIBindings.qpq_last_error(@handle) || "unknown error"
      case code
      when FFIBindings::QPQ_AUTH_FAILED
        raise AuthError, msg
      when FFIBindings::QPQ_TIMEOUT
        raise TimeoutError, msg
      when FFIBindings::QPQ_NOT_CONNECTED
        raise NotConnectedError, msg
      else
        raise Error, msg
      end
    end
  end
end
