//go:build darwin && !ios && !e2e_testing
// +build darwin,!ios,!e2e_testing

package udp

import (
	"context"
	"encoding/binary"
	"errors"
	"log/slog"
	"os"
	"time"

	"golang.org/x/sys/unix"
)

const (
	// netChangeSettleWindow is how long we keep swallowing routing messages after the first interesting one. A
	// single network change is never a single message, it is a burst: the link drops, addresses go away, new ones
	// arrive, routes get rewritten. Reporting part way through that just means reporting again.
	netChangeSettleWindow = time.Second

	// netChangeReadBuffer is sized well past any rt_msghdr plus its addresses. A short read would be discarded by
	// the kernel, so being generous here is how we avoid missing a message.
	netChangeReadBuffer = 4096
)

// watchNetworkChanges reports when the local network moves out from under us, so the listener can be rebound.
//
// Darwin scopes a udp socket to whatever interface it came up on. Move between networks and we keep sending out an
// interface that no longer has a route, which surfaces as an instant "no route to host" with no packet ever leaving
// the box. Rebind clears that, but only if something notices the change and calls it. iOS has always been told by
// the host app off NWPathMonitor. This is the equivalent for everything else that runs on darwin.
//
// The returned channel is buffered and coalescing: a send is dropped if one is already pending, since both mean the
// same thing to a reader. It is closed when ctx is cancelled or the routing socket fails, so a caller can simply
// range over it. Platforms whose sockets do not need rebinding return a nil channel and no error.
func watchNetworkChanges(ctx context.Context, l *slog.Logger) (<-chan struct{}, error) {
	sock, err := openRouteSocket()
	if err != nil {
		return nil, err
	}

	changes := make(chan struct{}, 1)

	go func() {
		defer close(changes)
		defer func() { _ = sock.Close() }()

		// Closing the socket is what unblocks the read in watchRouteSocket, so this turns cancellation into a
		// close. It is scoped to this call so it cannot outlive the watch it belongs to.
		done := make(chan struct{})
		defer close(done)
		go func() {
			select {
			case <-ctx.Done():
				_ = sock.Close()
			case <-done:
			}
		}()

		watchRouteSocket(l, sock, changes)
	}()

	return changes, nil
}

// watchRouteSocket blocks reading the routing socket, reporting once per settled burst of changes. It returns when
// the socket is closed, which is how cancellation gets us out of here.
func watchRouteSocket(l *slog.Logger, sock *os.File, changes chan<- struct{}) {
	buf := make([]byte, netChangeReadBuffer)

	for {
		n, err := sock.Read(buf)
		if err != nil {
			logRouteSocketError(l, err)
			return
		}

		if !isNetworkChange(buf[:n]) {
			continue
		}

		// Swallow the rest of the burst. The deadline is absolute and not extended by what arrives, so this always
		// ends after the settle window no matter how chatty the socket is. Changes that land after the window
		// simply produce another report, which is the correct outcome anyway.
		deadline := time.Now().Add(netChangeSettleWindow)
		for {
			if err = sock.SetReadDeadline(deadline); err != nil {
				logRouteSocketError(l, err)
				return
			}

			if _, err = sock.Read(buf); err != nil {
				if os.IsTimeout(err) {
					break
				}
				logRouteSocketError(l, err)
				return
			}
		}

		if err = sock.SetReadDeadline(time.Time{}); err != nil {
			logRouteSocketError(l, err)
			return
		}

		select {
		case changes <- struct{}{}:
		default:
			// One already pending, and a second "the network moved" tells the reader nothing new.
		}
	}
}

// logRouteSocketError reports a routing socket failure unless it is just us shutting the socket down.
func logRouteSocketError(l *slog.Logger, err error) {
	if errors.Is(err, os.ErrClosed) {
		return
	}

	l.Error("Error reading the routing socket, will no longer notice local network changes", "error", err)
}

// openRouteSocket returns the routing socket as a non blocking os.File. Going through os.File puts reads on the go
// poller, which buys us both a working read deadline and a Close that unblocks a read in progress.
func openRouteSocket() (*os.File, error) {
	fd, err := unix.Socket(unix.AF_ROUTE, unix.SOCK_RAW, unix.AF_UNSPEC)
	if err != nil {
		return nil, err
	}

	if err = unix.SetNonblock(fd, true); err != nil {
		_ = unix.Close(fd)
		return nil, err
	}

	return os.NewFile(uintptr(fd), "route"), nil
}

// isNetworkChange reports whether a routing message means our local addressing may have moved out from under us.
//
// We read the header instead of parsing the message because the type is the only part we need, and a full parse can
// fail on shapes we don't care about, which would turn "a message I can't parse" into "a change I missed".
// rt_msghdr, if_msghdr and ifa_msghdr all begin with the same three fields, so this is the same for every type.
func isNetworkChange(msg []byte) bool {
	if len(msg) < 4 {
		return false
	}

	// u_short msglen, u_char version, u_char type
	if int(binary.NativeEndian.Uint16(msg[0:2])) > len(msg) || msg[2] != unix.RTM_VERSION {
		return false
	}

	switch msg[3] {
	case unix.RTM_NEWADDR, unix.RTM_DELADDR, unix.RTM_IFINFO:
		// An address arrived or left, or a link changed state. Anything else on this socket is either a route
		// churning underneath us, which a rebind doesn't help with, or unrelated traffic.
		return true
	default:
		return false
	}
}
