// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause

package ts2021

import (
	"bytes"
	"cmp"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"log"
	"math"
	"net"
	"net/http"
	"net/netip"
	"net/url"
	"sync"
	"time"

	"tailscale.com/control/controlhttp"
	"tailscale.com/health"
	"tailscale.com/net/dnscache"
	"tailscale.com/net/netmon"
	"tailscale.com/net/tsdial"
	"tailscale.com/tailcfg"
	"tailscale.com/tstime"
	"tailscale.com/types/key"
	"tailscale.com/types/logger"
	"tailscale.com/util/mak"
	"tailscale.com/util/set"
)

// Client provides a http.Client to connect to tailcontrol over
// the ts2021 protocol.
type Client struct {
	// Client is an HTTP client to talk to the coordination server.
	// It automatically makes a new Noise connection as needed.
	*http.Client

	logf      logger.Logf // non-nil
	opts      ClientOpts
	host      string // the host part of serverURL
	httpPort  string // the default port to dial
	httpsPort string // the fallback Noise-over-https port or empty if none

	// mu protects the following
	mu       sync.Mutex
	closed   bool
	connPool set.HandleSet[*Conn] // all live connections
}

// ClientOpts contains options for the [NewClient] function. All fields are
// required unless otherwise specified.
type ClientOpts struct {
	// ServerURL is the URL of the server to connect to.
	ServerURL string

	// PrivKey is this node's private key.
	PrivKey key.MachinePrivate

	// ServerPubKey is the public key of the server.
	// It is of the form https://<host>:<port> (no trailing slash).
	ServerPubKey key.MachinePublic

	// Dialer's SystemDial function is used to connect to the server.
	Dialer *tsdial.Dialer

	// Optional fields follow

	// Logf is the log function to use.
	// If nil, log.Printf is used.
	Logf logger.Logf

	// NetMon is the network monitor that will be used to get the
	// network interface state. This field can be nil; if so, the current
	// state will be looked up dynamically.
	NetMon *netmon.Monitor

	// DNSCache is the caching Resolver to use to connect to the server.
	//
	// This field can be nil.
	DNSCache *dnscache.Resolver

	// HealthTracker, if non-nil, is the health tracker to use.
	HealthTracker *health.Tracker

	// DialPlan, if set, is a function that should return an explicit plan
	// on how to connect to the server.
	DialPlan func() *tailcfg.ControlDialPlan

	// ProtocolVersion, if non-zero, specifies an alternate
	// protocol version to use instead of the default,
	// of [tailcfg.CurrentCapabilityVersion].
	ProtocolVersion uint16
}

// NewClient returns a new noiseClient for the provided server and machine key.
//
// netMon may be nil, if non-nil it's used to do faster interface lookups.
// dialPlan may be nil
func NewClient(opts ClientOpts) (*Client, error) {
	logf := opts.Logf
	if logf == nil {
		logf = log.Printf
	}
	if opts.ServerURL == "" {
		return nil, errors.New("ServerURL is required")
	}
	if opts.PrivKey.IsZero() {
		return nil, errors.New("PrivKey is required")
	}
	if opts.ServerPubKey.IsZero() {
		return nil, errors.New("ServerPubKey is required")
	}
	if opts.Dialer == nil {
		return nil, errors.New("Dialer is required")
	}

	u, err := url.Parse(opts.ServerURL)
	if err != nil {
		return nil, fmt.Errorf("invalid ClientOpts.ServerURL: %w", err)
	}
	if u.Scheme != "http" && u.Scheme != "https" {
		return nil, errors.New("invalid ServerURL scheme, must be http or https")
	}

	httpPort, httpsPort := "80", "443"
	addr, _ := netip.ParseAddr(u.Hostname())
	isPrivateHost := addr.IsPrivate() || addr.IsLoopback() || u.Hostname() == "localhost"
	if port := u.Port(); port != "" {
		// If there is an explicit port specified, entirely rely on the scheme,
		// unless it's http with a private host in which case we never try using HTTPS.
		if u.Scheme == "https" {
			httpPort = ""
			httpsPort = port
		} else if u.Scheme == "http" {
			httpPort = port
			httpsPort = "443"
			if isPrivateHost {
				logf("setting empty HTTPS port with http scheme and private host %s", u.Hostname())
				httpsPort = ""
			}
		}
	} else if u.Scheme == "http" && isPrivateHost {
		// Whenever the scheme is http and the hostname is an IP address, do not set the HTTPS port,
		// as there cannot be a TLS certificate issued for an IP, unless it's a public IP.
		httpPort = "80"
		httpsPort = ""
	}

	np := &Client{
		opts:      opts,
		host:      u.Hostname(),
		httpPort:  httpPort,
		httpsPort: httpsPort,
		logf:      logf,
	}

	tr := &http.Transport{
		Protocols:       new(http.Protocols),
		MaxConnsPerHost: 1,
	}
	// We force only HTTP/2 for this transport, which is what the control server
	// speaks inside the ts2021 Noise encryption. But Go doesn't know about that,
	// so we use "SetUnencryptedHTTP2" even though it's actually encrypted.
	tr.Protocols.SetUnencryptedHTTP2(true)
	tr.DialTLSContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
		return np.dial(ctx)
	}

	np.Client = &http.Client{Transport: tr}
	return np, nil
}

// Close closes all the underlying noise connections.
// It is a no-op and returns nil if the connection is already closed.
func (nc *Client) Close() error {
	nc.mu.Lock()
	live := nc.connPool
	nc.closed = true
	nc.connPool = nil // stop noteConnClosed from mutating it as we loop over it (in live) below
	nc.mu.Unlock()

	for _, c := range live {
		c.Close()
	}
	nc.Client.CloseIdleConnections()

	return nil
}

// dial opens a new connection to tailcontrol, fetching the server noise key
// if not cached.
func (nc *Client) dial(ctx context.Context) (*Conn, error) {
	if tailcfg.CurrentCapabilityVersion > math.MaxUint16 {
		// Panic, because a test should have started failing several
		// thousand version numbers before getting to this point.
		panic("capability version is too high to fit in the wire protocol")
	}

	var dialPlan *tailcfg.ControlDialPlan
	if nc.opts.DialPlan != nil {
		dialPlan = nc.opts.DialPlan()
	}

	// If we have a dial plan, then set our timeout as slightly longer than
	// the maximum amount of time contained therein; we assume that
	// explicit instructions on timeouts are more useful than a single
	// hard-coded timeout.
	//
	// The default value of 5 is chosen so that, when there's no dial plan,
	// we retain the previous behaviour of 10 seconds end-to-end timeout.
	timeoutSec := 5.0
	if dialPlan != nil {
		for _, c := range dialPlan.Candidates {
			if v := c.DialStartDelaySec + c.DialTimeoutSec; v > timeoutSec {
				timeoutSec = v
			}
		}
	}

	// After we establish a connection, we need some time to actually
	// upgrade it into a Noise connection. With a ballpark worst-case RTT
	// of 1000ms, give ourselves an extra 5 seconds to complete the
	// handshake.
	timeoutSec += 5

	// Be extremely defensive and ensure that the timeout is in the range
	// [5, 60] seconds (e.g. if we accidentally get a negative number).
	if timeoutSec > 60 {
		timeoutSec = 60
	} else if timeoutSec < 5 {
		timeoutSec = 5
	}

	timeout := time.Duration(timeoutSec * float64(time.Second))
	ctx, cancel := context.WithTimeout(ctx, timeout)
	defer cancel()

	chd := &controlhttp.Dialer{
		Hostname:        nc.host,
		HTTPPort:        nc.httpPort,
		HTTPSPort:       cmp.Or(nc.httpsPort, controlhttp.NoPort),
		MachineKey:      nc.opts.PrivKey,
		ControlKey:      nc.opts.ServerPubKey,
		ProtocolVersion: cmp.Or(nc.opts.ProtocolVersion, uint16(tailcfg.CurrentCapabilityVersion)),
		Dialer:          nc.opts.Dialer.SystemDial,
		DNSCache:        nc.opts.DNSCache,
		DialPlan:        dialPlan,
		Logf:            nc.logf,
		NetMon:          nc.opts.NetMon,
		HealthTracker:   nc.opts.HealthTracker,
		Clock:           tstime.StdClock{},
	}
	clientConn, err := chd.Dial(ctx)
	if err != nil {
		return nil, err
	}

	nc.mu.Lock()

	handle := set.NewHandle()
	ncc := NewConn(clientConn.Conn, func() { nc.noteConnClosed(handle) })
	mak.Set(&nc.connPool, handle, ncc)

	if nc.closed {
		nc.mu.Unlock()
		ncc.Close() // Needs to be called without holding the lock.
		return nil, errors.New("noise client closed")
	}

	defer nc.mu.Unlock()
	return ncc, nil
}

// noteConnClosed notes that the *Conn with the given handle has closed and
// should be removed from the live connPool (which is usually of size 0 or 1,
// except perhaps briefly 2 during a network failure and reconnect).
func (nc *Client) noteConnClosed(handle set.Handle) {
	nc.mu.Lock()
	defer nc.mu.Unlock()
	nc.connPool.Delete(handle)
}

// post does a POST to the control server at the given path, JSON-encoding body.
// The provided nodeKey is an optional load balancing hint.
func (nc *Client) Post(ctx context.Context, path string, nodeKey key.NodePublic, body any) (*http.Response, error) {
	return nc.DoWithBody(ctx, "POST", path, nodeKey, body)
}

func (nc *Client) DoWithBody(ctx context.Context, method, path string, nodeKey key.NodePublic, body any) (*http.Response, error) {
	jbody, err := json.Marshal(body)
	if err != nil {
		return nil, err
	}
	req, err := http.NewRequestWithContext(ctx, method, "https://"+nc.host+path, bytes.NewReader(jbody))
	if err != nil {
		return nil, err
	}
	AddLBHeader(req, nodeKey)
	req.Header.Set("Content-Type", "application/json")
	return nc.Do(req)
}

// AddLBHeader adds the load balancer header to req if nodeKey is non-zero.
func AddLBHeader(req *http.Request, nodeKey key.NodePublic) {
	if !nodeKey.IsZero() {
		req.Header.Add(tailcfg.LBHeader, nodeKey.String())
	}
}
