package nettool

import (
	"fmt"
	"net"
	"net/netip"
	"net/url"
	"strconv"
	"strings"
)

const (
	emptySchemePanicMsg    = "multi URL: empty scheme. This is a programming error. Please open an issue at https://gitlab.com/gitlab-org/cluster-integration/gitlab-agent/-/issues"
	emptyHostPanicMsg      = "multi URL: no host. This is a programming error. Please open an issue at https://gitlab.com/gitlab-org/cluster-integration/gitlab-agent/-/issues"
	emptyAddressesPanicMsg = "multi URL: no addresses. This is a programming error. Please open an issue at https://gitlab.com/gitlab-org/cluster-integration/gitlab-agent/-/issues"
)

// MultiURL is an immutable URL that has scheme, port, and one or more IP addresses or a host.
// TLS host can also be supplied.
type MultiURL struct {
	// All fields are comparable so that the type is comparable itself.

	scheme    string
	addresses string // Should have been []netip.Addr, but it's a string to make the type comparable.
	host      string
	tlsHost   string
	port      uint16
}

func NewMultiURLForHost(scheme, host, tlsHost string, port uint16) MultiURL {
	if scheme == "" {
		panic(emptySchemePanicMsg)
	}
	if host == "" {
		panic(emptyHostPanicMsg)
	}
	return MultiURL{
		scheme:    scheme,
		addresses: "",
		host:      host,
		tlsHost:   tlsHost,
		port:      port,
	}
}

func NewMultiURLForAddresses(scheme, tlsHost string, port uint16, addresses []netip.Addr) MultiURL {
	if scheme == "" {
		panic(emptySchemePanicMsg)
	}
	if len(addresses) == 0 {
		panic(emptyAddressesPanicMsg)
	}
	return MultiURL{
		scheme:    scheme,
		addresses: joinAddresses(addresses...),
		host:      "",
		tlsHost:   tlsHost,
		port:      port,
	}
}

func (u MultiURL) String() string {
	var sb strings.Builder
	sb.WriteString(u.scheme)
	sb.WriteByte(':')
	for i, addr := range u.Addresses() {
		if i > 0 {
			sb.WriteByte(',')
		}
		sb.WriteString(addr.String())
	}
	if u.host != "" || u.tlsHost != "" { // be compatible with previous format.
		sb.WriteByte('/')
		sb.WriteString(u.host)
		sb.WriteByte('/')
		sb.WriteString(u.tlsHost)
	}
	sb.WriteByte('|')
	sb.WriteString(strconv.FormatUint(uint64(u.port), 10))
	return sb.String()
}

func (u MultiURL) Scheme() string {
	return u.scheme
}

func (u MultiURL) Addresses() []netip.Addr {
	return splitAddresses(u.addresses)
}

func (u MultiURL) Host() string {
	return u.host
}

func (u MultiURL) TLSHost() string {
	return u.tlsHost
}

func (u MultiURL) Port() uint16 {
	return u.port
}

func (u MultiURL) HostPort() string {
	return net.JoinHostPort(u.host, strconv.FormatUint(uint64(u.port), 10))
}

// ParseMultiURL parses addr according to the following opaque URL schema:
// scheme:ip[,ip]|port
func ParseMultiURL(addr string) (MultiURL, error) {
	scheme, rest, found := strings.Cut(addr, ":")
	if !found || scheme == "" {
		return MultiURL{}, fmt.Errorf("scheme not found in: %s", addr)
	}

	ipsHostTLSHostStr, portStr, found := strings.Cut(rest, "|")
	if !found {
		return MultiURL{}, fmt.Errorf("port not found in: %s", addr)
	}

	port, err := strconv.ParseUint(portStr, 10, 16)
	if err != nil {
		return MultiURL{}, fmt.Errorf("port in %s: %w", addr, err)
	}

	var host, tlsHost string
	ipsStr, hostTLSHostStr, found := strings.Cut(ipsHostTLSHostStr, "/")
	if found { // found host and tls host section
		host, tlsHost, found = strings.Cut(hostTLSHostStr, "/")
		if !found || (host == "" && tlsHost == "") { // shouldn't have slashes in the URL.
			return MultiURL{}, fmt.Errorf("invalid host/tlsHost in %s", addr)
		}
	}
	var ipsList []string
	if ipsStr != "" { // ipsStr == ipsHostTLSHostStr if strings.Cut() didn't find the first slash.
		ipsList = strings.Split(ipsStr, ",")
	}
	switch {
	case len(ipsList) > 0 && host != "":
		return MultiURL{}, fmt.Errorf("either IP(s) or host can be present, not both: %s", addr)
	case host != "":
		return NewMultiURLForHost(scheme, host, tlsHost, uint16(port)), nil
	case len(ipsList) > 0:
		addresses := make([]netip.Addr, 0, len(ipsList))
		for _, ipStr := range ipsList {
			ip, err := netip.ParseAddr(ipStr)
			if err != nil {
				return MultiURL{}, fmt.Errorf("IP parsing in %s: %w", addr, err)
			}
			addresses = append(addresses, ip)
		}
		return NewMultiURLForAddresses(scheme, tlsHost, uint16(port), addresses), nil
	default:
		return MultiURL{}, fmt.Errorf("no IPs and no host in: %s", addr)
	}
}

// ParseURL turns a URL into a MultiURL.
// See MultiURLFromURL.
func ParseURL(addr, tlsHost string) (MultiURL, error) {
	u, err := url.Parse(addr)
	if err != nil {
		return MultiURL{}, err
	}
	return MultiURLFromURL(u, tlsHost)
}

// MultiURLFromURL turns a URL into a MultiURL.
// The provided URL must:
// - not be opaque.
// - have a scheme.
// - host must be an IP address or a domain.
// - have a port.
// - hostname for TLS can be specified.
func MultiURLFromURL(u *url.URL, tlsHost string) (MultiURL, error) {
	if u.Scheme == "" {
		return MultiURL{}, fmt.Errorf("no scheme in: %s", u)
	}
	host, port, err := net.SplitHostPort(u.Host)
	if err != nil {
		return MultiURL{}, err
	}
	portNum, err := strconv.ParseUint(port, 10, 16)
	if err != nil {
		return MultiURL{}, fmt.Errorf("port in %s: %w", u.Host, err)
	}
	ip, err := netip.ParseAddr(host)
	if err != nil { // not an IP. Assume it's a hostname
		return NewMultiURLForHost(u.Scheme, host, tlsHost, uint16(portNum)), nil //nolint:nilerr
	}
	return NewMultiURLForAddresses(u.Scheme, tlsHost, uint16(portNum), []netip.Addr{ip}), nil
}

func joinAddresses(addresses ...netip.Addr) string {
	// We could use a slice, but then we'd have to do a string() cast, which would allocate the second time.
	// Instead, we are using the builder that would do an unsafe (but safe) conversion internally that doesn't allocate.
	var x strings.Builder
	x.Grow(len(addresses) * net.IPv6len)
	for _, addr := range addresses {
		ip16 := addr.As16()
		x.Write(ip16[:])
	}
	return x.String()
}

func splitAddresses(addresses string) []netip.Addr {
	l := len(addresses) / net.IPv6len
	res := make([]netip.Addr, 0, l)

	for n := range l {
		addr := [16]byte([]byte(addresses[n*net.IPv6len : (n+1)*net.IPv6len]))
		res = append(res, netip.AddrFrom16(addr).Unmap())
	}

	return res
}
