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

//go:build darwin && !ios

package portlist

import (
	"bufio"
	"bytes"
	"fmt"
	"log"
	"os/exec"
	"strings"
	"sync/atomic"
	"time"

	"go4.org/mem"
)

func init() {
	newOSImpl = newMacOSImpl

	// We have to run netstat, which is a bit expensive, so don't do it too often.
	pollInterval = 5 * time.Second
}

type macOSImpl struct {
	known       map[protoPort]*portMeta // inode string => metadata
	netstatPath string                  // lazily populated

	br               *bufio.Reader // reused
	portsBuf         []Port
	includeLocalhost bool
}

type protoPort struct {
	proto string
	port  uint16
}

type portMeta struct {
	port Port
	keep bool
}

func newMacOSImpl(includeLocalhost bool) osImpl {
	return &macOSImpl{
		known:            map[protoPort]*portMeta{},
		br:               bufio.NewReader(bytes.NewReader(nil)),
		includeLocalhost: includeLocalhost,
	}
}

func (*macOSImpl) Close() error { return nil }

func (im *macOSImpl) AppendListeningPorts(base []Port) ([]Port, error) {
	var err error
	im.portsBuf, err = im.appendListeningPortsNetstat(im.portsBuf[:0])
	if err != nil {
		return nil, err
	}

	for _, pm := range im.known {
		pm.keep = false
	}

	var needProcs bool
	for _, p := range im.portsBuf {
		fp := protoPort{
			proto: p.Proto,
			port:  p.Port,
		}
		if pm, ok := im.known[fp]; ok {
			pm.keep = true
		} else {
			needProcs = true
			im.known[fp] = &portMeta{
				port: p,
				keep: true,
			}
		}
	}

	ret := base
	for k, m := range im.known {
		if !m.keep {
			delete(im.known, k)
		}
	}

	if needProcs {
		im.addProcesses() // best effort
	}

	for _, m := range im.known {
		ret = append(ret, m.port)
	}
	return sortAndDedup(ret), nil
}

func (im *macOSImpl) appendListeningPortsNetstat(base []Port) ([]Port, error) {
	if im.netstatPath == "" {
		var err error
		im.netstatPath, err = exec.LookPath("netstat")
		if err != nil {
			return nil, fmt.Errorf("netstat: lookup: %v", err)
		}
	}

	cmd := exec.Command(im.netstatPath, "-na")
	outPipe, err := cmd.StdoutPipe()
	if err != nil {
		return nil, err
	}
	im.br.Reset(outPipe)

	if err := cmd.Start(); err != nil {
		return nil, err
	}
	defer cmd.Process.Wait()
	defer cmd.Process.Kill()

	return appendParsePortsNetstat(base, im.br, im.includeLocalhost)
}

var lsofFailed atomic.Bool

// In theory, lsof could replace the function of both listPorts() and
// addProcesses(), since it provides a superset of the netstat output.
// However, "netstat -na" runs ~100x faster than lsof on my machine, so
// we should do it only if the list of open ports has actually changed.
//
// This fails in a macOS sandbox (i.e. in the Mac App Store or System
// Extension GUI build), but does at least work in the
// tailscaled-on-macos mode.
func (im *macOSImpl) addProcesses() error {
	if lsofFailed.Load() {
		// This previously failed in the macOS sandbox, so don't try again.
		return nil
	}
	exe, err := exec.LookPath("lsof")
	if err != nil {
		return fmt.Errorf("lsof: lookup: %v", err)
	}
	lsofCmd := exec.Command(exe, "-F", "-n", "-P", "-O", "-S2", "-T", "-i4", "-i6")
	outPipe, err := lsofCmd.StdoutPipe()
	if err != nil {
		return err
	}
	err = lsofCmd.Start()
	if err != nil {
		var stderr []byte
		if xe, ok := err.(*exec.ExitError); ok {
			stderr = xe.Stderr
		}
		// fails when run in a macOS sandbox, so make this non-fatal.
		if lsofFailed.CompareAndSwap(false, true) {
			log.Printf("portlist: can't run lsof in Mac sandbox; omitting process names from service list. Error details: %v, %s", err, bytes.TrimSpace(stderr))
		}
		return nil
	}
	defer func() {
		ps, err := lsofCmd.Process.Wait()
		if err != nil || ps.ExitCode() != 0 {
			log.Printf("portlist: can't run lsof in Mac sandbox; omitting process names from service list. Error: %v, exit code %d", err, ps.ExitCode())
			lsofFailed.Store(true)
		}
	}()
	defer lsofCmd.Process.Kill()

	im.br.Reset(outPipe)

	var cmd, proto string
	var pid int
	for {
		line, err := im.br.ReadBytes('\n')
		if err != nil {
			break
		}
		if len(line) < 1 {
			continue
		}
		field, val := line[0], bytes.TrimSpace(line[1:])
		switch field {
		case 'p':
			// starting a new process
			cmd = ""
			proto = ""
			pid = 0
			if p, err := mem.ParseInt(mem.B(val), 10, 0); err == nil {
				pid = int(p)
			}
		case 'c':
			cmd = string(val) // TODO(bradfitz): avoid garbage; cache process names between runs?
		case 'P':
			proto = lsofProtoLower(val)
		case 'n':
			if mem.Contains(mem.B(val), mem.S("->")) {
				continue
			}
			// a listening port
			port := parsePort(mem.B(val))
			if port <= 0 {
				continue
			}
			pp := protoPort{proto, uint16(port)}
			m := im.known[pp]
			switch {
			case m != nil:
				m.port.Process = cmd
				m.port.Pid = pid
			default:
				// ignore: processes and ports come and go
			}
		}
	}

	return nil
}

func lsofProtoLower(p []byte) string {
	if string(p) == "TCP" {
		return "tcp"
	}
	if string(p) == "UDP" {
		return "udp"
	}
	return strings.ToLower(string(p))
}
