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

package cli

import (
	"context"
	"encoding/json"
	"flag"
	"fmt"
	"io"
	"log"
	"math"
	"net/http"
	"net/netip"
	"sort"
	"strings"
	"time"

	"github.com/peterbourgon/ff/v3/ffcli"
	"tailscale.com/envknob"
	"tailscale.com/feature/buildfeatures"
	"tailscale.com/ipn"
	"tailscale.com/net/netcheck"
	"tailscale.com/net/netmon"
	"tailscale.com/net/portmapper/portmappertype"
	"tailscale.com/net/tlsdial"
	"tailscale.com/tailcfg"
	"tailscale.com/tstime"
	"tailscale.com/types/logger"
	"tailscale.com/util/eventbus"
	"tailscale.com/util/set"

	// The "netcheck" command also wants the portmapper linked.
	//
	// TODO: make that subcommand either hit LocalAPI for that info, or use a
	// tailscaled subcommand, to avoid making the CLI also link in the portmapper.
	// For now (2025-09-15), keep doing what we've done for the past five years and
	// keep linking it here.
	_ "tailscale.com/feature/condregister/portmapper"
)

var netcheckCmd = &ffcli.Command{
	Name:       "netcheck",
	ShortUsage: "tailscale netcheck",
	ShortHelp:  "Print an analysis of local network conditions",
	Exec:       runNetcheck,
	FlagSet:    netcheckFlagSet,
}

var netcheckFlagSet = func() *flag.FlagSet {
	fs := newFlagSet("netcheck")
	fs.StringVar(&netcheckArgs.format, "format", "", `output format; empty (for human-readable), "json" or "json-line"`)
	fs.DurationVar(&netcheckArgs.every, "every", 0, "if non-zero, do an incremental report with the given frequency")
	fs.BoolVar(&netcheckArgs.verbose, "verbose", false, "verbose logs")
	fs.StringVar(&netcheckArgs.bindAddress, "bind-address", "", "send and receive connectivity probes using this locally bound IP address; default: OS-assigned")
	fs.IntVar(&netcheckArgs.bindPort, "bind-port", 0, "send and receive connectivity probes using this UDP port; default: OS-assigned")
	return fs
}()

var netcheckArgs struct {
	format      string
	every       time.Duration
	verbose     bool
	bindAddress string
	bindPort    int
}

func runNetcheck(ctx context.Context, args []string) error {
	logf := logger.WithPrefix(log.Printf, "portmap: ")
	bus := eventbus.New()
	defer bus.Close()
	netMon, err := netmon.New(bus, logf)
	if err != nil {
		return err
	}

	var pm portmappertype.Client
	if buildfeatures.HasPortMapper {
		// Ensure that we close the portmapper after running a netcheck; this
		// will release any port mappings created.
		pm = portmappertype.HookNewPortMapper.Get()(logf, bus, netMon, nil, nil)
		defer pm.Close()
	}

	flagsProvided := set.Set[string]{}
	netcheckFlagSet.Visit(func(f *flag.Flag) {
		flagsProvided.Add(f.Name)
	})

	c := &netcheck.Client{
		NetMon:      netMon,
		PortMapper:  pm,
		UseDNSCache: false, // always resolve, don't cache
	}
	if netcheckArgs.verbose {
		c.Logf = logger.WithPrefix(log.Printf, "netcheck: ")
		c.Verbose = true
	} else {
		c.Logf = logger.Discard
	}

	if strings.HasPrefix(netcheckArgs.format, "json") {
		fmt.Fprintln(Stderr, "# Warning: this JSON format is not yet considered a stable interface")
	}

	bind, err := createNetcheckBindString(
		netcheckArgs.bindAddress,
		flagsProvided.Contains("bind-address"),
		netcheckArgs.bindPort,
		flagsProvided.Contains("bind-port"),
		envknob.String("TS_DEBUG_NETCHECK_UDP_BIND"))
	if err != nil {
		return err
	}

	if err := c.Standalone(ctx, bind); err != nil {
		fmt.Fprintln(Stderr, "netcheck: UDP test failure:", err)
	}

	dm, err := localClient.CurrentDERPMap(ctx)
	noRegions := dm != nil && len(dm.Regions) == 0
	if noRegions {
		log.Printf("No DERP map from tailscaled; using default.")
	}
	if err != nil || noRegions {
		hc := &http.Client{
			Transport: tlsdial.NewTransport(),
			Timeout:   10 * time.Second,
		}
		dm, err = prodDERPMap(ctx, hc)
		if err != nil {
			log.Println("Failed to fetch a DERP map, so netcheck cannot continue. Check your Internet connection.")
			return err
		}
	}
	for {
		t0 := time.Now()
		report, err := c.GetReport(ctx, dm, nil)
		d := time.Since(t0)
		if netcheckArgs.verbose {
			c.Logf("GetReport took %v; err=%v", d.Round(time.Millisecond), err)
		}
		if err != nil {
			return fmt.Errorf("netcheck: %w", err)
		}
		if err := printNetCheckReport(dm, report); err != nil {
			return err
		}
		if netcheckArgs.every == 0 {
			return nil
		}
		time.Sleep(netcheckArgs.every)
	}
}

func printNetCheckReport(dm *tailcfg.DERPMap, report *netcheck.Report) error {
	var j []byte
	var err error
	switch netcheckArgs.format {
	case "":
	case "json":
		j, err = json.MarshalIndent(report, "", "\t")
	case "json-line":
		j, err = json.Marshal(report)
	default:
		return fmt.Errorf("unknown output format %q", netcheckArgs.format)
	}
	if err != nil {
		return err
	}
	if j != nil {
		j = append(j, '\n')
		Stdout.Write(j)
		return nil
	}

	printf("\nReport:\n")
	printf("\t* Time: %v\n", report.Now.Local().Format(tstime.DateSpTimeNanoZ))
	printf("\t* UDP: %v\n", report.UDP)
	if report.GlobalV4.IsValid() {
		printf("\t* IPv4: yes, %s\n", report.GlobalV4)
	} else {
		printf("\t* IPv4: (no addr found)\n")
	}
	if report.GlobalV6.IsValid() {
		printf("\t* IPv6: yes, %s\n", report.GlobalV6)
	} else if report.IPv6 {
		printf("\t* IPv6: (no addr found)\n")
	} else if report.OSHasIPv6 {
		printf("\t* IPv6: no, but OS has support\n")
	} else {
		printf("\t* IPv6: no, unavailable in OS\n")
	}
	printf("\t* MappingVariesByDestIP: %v\n", report.MappingVariesByDestIP)
	printf("\t* PortMapping: %v\n", portMapping(report))
	if report.CaptivePortal != "" {
		printf("\t* CaptivePortal: %v\n", report.CaptivePortal)
	}

	// When DERP latency checking failed,
	// magicsock will try to pick the DERP server that
	// most of your other nodes are also using
	if len(report.RegionLatency) == 0 {
		printf("\t* Nearest DERP: unknown (no response to latency probes)\n")
	} else {
		if report.PreferredDERP != 0 {
			if region, ok := dm.Regions[report.PreferredDERP]; ok {
				printf("\t* Nearest DERP: %v\n", region.RegionName)
			} else {
				printf("\t* Nearest DERP: %v (region not found in map)\n", report.PreferredDERP)
			}
		} else {
			printf("\t* Nearest DERP: [none]\n")
		}
		printf("\t* DERP latency:\n")
		var rids []int
		for rid := range dm.Regions {
			rids = append(rids, rid)
		}
		sort.Slice(rids, func(i, j int) bool {
			l1, ok1 := report.RegionLatency[rids[i]]
			l2, ok2 := report.RegionLatency[rids[j]]
			if ok1 != ok2 {
				return ok1 // defined things sort first
			}
			if !ok1 {
				return rids[i] < rids[j]
			}
			return l1 < l2
		})
		for _, rid := range rids {
			d, ok := report.RegionLatency[rid]
			var latency string
			if ok {
				latency = d.Round(time.Millisecond / 10).String()
			}
			r := dm.Regions[rid]
			var derpNum string
			if netcheckArgs.verbose {
				derpNum = fmt.Sprintf("derp%d, ", rid)
			}
			printf("\t\t- %3s: %-7s (%s%s)\n", r.RegionCode, latency, derpNum, r.RegionName)
		}
	}
	return nil
}

func portMapping(r *netcheck.Report) string {
	if !buildfeatures.HasPortMapper {
		return "binary built without portmapper support"
	}
	if !r.AnyPortMappingChecked() {
		return "not checked"
	}
	var got []string
	if r.UPnP.EqualBool(true) {
		got = append(got, "UPnP")
	}
	if r.PMP.EqualBool(true) {
		got = append(got, "NAT-PMP")
	}
	if r.PCP.EqualBool(true) {
		got = append(got, "PCP")
	}
	return strings.Join(got, ", ")
}

func prodDERPMap(ctx context.Context, httpc *http.Client) (*tailcfg.DERPMap, error) {
	log.Printf("attempting to fetch a DERPMap from %s", ipn.DefaultControlURL)
	req, err := http.NewRequestWithContext(ctx, "GET", ipn.DefaultControlURL+"/derpmap/default", nil)
	if err != nil {
		return nil, fmt.Errorf("create prodDERPMap request: %w", err)
	}
	res, err := httpc.Do(req)
	if err != nil {
		return nil, fmt.Errorf("fetch prodDERPMap failed: %w", err)
	}
	defer res.Body.Close()
	b, err := io.ReadAll(io.LimitReader(res.Body, 1<<20))
	if err != nil {
		return nil, fmt.Errorf("fetch prodDERPMap failed: %w", err)
	}
	if res.StatusCode != 200 {
		return nil, fmt.Errorf("fetch prodDERPMap: %v: %s", res.Status, b)
	}
	var derpMap tailcfg.DERPMap
	if err = json.Unmarshal(b, &derpMap); err != nil {
		return nil, fmt.Errorf("fetch prodDERPMap: %w", err)
	}
	return &derpMap, nil
}

// createNetcheckBindString determines the netcheck socket bind "address:port" string based
// on the CLI args and environment variable values used to invoke the netcheck CLI.
// Arguments cliAddressIsSet and cliPortIsSet explicitly indicate whether the
// corresponding cliAddress and cliPort were set in CLI args, instead of relying
// on in-band sentinel values.
func createNetcheckBindString(cliAddress string, cliAddressIsSet bool, cliPort int, cliPortIsSet bool, envBind string) (string, error) {
	// Default to port number 0 but overwrite with a valid CLI value, if set.
	var port uint16 = 0
	if cliPortIsSet {
		// 0 is valid, results in OS picking port.
		if cliPort >= 0 && cliPort <= math.MaxUint16 {
			port = uint16(cliPort)
		} else {
			return "", fmt.Errorf("invalid bind port number: %d", cliPort)
		}
	}

	// Use CLI address, if set.
	if cliAddressIsSet {
		addr, err := netip.ParseAddr(cliAddress)
		if err != nil {
			return "", fmt.Errorf("invalid bind address: %q", cliAddress)
		}
		return netip.AddrPortFrom(addr, port).String(), nil
	} else {
		// No CLI address set, but port is set.
		if cliPortIsSet {
			return fmt.Sprintf(":%d", port), nil
		}
	}

	// Fall back to the environment variable.
	// Intentionally skipping input validation here to avoid breaking legacy usage method.
	if envBind != "" {
		return envBind, nil
	}

	// OS picks both address and port.
	return ":0", nil
}
