local TEST_PORT = 9123

return require('lib/tap')(function (test)
  test("basic udp server and client (ipv4)", function (print, p, expect, uv)
    local server = uv.new_udp()
    assert(uv.udp_bind(server, "0.0.0.0", TEST_PORT))
    assert(uv.udp_recv_start(server, expect(function (err, data, addr, flags)
      p("server on recv", server, data, addr, flags)
      assert(not err, err)
      assert(data == "PING")
      uv.close(server, expect(function()
        p("server on close", server)
      end))
    end)))
    p{server=server}

    local client = uv.new_udp()
    local req = assert(uv.udp_send(client, "PING", "127.0.0.1", TEST_PORT, expect(function (err)
      p("client on send", client, err)
      assert(not err, err)
      uv.close(client, expect(function()
        p("client on close", client)
      end))
    end)))
    p{client=client,req=req}
  end)

  test("basic udp send from table", function (print, p, expect, uv)
    local sendData = {"P", "I", "NG"}
    local server = uv.new_udp()
    assert(uv.udp_bind(server, "0.0.0.0", TEST_PORT))
    assert(uv.udp_recv_start(server, expect(function (err, data, addr, flags)
      p("server on recv", server, data, addr, flags)
      assert(not err, err)
      assert(data == table.concat(sendData))
      uv.close(server, expect(function()
        p("server on close", server)
      end))
    end)))
    p{server=server}

    local client = uv.new_udp()
    local req = assert(uv.udp_send(client, sendData, "127.0.0.1", TEST_PORT, expect(function (err)
      p("client on send", client, err)
      assert(not err, err)
      uv.close(client, expect(function()
        p("client on close", client)
      end))
    end)))
    p{client=client,req=req}
  end)

  test("basic udp server and client (ipv6)", function (print, p, expect, uv)
    local server = uv.new_udp()
    local _, err = uv.udp_bind(server, "::1", TEST_PORT)
    if err then
      p("ipv6 unavailable", err)
      uv.close(server)
      return
    end
    assert(uv.udp_recv_start(server, expect(function (err, data, addr, flags)
      p("server on recv", server, data, addr, flags)
      assert(not err, err)
      assert(data == "PING")
      uv.close(server, expect(function()
        p("server on close", server)
      end))
    end)))
    p{server=server}

    local client = uv.new_udp()
    local req = assert(uv.udp_send(client, "PING", "::1", TEST_PORT, expect(function (err)
      p("client on send", client, err)
      assert(not err, err)
      uv.close(client, expect(function()
        p("client on close", client)
      end))
    end)))
    p{client=client,req=req}
  end)

  test("udp send args", function(print, p, expect, uv)
    local udp = uv.new_udp()

    local _, err = pcall(function() uv.udp_send(udp, "PING", 5, 5) end)
    print(assert(err))
    _, err = pcall(function() uv.udp_send(udp, "PING", "host", "port") end)
    print(assert(err))
    _, err = pcall(function() uv.udp_send(udp, "PING", "host", nil) end)
    print(assert(err))
    _, err = pcall(function() uv.udp_send(udp, "PING", nil, 5) end)
    print(assert(err))

    uv.close(udp)
  end, "1.27.0")

  test("udp connect", function(print, p, expect, uv)
    local server = uv.new_udp()
    local client = uv.new_udp()

    assert(uv.udp_bind(server, "0.0.0.0", TEST_PORT))
    local numRecvs = 0
    assert(uv.udp_recv_start(server, function (err, data, addr, flags)
      p("server on recv", server, data, addr, flags)
      assert(not err, err)
      -- nil data signifies nothing more to read
      if data ~= nil then
        assert(data == "PING", data)
        numRecvs = numRecvs + 1
        if numRecvs == 4 then
          uv.close(server, expect(function()
            p("server on close", server)
          end))
          uv.close(client, expect(function()
            p("client on close", client)
          end))
        end
      end
    end))
    p{server=server}

    assert(uv.udp_connect(client, "127.0.0.1", TEST_PORT))
    local _, err = uv.udp_connect(client, "8.8.8.8", TEST_PORT)
    assert(err and err:sub(1,7) == "EISCONN", err)

    local addr = assert(uv.udp_getpeername(client))
    p(addr)
    assert(addr.ip == "127.0.0.1")
    assert(addr.port == TEST_PORT)

    -- To send messages in connected UDP sockets addr must be NULL
    _, err = uv.udp_try_send(client, "PING", "127.0.0.1", TEST_PORT)
    assert(err and err:sub(1,7) == "EISCONN", err)

    local r = assert(uv.udp_try_send(client, "PING", nil, nil))
    assert(r == 4)

    _, err = uv.udp_try_send(client, "PING", "8.8.8.8", TEST_PORT)
    assert(err and err:sub(1,7) == "EISCONN", err)

    assert(uv.udp_connect(client, nil, nil))
    _, err = uv.udp_connect(client, nil, nil)
    assert(err and err:sub(1,8) == "ENOTCONN", err)

    _, err = uv.udp_getpeername(client)
    assert(err and err:sub(1,8) == "ENOTCONN", err)

    r = uv.udp_try_send(client, "PING", "127.0.0.1", TEST_PORT)
    assert(r == 4)
    _, err = uv.udp_try_send(client, "PING", nil, nil)
    assert(err and err:sub(1,12) == "EDESTADDRREQ", err)

    assert(uv.udp_connect(client, "127.0.0.1", TEST_PORT))
    _, err = uv.udp_send(client, "PING", "127.0.0.1", TEST_PORT, function()
      error("this send should fail")
    end)
    assert(err and err:sub(1,7) == "EISCONN", err)

    assert(uv.udp_send(client, "PING", nil, nil, expect(function(err)
      assert(not err, err)
      uv.udp_connect(client, nil, nil)
      _, err = uv.udp_send(client, "PING", nil, nil, function()
        error("this send should fail")
      end)
      assert(err and err:sub(1,12) == "EDESTADDRREQ", err)

      uv.udp_send(client, "PING", "127.0.0.1", TEST_PORT, expect(function(err)
        assert(not err, err)
      end))
    end)))
  end, "1.27.0")

  -- return a test function reusable for ipv4 and ipv6
  local function multicast_join_test(bind_addr, multicast_addr, interface_addr)
    return function(print, p, expect, uv)
      local uvVersionGEQ = require('lib/utils').uvVersionGEQ
      local timeout = uv.new_timer()
      local TIMEOUT_TIME = 1000

      local server = assert(uv.new_udp())
      assert(uv.udp_bind(server, bind_addr, TEST_PORT))
      local _, err, errname = uv.udp_set_membership(server, multicast_addr, interface_addr, "join")
      if errname == "ENODEV" then
        print("no multicast route, skipping")
        server:close()
        timeout:close()
        return
      elseif errname == "EADDRNOTAVAIL" and multicast_addr == "ff02::1" then
        -- OSX, BSDs, and some other platforms need %lo in their multicast/interface addr
        -- so try that instead
        multicast_addr = "ff02::1%lo0"
        interface_addr = "::1%lo0"
        assert(uv.udp_set_membership(server, multicast_addr, interface_addr, "join"))
      else
        assert(not err, err)
      end

      local client = assert(uv.new_udp())

      local recv_cb_called = 0
      local function recv_cb(err, data, addr, flags)
        assert(not err, err)
        p(data, addr)

        -- empty callback can happen, just return early
        if data == nil and addr == nil then
          return
        end

        assert(addr)
        assert(data == "PING")

        recv_cb_called = recv_cb_called + 1
        if recv_cb_called == 2 then
          -- note: because of this conditional close, the test will fail with an unclosed handle if recv_cb_called
          -- doesn't hit 2, so we don't need to expect(recv_cb) or assert recv_cb_called == 2
          server:close()
          timeout:close()
        else
          -- udp_set_source_membership added in 1.32.0
          if uvVersionGEQ("1.32.0") then
            local source_addr = addr.ip
            assert(server:set_membership(multicast_addr, interface_addr, "leave"))
            _, err, errname = server:set_source_membership(multicast_addr, interface_addr, source_addr, "join")
            -- handle 'EBUSY' error accidentally on macOS macOS CI, see https://github.com/luvit/luv/issues/704
            while errname == 'EBUSY' do
              _, err, errname = server:set_source_membership(multicast_addr, interface_addr, source_addr, "join")
            end
            if errname == "ENOSYS" then
              -- not all systems support set_source_membership, so rejoin the previous group and continue on
              assert(server:set_membership(multicast_addr, interface_addr, "join"))
            else
              assert(not err, err)
            end
          end
          assert(client:send("PING", multicast_addr, TEST_PORT, expect(function(err)
            assert(not err, err)
            client:close()
          end)))
        end
      end

      server:recv_start(recv_cb)

      assert(client:send("PING", multicast_addr, TEST_PORT, expect(function(err)
        -- EPERM here likely means that a firewall has denied the send, which
        -- can happen in some build/CI environments, e.g. the Fedora build system.
        -- Reproducible on Linux with iptables by doing:
        --  iptables --policy OUTPUT DROP
        --  iptables -A OUTPUT -s 127.0.0.1 -j ACCEPT
        -- and for ipv6:
        --  ip6tables --policy OUTPUT DROP
        --  ip6tables -A OUTPUT -s ::1 -j ACCEPT
        --
        -- EHOSTUNREACH was also observed on the macos CI runner
        -- TODO: Investigate EHOSTUNREACH more: https://github.com/luvit/luv/issues/781
        if err == "EPERM" or err == "EHOSTUNREACH" then
          print("send to multicast ip was likely denied by firewall, skipping")
          client:close()
          server:close()
          timeout:close()
          return
        end
        assert(not err, err)
      end)))

      -- some firewalls might reject incoming messages from multicast IPs,
      -- so we need a timeout to avoid hanging forever in that scenario
      timeout:start(TIMEOUT_TIME, 0, expect(function()
        print("timeout (could be caused by firewall settings)")
        client:close()
        server:close()
        timeout:close()
      end, 0))
    end
  end

  -- TODO This might be overkill, but the multicast
  -- tests seem to rely on external interfaces being
  -- available on some platforms. So, we use this to skip
  -- the tests when there are no relevant external interfaces
  -- available. Note: The Libuv multicast join test does use this
  -- same check for skipping the ipv6 test; we just expanded it to
  -- the ipv4 test as well.
  local function has_external_interface(uv, family)
    local addresses = assert(uv.interface_addresses())
    for _, vals in pairs(addresses) do
      for _, info in ipairs(vals) do
        if (not family or info.family == family) and not info.internal then
          return true
        end
      end
    end
    return false
  end

  test("udp multicast join ipv4", function(print, p, expect, uv)
    if not has_external_interface(uv, "inet") then
      print("no external ipv4 interface, skipping")
      return
    end
    local testfn = multicast_join_test("0.0.0.0", "239.255.0.1", nil)
    return testfn(print, p, expect, uv)
  end)

  test("udp multicast join ipv6", function(print, p, expect, uv)
    if not has_external_interface(uv, "inet6") then
      print("no external ipv6 interface, skipping")
      return
    end
    local testfn = multicast_join_test("::", "ff02::1", nil)
    return testfn(print, p, expect, uv)
  end)

  test("udp recvmmsg", function(print, p, expect, uv)
    local NUM_SENDS = 8
    local NUM_MSGS_PER_ALLOC = 4

    local recver = uv.new_udp({mmsgs = NUM_MSGS_PER_ALLOC})
    assert(recver:bind("0.0.0.0", TEST_PORT))

    local sender = uv.new_udp()

    local msgs_recved = 0
    local recv_cb = function(err, data, addr, flags)
      assert(not err, err)
      p(data, addr)

      -- empty callback can happen, just return early
      if data == nil and addr == nil then
        return
      end

      assert(addr)
      assert(data == "PING")

      msgs_recved = msgs_recved + 1
      if msgs_recved == NUM_SENDS then
        sender:close()
        recver:close()
      end
    end

    assert(recver:recv_start(recv_cb))

    for i=1,NUM_SENDS do
      assert(sender:try_send("PING", "127.0.0.1", TEST_PORT))
    end
  end, "1.39.0")

  local function udp_try_send2_test(should_connect)
    return function(print, p, expect, uv)
      -- If udp_connect is called on the sender, then addr cannot be specified in any messages.
      -- Otherwise, it *must* be specified in every message.
      local send_addr = { ip = "127.0.0.1", port = TEST_PORT }
      local msgs_to_send = {
        { data = "PING", addr = send_addr },
        { data = "PING", addr = send_addr },
        { data = "PING", addr = send_addr },
        -- Add in a message that goes to a different address to ensure that
        -- messages having a different address are allowed (when not connected)
        { data = "PING", addr = { ip = "127.0.0.1", port = TEST_PORT + 1 } },
        { data = { "P", "I", "N", "G" }, addr = send_addr },
      }
      local expected_msgs = #msgs_to_send
      if not should_connect then
        expected_msgs = expected_msgs - 1
      end
      -- Remove addr from the messages if we are calling udp_connect
      if should_connect then
        for _,v in ipairs(msgs_to_send) do
          v.addr = nil
        end
      end

      local recver = uv.new_udp()
      assert(recver:bind("0.0.0.0", TEST_PORT))

      local sender = uv.new_udp()
      if should_connect then
        assert(uv.udp_connect(sender, send_addr.ip, send_addr.port))
      else
        -- bind must be called; try_send2 doesn't automatically bind
        assert(sender:bind("0.0.0.0", TEST_PORT+2))
      end

      local msgs_recved = 0
      local recv_cb = function(err, data, addr, flags)
        assert(not err, err)
        p(data, addr)

        -- empty callback can happen, just return early
        if data == nil and addr == nil then
          return
        end

        assert(addr)
        assert(data == "PING")

        msgs_recved = msgs_recved + 1
        if msgs_recved == expected_msgs then
          sender:close()
          recver:close()
        end
      end

      assert(recver:recv_start(recv_cb))
      while #msgs_to_send > 0 do
        local num_sent = assert(sender:try_send2(msgs_to_send))
        print('sent '..num_sent..' out of '..#msgs_to_send..', expecting '..expected_msgs..' to be received')
        for _=1,num_sent do
          table.remove(msgs_to_send, 1)
        end
      end
    end
  end

  test("udp try_send2 not connected", function(print, p, expect, uv)
    local testfn = udp_try_send2_test(false)
    return testfn(print, p, expect, uv)
  end, "1.50.0")

  test("udp try_send2 connected", function(print, p, expect, uv)
    local testfn = udp_try_send2_test(true)
    return testfn(print, p, expect, uv)
  end, "1.50.0")
end)
