require "./ip_socket"

# A User Datagram Protocol (UDP) socket.
#
# UDP runs on top of the Internet Protocol (IP) and was developed for applications that do
# not require reliability, acknowledgement, or flow control features at the transport layer.
# This simple protocol provides transport layer addressing in the form of UDP ports and an
# optional checksum capability.
#
# UDP is a very simple protocol. Messages, so called datagrams, are sent to other hosts on
# an IP network without the need to set up special transmission channels or data paths
# beforehand. The UDP socket only needs to be opened for communication. It listens for
# incoming messages and sends outgoing messages on request.
#
# This implementation supports both IPv4 and IPv6 addresses. For IPv4 addresses you must use
# `Socket::Family::INET` family (default) or `Socket::Family::INET6` for IPv6 # addresses.
#
# NOTE: To use `UDPSocket`, you must explicitly import it with `require "socket"`
#
# Usage example:
#
# ```
# require "socket"
#
# # Create server
# server = UDPSocket.new
# server.bind "localhost", 1234
#
# # Create client and connect to server
# client = UDPSocket.new
# client.connect "localhost", 1234
#
# # Send a text message to server
# client.send "message"
#
# # Receive text message from client
# message, client_addr = server.receive
#
# # Close client and server
# client.close
# server.close
# ```
#
# The `send` methods may sporadically fail with `Socket::ConnectError` when sending datagrams
# to a non-listening server.
# Wrap with an exception handler to prevent raising. Example:
#
# ```
# begin
#   client.send(message, @destination)
# rescue ex : Socket::ConnectError
#   p ex.inspect
# end
# ```
class UDPSocket < IPSocket
  def initialize(family : Family = Family::INET)
    super(family, Type::DGRAM, Protocol::UDP)
  end

  # Receives a text message from the previously bound address.
  #
  # ```
  # require "socket"
  #
  # server = UDPSocket.new
  # server.bind("localhost", 1234)
  #
  # message, client_addr = server.receive
  # ```
  def receive(max_message_size = 512) : {String, IPAddress}
    address = nil
    message = String.new(max_message_size) do |buffer|
      bytes_read, address = @fd_lock.read do
        system_receive_from(Slice.new(buffer, max_message_size))
      end
      {bytes_read, 0}
    end
    {message, address.as(IPAddress)}
  end

  # Receives a binary message from the previously bound address.
  #
  # ```
  # require "socket"
  #
  # server = UDPSocket.new
  # server.bind "localhost", 1234
  #
  # message = Bytes.new(32)
  # bytes_read, client_addr = server.receive(message)
  # ```
  def receive(message : Bytes) : {Int32, IPAddress}
    bytes_read, address = @fd_lock.read { system_receive_from(message) }
    {bytes_read, address.as(IPAddress)}
  end

  # Reports whether transmitted multicast packets should be copied and sent
  # back to the originator.
  def multicast_loopback? : Bool
    case @family
    when Family::INET
      getsockopt_bool LibC::IP_MULTICAST_LOOP, LibC::IPPROTO_IP
    when Family::INET6
      getsockopt_bool LibC::IPV6_MULTICAST_LOOP, LibC::IPPROTO_IPV6
    else
      raise Socket::Error.new "Unsupported IP address family: #{@family}"
    end
  end

  # Sets whether transmitted multicast packets should be copied and sent back
  # to the originator, if the host has joined the multicast group.
  def multicast_loopback=(val : Bool)
    case @family
    when Family::INET
      setsockopt_bool LibC::IP_MULTICAST_LOOP, val, LibC::IPPROTO_IP
    when Family::INET6
      setsockopt_bool LibC::IPV6_MULTICAST_LOOP, val, LibC::IPPROTO_IPV6
    else
      raise Socket::Error.new "Unsupported IP address family: #{@family}"
    end
  end

  # Returns the current value of the `hoplimit` field on uni-cast packets.
  # Datagrams with a `hoplimit` of `1` are not forwarded beyond the local network.
  # Multicast datagrams with a `hoplimit` of `0` will not be transmitted on any
  # network, but may be delivered locally if the sending host belongs to the
  # destination group and multicast loopback is enabled.
  def multicast_hops : Int32
    case @family
    when Family::INET
      getsockopt LibC::IP_MULTICAST_TTL, 0, LibC::IPPROTO_IP
    when Family::INET6
      getsockopt LibC::IPV6_MULTICAST_HOPS, 0, LibC::IPPROTO_IPV6
    else
      raise Socket::Error.new "Unsupported IP address family: #{@family}"
    end
  end

  # The multicast hops option controls the `hoplimit` field on uni-cast packets.
  # If `-1` is specified, the kernel will use a default value.
  # If a value of `0` to `255` is specified, the packet will have the specified
  # value as `hoplimit`. Other values are considered invalid and `Socket::Error` will be raised.
  # Datagrams with a `hoplimit` of `1` are not forwarded beyond the local network.
  # Multicast datagrams with a `hoplimit` of `0` will not be transmitted on any
  # network, but may be delivered locally if the sending host belongs to the
  # destination group and multicast loopback is enabled.
  def multicast_hops=(val : Int)
    case @family
    when Family::INET
      setsockopt LibC::IP_MULTICAST_TTL, val, LibC::IPPROTO_IP
    when Family::INET6
      setsockopt LibC::IPV6_MULTICAST_HOPS, val, LibC::IPPROTO_IPV6
    else
      raise Socket::Error.new "Unsupported IP address family: #{@family}"
    end
    val
  end

  # For hosts with multiple interfaces, each multicast transmission is sent
  # from the primary network interface. This function overrides the default
  # IPv4 interface address for subsequent transmissions. Setting the interface
  # to `0.0.0.0` will select the default interface.
  # Raises `Socket::Error` unless the socket is IPv4 and an IPv4 address is provided.
  def multicast_interface(address : IPAddress)
    if @family == Family::INET
      addr = address.@addr
      if addr.is_a?(LibC::InAddr)
        setsockopt LibC::IP_MULTICAST_IF, addr, LibC::IPPROTO_IP
      else
        raise Socket::Error.new "Expecting an IPv4 interface address. Address provided: #{address.address}"
      end
    else
      raise Socket::Error.new "Unsupported IP address family: #{@family}. For use with IPv4 only"
    end
  end

  # For hosts with multiple interfaces, each multicast transmission is sent
  # from the primary network interface. This function overrides the default
  # IPv6 interface for subsequent transmissions. Setting the interface to
  # index `0` will select the default interface.
  def multicast_interface(index : UInt32)
    if @family == Family::INET6
      setsockopt LibC::IPV6_MULTICAST_IF, index, LibC::IPPROTO_IPV6
    else
      raise Socket::Error.new "Unsupported IP address family: #{@family}. For use with IPv6 only"
    end
  end

  # A host must become a member of a multicast group before it can receive
  # datagrams sent to the group.
  # Raises `Socket::Error` if an incompatible address is provided.
  def join_group(address : IPAddress)
    case @family
    when Family::INET
      group_modify(address, LibC::IP_ADD_MEMBERSHIP)
    when Family::INET6
      group_modify(address, LibC::IPV6_JOIN_GROUP)
    else
      raise Socket::Error.new "Unsupported IP address family: #{@family}"
    end
  end

  # Drops membership to the specified group. Memberships are automatically
  # dropped when the socket is closed or the process exits.
  # Raises `Socket::Error` if an incompatible address is provided.
  def leave_group(address : IPAddress)
    case @family
    when Family::INET
      group_modify(address, LibC::IP_DROP_MEMBERSHIP)
    when Family::INET6
      group_modify(address, LibC::IPV6_LEAVE_GROUP)
    else
      raise Socket::Error.new "Unsupported IP address family: #{@family}"
    end
  end

  private def group_modify(ip, operation)
    ip_addr = ip.@addr

    case @family
    when Family::INET
      if ip_addr.is_a?(LibC::InAddr)
        req = LibC::IpMreq.new
        req.imr_multiaddr = ip_addr

        setsockopt operation, req, LibC::IPPROTO_IP
      else
        raise Socket::Error.new "Expecting an IPv4 multicast address. Address provided: #{ip.address}"
      end
    when Family::INET6
      if ip_addr.is_a?(LibC::In6Addr)
        req = LibC::Ipv6Mreq.new
        req.ipv6mr_multiaddr = ip_addr

        setsockopt operation, req, LibC::IPPROTO_IPV6
      else
        raise Socket::Error.new "Expecting an IPv6 multicast address. Address provided: #{ip.address}"
      end
    else
      raise Socket::Error.new "Unsupported IP address family: #{@family}"
    end
  end
end
