// livetrace - continuous traceroute with live TUI
// Inspired by MikroTik RouterOS traceroute.
// Requires root / Administrator (raw ICMP socket for receiving).
//
// Usage: livetrace [options] <target>
// Build: go build -o livetrace .
// Linux:   sudo ./livetrace 8.8.8.8
// Windows: run as Administrator: livetrace.exe 8.8.8.8
//
// Recommended terminal width: 130+ columns.

package main

import (
	"encoding/binary"
	"flag"
	"fmt"
	"math"
	"net"
	"os"
	"os/signal"
	"runtime"
	"sort"
	"strings"
	"sync"
	"syscall"
	"time"

	"golang.org/x/net/icmp"
	"golang.org/x/net/ipv4"
)

const (
	icmpProto  = 1
	histSize   = 25  // history slots per hop
	minPktSize = 28  // IP(20) + ICMP/UDP header(8) — zero payload
	defPktSize = 56  // matches common ping/traceroute default
)

var barChars = []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'}

// ─── History ring buffer ──────────────────────────────────────────────────────

type HistEntry struct {
	timedOut bool
	rtt      float64 // ms; valid when timedOut=false
}

// ─── Per-hop statistics ───────────────────────────────────────────────────────

type Hop struct {
	mu          sync.Mutex
	ttl         int
	noDNS       bool
	addrs       map[string]bool // all IPs that responded (ECMP support)
	host        string          // reverse-DNS of first addr
	sent        int
	recv        int
	last        float64
	lastTimeout bool    // true if the most recent resolved probe timed out
	sumRTT      float64
	sumRTTSq    float64 // Σ(rtt²) for online std-dev computation
	best        float64
	worst       float64
	reached     bool
	history     [histSize]HistEntry
	histLen     int
	histPos     int // next write slot (ring buffer)
}

func (h *Hop) recordSent() {
	h.mu.Lock()
	h.sent++
	h.mu.Unlock()
}

func (h *Hop) recordRecv(rtt float64, from string, reached bool) {
	h.mu.Lock()
	defer h.mu.Unlock()

	h.recv++
	h.last = rtt
	h.lastTimeout = false
	h.sumRTT += rtt
	h.sumRTTSq += rtt * rtt
	if h.best == 0 || rtt < h.best {
		h.best = rtt
	}
	if rtt > h.worst {
		h.worst = rtt
	}
	h.reached = reached

	if h.addrs == nil {
		h.addrs = make(map[string]bool)
	}
	isNew := !h.addrs[from]
	h.addrs[from] = true

	// Async reverse-DNS only for the first IP ever seen, and only if enabled.
	if isNew && len(h.addrs) == 1 && !h.noDNS {
		go func(ip string) {
			names, err := net.LookupAddr(ip)
			h.mu.Lock()
			defer h.mu.Unlock()
			if err == nil && len(names) > 0 {
				h.host = strings.TrimSuffix(names[0], ".")
			}
		}(from)
	}

	h.pushHistory(HistEntry{rtt: rtt})
}

func (h *Hop) recordTimeout() {
	h.mu.Lock()
	defer h.mu.Unlock()
	h.lastTimeout = true
	h.pushHistory(HistEntry{timedOut: true})
}

func (h *Hop) pushHistory(e HistEntry) {
	h.history[h.histPos] = e
	h.histPos = (h.histPos + 1) % histSize
	if h.histLen < histSize {
		h.histLen++
	}
}

// HopSnap is a render-time snapshot of a hop.
type HopSnap struct {
	TTL         int
	Addrs       []string // sorted; multiple = ECMP
	Host        string
	Sent        int
	Recv        int
	Last        float64
	LastTimeout bool
	Avg         float64
	Best        float64
	Worst       float64
	StdDev      float64
	Loss        float64
	Reached     bool
	History     []HistEntry // oldest → newest
}

func (h *Hop) snapshot() HopSnap {
	h.mu.Lock()
	defer h.mu.Unlock()

	s := HopSnap{
		TTL:         h.ttl,
		Host:        h.host,
		Sent:        h.sent,
		Recv:        h.recv,
		Last:        h.last,
		LastTimeout: h.lastTimeout,
		Best:        h.best,
		Worst:       h.worst,
		Reached:     h.reached,
	}
	for addr := range h.addrs {
		s.Addrs = append(s.Addrs, addr)
	}
	sort.Strings(s.Addrs)

	if h.recv > 0 {
		s.Avg = h.sumRTT / float64(h.recv)
		if v := h.sumRTTSq/float64(h.recv) - s.Avg*s.Avg; v > 0 {
			s.StdDev = math.Sqrt(v)
		}
	}
	if h.sent > 0 {
		s.Loss = float64(h.sent-h.recv) / float64(h.sent) * 100
	}

	if h.histLen > 0 {
		s.History = make([]HistEntry, h.histLen)
		if h.histLen < histSize {
			copy(s.History, h.history[:h.histLen])
		} else {
			n := copy(s.History, h.history[h.histPos:])
			copy(s.History[n:], h.history[:h.histPos])
		}
	}
	return s
}

// ─── Pending probe ────────────────────────────────────────────────────────────

type pending struct {
	ttl  int
	sent time.Time
	port int // UDP destination port (UDP mode); 0 for ICMP mode
}

// ─── Tracer ───────────────────────────────────────────────────────────────────

type Tracer struct {
	// config
	target    string
	targetIP  net.IP
	maxHops   int
	interval  time.Duration
	timeout   time.Duration
	proto     string        // "icmp" or "udp"
	basePort  int           // UDP: starting destination port
	pktSize   int           // total IP packet size in bytes
	maxRounds int           // 0 = unlimited
	maxDur    time.Duration // 0 = unlimited

	hops []*Hop
	pid  int // used as ICMP Echo identifier

	// sockets
	// icmpConn is always opened (for receiving ICMP errors and, in ICMP mode,
	// for sending Echo Requests).
	icmpConn *icmp.PacketConn
	icmpPC   *ipv4.PacketConn
	// udpSend is only opened in UDP mode; it sends the UDP probes.
	udpSend net.PacketConn
	udpPC   *ipv4.PacketConn

	// probe tracking
	mu         sync.Mutex
	seqCtr     int
	probes     map[int]*pending // key: ICMP seq (ICMP) or sequential int (UDP)
	portToKey  map[int]int      // UDP only: dstPort → probe key
	maxActive  int
	sendMu     sync.Mutex // serialises SetTTL + send

	// lifecycle
	stopCh  chan struct{}
	started time.Time
}

func NewTracer(
	target string,
	maxHops int,
	interval, timeout time.Duration,
	proto string,
	basePort, pktSize int,
	maxRounds int,
	maxDur time.Duration,
	noDNS bool,
) (*Tracer, error) {
	ips, err := net.LookupHost(target)
	if err != nil {
		return nil, fmt.Errorf("resolve %q: %w", target, err)
	}
	var targetIP net.IP
	for _, raw := range ips {
		if v4 := net.ParseIP(raw).To4(); v4 != nil {
			targetIP = v4
			break
		}
	}
	if targetIP == nil {
		return nil, fmt.Errorf("no IPv4 address for %s (IPv6 not yet supported)", target)
	}

	if pktSize < minPktSize {
		pktSize = minPktSize
	}

	hops := make([]*Hop, maxHops)
	for i := range hops {
		hops[i] = &Hop{ttl: i + 1, noDNS: noDNS}
	}

	return &Tracer{
		target:    target,
		targetIP:  targetIP,
		maxHops:   maxHops,
		interval:  interval,
		timeout:   timeout,
		proto:     proto,
		basePort:  basePort,
		pktSize:   pktSize,
		maxRounds: maxRounds,
		maxDur:    maxDur,
		hops:      hops,
		pid:       os.Getpid() & 0xffff,
		probes:    make(map[int]*pending),
		portToKey: make(map[int]int),
		maxActive: maxHops,
		stopCh:    make(chan struct{}),
		started:   time.Now(),
	}, nil
}

// Done returns a channel that is closed when the tracer stops (either due to
// count/duration limits or an explicit Stop() call).
func (t *Tracer) Done() <-chan struct{} { return t.stopCh }

func (t *Tracer) Start() error {
	var err error

	// ICMP socket — always required (receiving ICMP errors, and sending in ICMP mode).
	t.icmpConn, err = icmp.ListenPacket("ip4:icmp", "0.0.0.0")
	if err != nil {
		// Fallback: unprivileged ICMP dgram (Linux ≥5.x with ping_group_range).
		t.icmpConn, err = icmp.ListenPacket("udp4", "0.0.0.0:0")
		if err != nil {
			hint := "try: sudo ./livetrace"
			if runtime.GOOS == "windows" {
				hint = "run as Administrator"
			}
			return fmt.Errorf("cannot open ICMP socket (%s): %w", hint, err)
		}
	}
	t.icmpPC = t.icmpConn.IPv4PacketConn()

	// UDP sending socket — only needed in UDP mode.
	if t.proto == "udp" {
		t.udpSend, err = net.ListenPacket("udp4", ":0")
		if err != nil {
			_ = t.icmpConn.Close()
			return fmt.Errorf("cannot open UDP send socket: %w", err)
		}
		t.udpPC = ipv4.NewPacketConn(t.udpSend)
	}

	go t.receiveLoop()
	go t.probeLoop()
	go t.cleanupLoop()
	return nil
}

func (t *Tracer) Stop() {
	select {
	case <-t.stopCh:
	default:
		close(t.stopCh)
	}
	_ = t.icmpConn.Close()
	if t.udpSend != nil {
		_ = t.udpSend.Close()
	}
}

func (t *Tracer) nextSeq() int {
	t.mu.Lock()
	defer t.mu.Unlock()
	t.seqCtr = (t.seqCtr % 0xffff) + 1
	return t.seqCtr
}

// udpPortForKey maps a sequential probe key to a UDP destination port.
func (t *Tracer) udpPortForKey(key int) int {
	spread := 65535 - t.basePort
	if spread < 1 {
		spread = 1
	}
	return t.basePort + (key-1)%spread
}

// payloadSize returns the number of bytes to put in the ICMP/UDP data section.
func (t *Tracer) payloadSize() int {
	// pktSize = IP header(20) + protocol header(8) + payload
	sz := t.pktSize - 28
	if sz < 0 {
		sz = 0
	}
	return sz
}

// makeICMPBytes builds a marshalled ICMP Echo Request.
func (t *Tracer) makeICMPBytes(seq int) ([]byte, error) {
	data := make([]byte, t.payloadSize())
	copy(data, "livetrace") // tag for identification
	msg := icmp.Message{
		Type: ipv4.ICMPTypeEcho,
		Code: 0,
		Body: &icmp.Echo{ID: t.pid, Seq: seq, Data: data},
	}
	return msg.Marshal(nil)
}

// makeUDPBytes builds a raw UDP payload of the configured size.
func (t *Tracer) makeUDPBytes() []byte {
	return make([]byte, t.payloadSize())
}

// ─── Sending ──────────────────────────────────────────────────────────────────

func (t *Tracer) sendProbe(ttl int) {
	if t.proto == "udp" {
		t.sendProbeUDP(ttl)
	} else {
		t.sendProbeICMP(ttl)
	}
}

func (t *Tracer) sendProbeICMP(ttl int) {
	seq := t.nextSeq()
	b, err := t.makeICMPBytes(seq)
	if err != nil {
		return
	}

	t.mu.Lock()
	t.probes[seq] = &pending{ttl: ttl, sent: time.Now()}
	t.mu.Unlock()
	t.hops[ttl-1].recordSent()

	t.sendMu.Lock()
	_ = t.icmpPC.SetTTL(ttl)
	_, err = t.icmpConn.WriteTo(b, &net.IPAddr{IP: t.targetIP})
	t.sendMu.Unlock()

	if err != nil {
		t.mu.Lock()
		delete(t.probes, seq)
		t.mu.Unlock()
		h := t.hops[ttl-1]
		h.mu.Lock()
		h.sent--
		h.mu.Unlock()
	}
}

func (t *Tracer) sendProbeUDP(ttl int) {
	key := t.nextSeq()
	dstPort := t.udpPortForKey(key)
	payload := t.makeUDPBytes()

	t.mu.Lock()
	t.probes[key] = &pending{ttl: ttl, sent: time.Now(), port: dstPort}
	t.portToKey[dstPort] = key
	t.mu.Unlock()
	t.hops[ttl-1].recordSent()

	t.sendMu.Lock()
	_ = t.udpPC.SetTTL(ttl)
	_, err := t.udpSend.WriteTo(payload, &net.UDPAddr{IP: t.targetIP, Port: dstPort})
	t.sendMu.Unlock()

	if err != nil {
		t.mu.Lock()
		delete(t.probes, key)
		delete(t.portToKey, dstPort)
		t.mu.Unlock()
		h := t.hops[ttl-1]
		h.mu.Lock()
		h.sent--
		h.mu.Unlock()
	}
}

// ─── Probe loop ───────────────────────────────────────────────────────────────

func (t *Tracer) probeLoop() {
	round := 0
	for {
		// Check stop conditions.
		if t.maxRounds > 0 && round >= t.maxRounds {
			t.Stop()
			return
		}
		if t.maxDur > 0 && time.Since(t.started) >= t.maxDur {
			t.Stop()
			return
		}

		t.mu.Lock()
		maxActive := t.maxActive
		t.mu.Unlock()

		for ttl := 1; ttl <= maxActive; ttl++ {
			select {
			case <-t.stopCh:
				return
			default:
			}
			t.sendProbe(ttl)
			time.Sleep(3 * time.Millisecond)
		}
		round++

		select {
		case <-t.stopCh:
			return
		case <-time.After(t.interval):
		}
	}
}

// ─── Receive loop ─────────────────────────────────────────────────────────────

func (t *Tracer) receiveLoop() {
	buf := make([]byte, 1500)
	for {
		select {
		case <-t.stopCh:
			return
		default:
		}

		_ = t.icmpConn.SetReadDeadline(time.Now().Add(100 * time.Millisecond))
		n, peer, err := t.icmpConn.ReadFrom(buf)
		if err != nil {
			if ne, ok := err.(net.Error); ok && ne.Timeout() {
				continue
			}
			select {
			case <-t.stopCh:
				return
			default:
				continue
			}
		}

		from := peerString(peer)
		now := time.Now()

		msg, err := icmp.ParseMessage(icmpProto, buf[:n])
		if err != nil {
			continue
		}

		switch msg.Type {

		case ipv4.ICMPTypeEchoReply:
			// Only relevant in ICMP mode.
			if t.proto != "icmp" {
				continue
			}
			reply, ok := msg.Body.(*icmp.Echo)
			if !ok || reply.ID != t.pid {
				continue
			}
			t.handleReply(reply.Seq, from, now, true)

		case ipv4.ICMPTypeTimeExceeded:
			te, ok := msg.Body.(*icmp.TimeExceeded)
			if !ok {
				continue
			}
			if t.proto == "udp" {
				dstPort, ok := extractOriginalUDPPort(te.Data)
				if !ok {
					continue
				}
				t.mu.Lock()
				key, exists := t.portToKey[dstPort]
				t.mu.Unlock()
				if !exists {
					continue
				}
				t.handleReply(key, from, now, false)
			} else {
				seq, id, ok := extractOriginalICMP(te.Data)
				if !ok || id != t.pid {
					continue
				}
				t.handleReply(seq, from, now, false)
			}

		case ipv4.ICMPTypeDestinationUnreachable:
			// In UDP mode, Port Unreachable means we reached the destination.
			// In ICMP mode (unprivileged udp4 socket fallback), treat as destination.
			du, ok := msg.Body.(*icmp.DstUnreach)
			if !ok {
				continue
			}
			if t.proto == "udp" {
				dstPort, ok := extractOriginalUDPPort(du.Data)
				if !ok {
					continue
				}
				t.mu.Lock()
				key, exists := t.portToKey[dstPort]
				t.mu.Unlock()
				if !exists {
					continue
				}
				t.handleReply(key, from, now, true)
			} else {
				seq, id, ok := extractOriginalICMP(du.Data)
				if !ok || id != t.pid {
					continue
				}
				t.handleReply(seq, from, now, true)
			}
		}
	}
}

// ─── ICMP error payload extraction ───────────────────────────────────────────

// extractOriginalICMP reads the ICMP id and seq from the original IP+ICMP
// header embedded in a Time Exceeded or Dest Unreachable reply.
func extractOriginalICMP(data []byte) (seq, id int, ok bool) {
	if len(data) < 28 {
		return 0, 0, false
	}
	ihl := int(data[0]&0x0f) * 4
	if ihl < 20 || len(data) < ihl+8 {
		return 0, 0, false
	}
	if data[9] != 1 { // protocol must be ICMP (1)
		return 0, 0, false
	}
	orig := data[ihl : ihl+8]
	// ICMP: type(1) code(1) checksum(2) id(2) seq(2)
	id = int(binary.BigEndian.Uint16(orig[4:6]))
	seq = int(binary.BigEndian.Uint16(orig[6:8]))
	return seq, id, true
}

// extractOriginalUDPPort reads the original UDP destination port from the
// embedded original IP+UDP header in a Time Exceeded or Dest Unreachable reply.
func extractOriginalUDPPort(data []byte) (dstPort int, ok bool) {
	if len(data) < 28 {
		return 0, false
	}
	ihl := int(data[0]&0x0f) * 4
	if ihl < 20 || len(data) < ihl+8 {
		return 0, false
	}
	if data[9] != 17 { // protocol must be UDP (17)
		return 0, false
	}
	// UDP: srcPort(2) dstPort(2) length(2) checksum(2)
	dstPort = int(binary.BigEndian.Uint16(data[ihl+2 : ihl+4]))
	return dstPort, true
}

// ─── Reply handler ────────────────────────────────────────────────────────────

func (t *Tracer) handleReply(key int, from string, now time.Time, reached bool) {
	t.mu.Lock()
	p, ok := t.probes[key]
	if !ok {
		t.mu.Unlock()
		return
	}
	delete(t.probes, key)
	if p.port != 0 {
		delete(t.portToKey, p.port)
	}
	rtt := now.Sub(p.sent).Seconds() * 1000
	ttl := p.ttl
	t.mu.Unlock()

	if ttl < 1 || ttl > t.maxHops {
		return
	}
	t.hops[ttl-1].recordRecv(rtt, from, reached)

	if reached {
		t.mu.Lock()
		if t.maxActive > ttl {
			t.maxActive = ttl
		}
		t.mu.Unlock()
	}
}

// ─── Cleanup loop ─────────────────────────────────────────────────────────────

// expiredProbe holds just enough info to call recordTimeout after releasing mu.
type expiredProbe struct {
	ttl  int
	port int // UDP port, or 0 for ICMP
}

func (t *Tracer) cleanupLoop() {
	tick := time.NewTicker(250 * time.Millisecond)
	defer tick.Stop()
	for {
		select {
		case <-t.stopCh:
			return
		case now := <-tick.C:
			var expired []expiredProbe
			t.mu.Lock()
			for key, p := range t.probes {
				if now.Sub(p.sent) > t.timeout {
					expired = append(expired, expiredProbe{ttl: p.ttl, port: p.port})
					if p.port != 0 {
						delete(t.portToKey, p.port)
					}
					delete(t.probes, key)
				}
			}
			t.mu.Unlock()

			// Record timeouts outside the lock so recordTimeout can acquire h.mu freely.
			for _, e := range expired {
				if e.ttl >= 1 && e.ttl <= t.maxHops {
					t.hops[e.ttl-1].recordTimeout()
				}
			}
		}
	}
}

// ─── TUI display ─────────────────────────────────────────────────────────────

const (
	colReset  = "\033[0m"
	colRed    = "\033[31m"
	colYellow = "\033[33m"
	colGreen  = "\033[32m"
	colCyan   = "\033[36m"
	colBold   = "\033[1m"
	colDim    = "\033[2m"
)

func renderHistory(hist []HistEntry, best, worst float64) string {
	var sb strings.Builder

	for i := 0; i < histSize-len(hist); i++ {
		sb.WriteByte(' ')
	}

	rng := worst - best
	if rng < 1 {
		rng = 1
	}

	for i, e := range hist {
		isLatest := i == len(hist)-1
		var color, char string

		if e.timedOut {
			char = "█"
			if isLatest {
				color = colCyan
			} else {
				color = colRed
			}
		} else {
			level := 0
			if best > 0 && worst > best {
				norm := (e.rtt - best) / rng
				level = int(norm * 8)
				if level < 0 {
					level = 0
				}
				if level > 7 {
					level = 7
				}
			}
			char = string(barChars[level])
			switch {
			case isLatest:
				color = colCyan
			case best > 0 && e.rtt > best*3:
				color = colYellow
			default:
				color = colGreen
			}
		}
		sb.WriteString(color + char + colReset)
	}
	return sb.String()
}

func formatHost(s HopSnap, maxLen int) string {
	if len(s.Addrs) == 0 {
		return "???"
	}
	primary := s.Addrs[0]
	if s.Host != "" {
		primary = s.Host
	}
	if len(s.Addrs) == 1 {
		if len(primary) > maxLen {
			return primary[:maxLen-3] + "..."
		}
		return primary
	}
	// ECMP: comma-separate all IPs after the primary label.
	full := primary + ", " + strings.Join(s.Addrs[1:], ", ")
	if len(full) > maxLen {
		return full[:maxLen-3] + "..."
	}
	return full
}

func fms(v float64) string {
	if v == 0 {
		return "      -"
	}
	return fmt.Sprintf("%7.1f", v)
}

func fdev(v float64) string {
	if v == 0 {
		return "     -"
	}
	return fmt.Sprintf("%6.1f", v)
}

func (t *Tracer) Render() {
	t.mu.Lock()
	maxActive := t.maxActive
	t.mu.Unlock()

	elapsed := time.Since(t.started)
	fmt.Print("\033[H")

	// Header line
	label := t.target
	if label != t.targetIP.String() {
		label = fmt.Sprintf("%s (%s)", t.target, t.targetIP)
	}
	protoTag := fmt.Sprintf("[%s/%dB]", strings.ToUpper(t.proto), t.pktSize)
	fmt.Printf("%sLiveTrace%s  →  %-36s  %s  %s  %s[Ctrl+C to quit]%s\n\n",
		colBold, colReset, label, protoTag, formatElapsed(elapsed), colDim, colReset,
	)

	// Column headers
	fmt.Printf("  %s%-4s  %-28s  %6s  %4s  %4s  %7s  %7s  %7s  %7s  %6s  %-25s%s\n",
		colBold,
		"Hop", "Host", "Loss%", "Snt", "Rcv",
		"Last", "Avg", "Best", "Wrst", "StdD",
		fmt.Sprintf("History (%ds)", histSize),
		colReset,
	)
	fmt.Println("  " + strings.Repeat("─", 129))

	for i := 0; i < maxActive && i < len(t.hops); i++ {
		renderHopRow(t.hops[i].snapshot())
	}
	fmt.Println()
}

func renderHopRow(s HopSnap) {
	label := formatHost(s, 28)

	lc, rc := "", ""
	switch {
	case s.Loss >= 50:
		lc, rc = colRed, colReset
	case s.Loss >= 10:
		lc, rc = colYellow, colReset
	case s.Recv > 0:
		lc, rc = colGreen, colReset
	}

	var lastStr string
	switch {
	case s.LastTimeout:
		lastStr = "timeout"
	case s.Last > 0:
		lastStr = fmt.Sprintf("%7.1f", s.Last)
	default:
		lastStr = "      -"
	}

	histStr := renderHistory(s.History, s.Best, s.Worst)

	reachedStr := ""
	if s.Reached {
		reachedStr = colGreen + " ✓" + colReset
	}

	fmt.Printf("  %-4d  %-28s  %s%5.1f%%%s  %4d  %4d  %s  %s  %s  %s  %s  %s%s\n",
		s.TTL, label,
		lc, s.Loss, rc,
		s.Sent, s.Recv,
		lastStr, fms(s.Avg), fms(s.Best), fms(s.Worst),
		fdev(s.StdDev),
		histStr,
		reachedStr,
	)
}

func formatElapsed(d time.Duration) string {
	h := int(d.Hours())
	m := int(d.Minutes()) % 60
	s := int(d.Seconds()) % 60
	if h > 0 {
		return fmt.Sprintf("%d:%02d:%02d", h, m, s)
	}
	return fmt.Sprintf("%02d:%02d", m, s)
}

func peerString(addr net.Addr) string {
	switch a := addr.(type) {
	case *net.IPAddr:
		return a.IP.String()
	case *net.UDPAddr:
		return a.IP.String()
	default:
		return addr.String()
	}
}

// ─── Entry point ─────────────────────────────────────────────────────────────

func main() {
	maxHops  := flag.Int("m", 30, "maximum hops")
	intervalMS := flag.Int("i", 1000, "probe interval in milliseconds")
	timeoutMS  := flag.Int("t", 3000, "probe timeout in milliseconds")
	proto    := flag.String("proto", "icmp", "probe protocol: icmp or udp")
	port     := flag.Int("port", 33434, "base destination port (UDP mode only)")
	size     := flag.Int("size", defPktSize, "total IP packet size in bytes (min 28); use 1500 for MTU test")
	noDNS   := flag.Bool("n", false, "disable reverse DNS (show numeric IPs only)")
	count   := flag.Int("count", 0, "stop after N complete probe rounds (0 = run forever)")
	dur     := flag.Duration("duration", 0, "stop after this duration, e.g. 30s, 5m (0 = run forever)")

	flag.Usage = func() {
		fmt.Fprintf(os.Stderr, `Usage: livetrace [options] <target>

Options:
`)
		flag.PrintDefaults()
		fmt.Fprintf(os.Stderr, `
Requires root (Linux/macOS) or Administrator (Windows).
Recommended terminal width: 130+ columns.

Examples:
  sudo ./livetrace 8.8.8.8
  sudo ./livetrace -proto udp -port 33434 8.8.8.8
  sudo ./livetrace -size 1500 8.8.8.8          # MTU path test
  sudo ./livetrace -n -count 30 -duration 1m 8.8.8.8
`)
	}
	flag.Parse()

	if flag.NArg() < 1 {
		flag.Usage()
		os.Exit(1)
	}

	p := strings.ToLower(*proto)
	if p != "icmp" && p != "udp" {
		fmt.Fprintln(os.Stderr, "Error: -proto must be icmp or udp")
		os.Exit(1)
	}

	tracer, err := NewTracer(
		flag.Arg(0),
		*maxHops,
		time.Duration(*intervalMS)*time.Millisecond,
		time.Duration(*timeoutMS)*time.Millisecond,
		p,
		*port,
		*size,
		*count,
		*dur,
		*noDNS,
	)
	if err != nil {
		fmt.Fprintln(os.Stderr, "Error:", err)
		os.Exit(1)
	}

	if err := tracer.Start(); err != nil {
		fmt.Fprintln(os.Stderr, "Error:", err)
		os.Exit(1)
	}

	fmt.Print("\033[2J\033[H")

	sigCh := make(chan os.Signal, 1)
	signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)

	tick := time.NewTicker(200 * time.Millisecond)
	defer tick.Stop()

	for {
		select {
		case <-sigCh:
			tracer.Stop()
			fmt.Print("\033[2J\033[H")
			fmt.Println("livetrace stopped.")
			return
		case <-tracer.Done():
			tracer.Render()
			fmt.Println("livetrace: trace complete.")
			return
		case <-tick.C:
			tracer.Render()
		}
	}
}

