package connection

import (
	"context"
	"crypto/tls"
	"fmt"
	"net"
	"net/netip"
	"runtime"
	"sync"

	"github.com/quic-go/quic-go"
	"github.com/rs/zerolog"

	"github.com/cloudflare/cloudflared/connection/dialopts"
)

var (
	portForConnIndex = make(map[uint8]int, 0)
	portMapMutex     sync.Mutex
)

func DialQuic(
	ctx context.Context,
	quicConfig *quic.Config,
	tlsConfig *tls.Config,
	edgeAddr netip.AddrPort,
	localAddr net.IP,
	connIndex uint8,
	logger *zerolog.Logger,
	opts dialopts.DialOpts,
) (quic.Connection, error) {
	udpConn, err := createUDPConnForConnIndex(connIndex, localAddr, edgeAddr, opts, logger)
	if err != nil {
		return nil, err
	}

	conn, err := quic.Dial(ctx, udpConn, net.UDPAddrFromAddrPort(edgeAddr), tlsConfig, quicConfig)
	if err != nil {
		// close the udp server socket in case of error connecting to the edge
		_ = udpConn.Close()
		return nil, &EdgeQuicDialError{Cause: err}
	}

	// wrap the session, so that the UDPConn is closed after session is closed.
	conn = &wrapCloseableConnQuicConnection{
		conn,
		udpConn,
	}
	return conn, nil
}

func createUDPConnForConnIndex(connIndex uint8, localIP net.IP, edgeIP netip.AddrPort, opts dialopts.DialOpts, logger *zerolog.Logger) (*net.UDPConn, error) {
	listenNetwork := "udp"
	// https://github.com/quic-go/quic-go/issues/3793 DF bit cannot be set for dual stack listener ("udp") on macOS,
	// to set the DF bit properly, the network string needs to be specific to the IP family.
	if runtime.GOOS == "darwin" {
		if edgeIP.Addr().Is4() {
			listenNetwork = "udp4"
		} else {
			listenNetwork = "udp6"
		}
	}

	// Probes skip port reuse entirely to avoid interfering with the main connection flow.
	// They use a random ephemeral port for each dial.
	if opts.SkipPortReuse {
		return net.ListenUDP(listenNetwork, &net.UDPAddr{IP: localIP, Port: 0})
	}

	portMapMutex.Lock()
	defer portMapMutex.Unlock()

	// if port was not set yet, it will be zero, so bind will randomly allocate one.
	if port, ok := portForConnIndex[connIndex]; ok {
		udpConn, err := net.ListenUDP(listenNetwork, &net.UDPAddr{IP: localIP, Port: port})
		// if there wasn't an error, or if port was 0 (independently of error or not, just return)
		if err == nil {
			return udpConn, nil
		}

		logger.Debug().Err(err).Msgf("Unable to reuse port %d for connIndex %d. Falling back to random allocation.", port, connIndex)
	}

	// if we reached here, then there was an error or port as not been allocated it.
	udpConn, err := net.ListenUDP(listenNetwork, &net.UDPAddr{IP: localIP, Port: 0})
	if err == nil {
		udpAddr, ok := (udpConn.LocalAddr()).(*net.UDPAddr)
		if !ok {
			return nil, fmt.Errorf("unable to cast to udpConn")
		}
		portForConnIndex[connIndex] = udpAddr.Port
	} else {
		delete(portForConnIndex, connIndex)
	}

	return udpConn, err
}

type wrapCloseableConnQuicConnection struct {
	quic.Connection
	udpConn *net.UDPConn
}

func (w *wrapCloseableConnQuicConnection) CloseWithError(errorCode quic.ApplicationErrorCode, reason string) error {
	err := w.Connection.CloseWithError(errorCode, reason)
	_ = w.udpConn.Close()

	return err
}
