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

package tshttpproxy

import (
	"context"
	"encoding/base64"
	"fmt"
	"log"
	"net/http"
	"net/url"
	"runtime"
	"strings"
	"sync"
	"syscall"
	"time"
	"unsafe"

	"github.com/alexbrainman/sspi/negotiate"
	"github.com/metacubex/tailscale/hostinfo"
	"github.com/metacubex/tailscale/syncs"
	"github.com/metacubex/tailscale/types/logger"
	"github.com/metacubex/tailscale/util/clientmetric"
	"github.com/metacubex/tailscale/util/cmpver"
	"github.com/metacubex/tailscale/util/wingoes"
	"golang.org/x/sys/windows"
)

func init() {
	sysProxyFromEnv = proxyFromWinHTTPOrCache
	sysAuthHeader = sysAuthHeaderWindows
}

var cachedProxy struct {
	sync.Mutex
	val *url.URL
}

// proxyErrorf is a rate-limited logger specifically for errors asking
// WinHTTP for the proxy information. We don't want to log about
// errors often, otherwise the log message itself will generate a new
// HTTP request which ultimately will call back into us to log again,
// forever. So for errors, we only log a bit.
var proxyErrorf = logger.RateLimitedFn(log.Printf, 10*time.Minute, 2 /* burst*/, 10 /* maxCache */)

var (
	metricSuccess              = clientmetric.NewCounter("winhttp_proxy_success")
	metricErrDetectionFailed   = clientmetric.NewCounter("winhttp_proxy_err_detection_failed")
	metricErrInvalidParameters = clientmetric.NewCounter("winhttp_proxy_err_invalid_param")
	metricErrDownloadScript    = clientmetric.NewCounter("winhttp_proxy_err_download_script")
	metricErrTimeout           = clientmetric.NewCounter("winhttp_proxy_err_timeout")
	metricErrOther             = clientmetric.NewCounter("winhttp_proxy_err_other")
)

func proxyFromWinHTTPOrCache(req *http.Request) (*url.URL, error) {
	if req.URL == nil {
		return nil, nil
	}
	urlStr := req.URL.String()

	ctx, cancel := context.WithTimeout(req.Context(), 5*time.Second)
	defer cancel()

	type result struct {
		proxy *url.URL
		err   error
	}
	resc := make(chan result, 1)
	go func() {
		proxy, err := proxyFromWinHTTP(ctx, urlStr)
		resc <- result{proxy, err}
	}()

	select {
	case res := <-resc:
		err := res.err
		if err == nil {
			metricSuccess.Add(1)
			cachedProxy.Lock()
			defer cachedProxy.Unlock()
			if was, now := fmt.Sprint(cachedProxy.val), fmt.Sprint(res.proxy); was != now {
				log.Printf("tshttpproxy: winhttp: updating cached proxy setting from %v to %v", was, now)
			}
			cachedProxy.val = res.proxy
			return res.proxy, nil
		}

		// See https://docs.microsoft.com/en-us/windows/win32/winhttp/error-messages
		const (
			ERROR_WINHTTP_AUTODETECTION_FAILED      = 12180
			ERROR_WINHTTP_UNABLE_TO_DOWNLOAD_SCRIPT = 12167
		)
		if err == syscall.Errno(ERROR_WINHTTP_AUTODETECTION_FAILED) {
			metricErrDetectionFailed.Add(1)
			setNoProxyUntil(10 * time.Second)
			return nil, nil
		}
		if err == windows.ERROR_INVALID_PARAMETER {
			metricErrInvalidParameters.Add(1)
			setNoProxyUntil(10 * time.Second)
			proxyErrorf("tshttpproxy: winhttp: GetProxyForURL(%q): ERROR_INVALID_PARAMETER [unexpected]", urlStr)
			return nil, nil
		}
		proxyErrorf("tshttpproxy: winhttp: GetProxyForURL(%q): %v/%#v", urlStr, err, err)
		if err == syscall.Errno(ERROR_WINHTTP_UNABLE_TO_DOWNLOAD_SCRIPT) {
			metricErrDownloadScript.Add(1)
			setNoProxyUntil(10 * time.Second)
			return nil, nil
		}
		metricErrOther.Add(1)
		return nil, err
	case <-ctx.Done():
		metricErrTimeout.Add(1)
		cachedProxy.Lock()
		defer cachedProxy.Unlock()
		proxyErrorf("tshttpproxy: winhttp: GetProxyForURL(%q): timeout; using cached proxy %v", urlStr, cachedProxy.val)
		return cachedProxy.val, nil
	}
}

func proxyFromWinHTTP(ctx context.Context, urlStr string) (proxy *url.URL, err error) {
	runtime.LockOSThread()
	defer runtime.UnlockOSThread()

	whi, err := httpOpen()
	if err != nil {
		proxyErrorf("winhttp: Open: %v", err)
		return nil, err
	}
	defer whi.Close()

	t0 := time.Now()
	v, err := whi.GetProxyForURL(urlStr)
	td := time.Since(t0).Round(time.Millisecond)
	if err := ctx.Err(); err != nil {
		log.Printf("tshttpproxy: winhttp: context canceled, ignoring GetProxyForURL(%q) after %v", urlStr, td)
		return nil, err
	}
	if err != nil {
		return nil, err
	}
	if v == "" {
		return nil, nil
	}
	// Discard all but first proxy value for now.
	if i := strings.Index(v, ";"); i != -1 {
		v = v[:i]
	}
	if !strings.HasPrefix(v, "https://") {
		v = "http://" + v
	}
	return url.Parse(v)
}

var userAgent = windows.StringToUTF16Ptr("Tailscale")

const (
	winHTTP_ACCESS_TYPE_DEFAULT_PROXY   = 0
	winHTTP_ACCESS_TYPE_AUTOMATIC_PROXY = 4
	winHTTP_AUTOPROXY_ALLOW_AUTOCONFIG  = 0x00000100
	winHTTP_AUTOPROXY_AUTO_DETECT       = 1
	winHTTP_AUTO_DETECT_TYPE_DHCP       = 0x00000001
	winHTTP_AUTO_DETECT_TYPE_DNS_A      = 0x00000002
)

// Windows 8.1 is actually Windows 6.3 under the hood. Yay, marketing!
const win8dot1Ver = "6.3"

// accessType is the flag we must pass to WinHttpOpen for proxy resolution
// depending on whether or not we're running Windows < 8.1
var accessType syncs.AtomicValue[uint32]

func getAccessFlag() uint32 {
	if flag, ok := accessType.LoadOk(); ok {
		return flag
	}
	var flag uint32
	if cmpver.Compare(hostinfo.GetOSVersion(), win8dot1Ver) < 0 {
		flag = winHTTP_ACCESS_TYPE_DEFAULT_PROXY
	} else {
		flag = winHTTP_ACCESS_TYPE_AUTOMATIC_PROXY
	}
	accessType.Store(flag)
	return flag
}

func httpOpen() (winHTTPInternet, error) {
	return winHTTPOpen(
		userAgent,
		getAccessFlag(),
		nil, /* WINHTTP_NO_PROXY_NAME */
		nil, /* WINHTTP_NO_PROXY_BYPASS */
		0,
	)
}

type winHTTPInternet windows.Handle

func (hi winHTTPInternet) Close() error {
	return winHTTPCloseHandle(hi)
}

// WINHTTP_AUTOPROXY_OPTIONS
// https://docs.microsoft.com/en-us/windows/win32/api/winhttp/ns-winhttp-winhttp_autoproxy_options
type winHTTPAutoProxyOptions struct {
	DwFlags                uint32
	DwAutoDetectFlags      uint32
	AutoConfigUrl          *uint16
	_                      uintptr
	_                      uint32
	FAutoLogonIfChallenged int32 // BOOL
}

// WINHTTP_PROXY_INFO
// https://docs.microsoft.com/en-us/windows/win32/api/winhttp/ns-winhttp-winhttp_proxy_info
type winHTTPProxyInfo struct {
	AccessType  uint32
	Proxy       *uint16
	ProxyBypass *uint16
}

type winHGlobal windows.Handle

func globalFreeUTF16Ptr(p *uint16) error {
	return globalFree((winHGlobal)(unsafe.Pointer(p)))
}

func (pi *winHTTPProxyInfo) free() {
	if pi.Proxy != nil {
		globalFreeUTF16Ptr(pi.Proxy)
		pi.Proxy = nil
	}
	if pi.ProxyBypass != nil {
		globalFreeUTF16Ptr(pi.ProxyBypass)
		pi.ProxyBypass = nil
	}
}

var getProxyForURLOpts = syncs.OnceValue(func() *winHTTPAutoProxyOptions {
	opts := &winHTTPAutoProxyOptions{
		DwFlags:           winHTTP_AUTOPROXY_AUTO_DETECT,
		DwAutoDetectFlags: winHTTP_AUTO_DETECT_TYPE_DHCP | winHTTP_AUTO_DETECT_TYPE_DNS_A,
	}
	// Support for the WINHTTP_AUTOPROXY_ALLOW_AUTOCONFIG flag was added in Windows 10, version 1703.
	//
	// Using it on earlier versions causes GetProxyForURL to fail with ERROR_INVALID_PARAMETER,
	// which prevents proxy detection and can lead to failures reaching the control server
	// on environments where a proxy is required.
	//
	// https://web.archive.org/web/20250529044903/https://learn.microsoft.com/en-us/windows/win32/api/winhttp/ns-winhttp-winhttp_autoproxy_options
	if wingoes.IsWin10BuildOrGreater(wingoes.Win10Build1703) {
		opts.DwFlags |= winHTTP_AUTOPROXY_ALLOW_AUTOCONFIG
	}
	return opts
})

func (hi winHTTPInternet) GetProxyForURL(urlStr string) (string, error) {
	var out winHTTPProxyInfo
	err := winHTTPGetProxyForURL(
		hi,
		windows.StringToUTF16Ptr(urlStr),
		getProxyForURLOpts(),
		&out,
	)
	if err != nil {
		return "", err
	}
	defer out.free()
	return windows.UTF16PtrToString(out.Proxy), nil
}

func sysAuthHeaderWindows(u *url.URL) (string, error) {
	spn := "HTTP/" + u.Hostname()
	creds, err := negotiate.AcquireCurrentUserCredentials()
	if err != nil {
		return "", fmt.Errorf("negotiate.AcquireCurrentUserCredentials: %w", err)
	}
	defer creds.Release()

	secCtx, token, err := negotiate.NewClientContext(creds, spn)
	if err != nil {
		return "", fmt.Errorf("negotiate.NewClientContext: %w", err)
	}
	defer secCtx.Release()

	return "Negotiate " + base64.StdEncoding.EncodeToString(token), nil
}
