// Command netlens is a small, dependency-free network diagnostics CLI.
//
// It provides two genuinely portable, unprivileged network probes:
//
//   - scan: TCP-connect port scanning (net.DialTimeout against each port).
//   - ping: TCP-connect latency measurement, used as a portable proxy for
//     ICMP ping (which requires a raw socket and administrator/root
//     privileges on every target platform).
//
// See README.txt for the full scope discussion and roadmap.
package main

import (
	"encoding/json"
	"errors"
	"flag"
	"fmt"
	"net"
	"os"
	"sort"
	"strconv"
	"strings"
	"sync"
	"time"
)

func main() {
	if len(os.Args) < 2 {
		// Double-clicked in Explorer rather than run from a prompt: ask the
		// one question the program needs and stay on screen. Printing usage
		// and exiting here is what made the window vanish instantly.
		if interactiveConsole() {
			runGuided()
			return
		}
		usage()
		os.Exit(1)
	}

	switch os.Args[1] {
	case "-h", "--help", "help":
		usage()
		return
	case "scan":
		runScan(os.Args[2:])
	case "ping":
		runPing(os.Args[2:])
	default:
		fmt.Fprintf(os.Stderr, "netlens: unknown command %q\n\n", os.Args[1])
		usage()
		os.Exit(1)
	}
}

func usage() {
	fmt.Fprint(os.Stderr, `NetLens - TCP port scanning and TCP-connect latency probing

USAGE:
    netlens <command> [arguments]

COMMANDS:
    scan    TCP-connect port scanner
    ping    TCP-connect latency probe (NOT ICMP ping)
    help    Show this help message

Run 'netlens <command> -h' for command-specific help.

NOTE: "ping" here measures TCP connect time, not ICMP echo. True ICMP
ping requires a raw socket and administrator/root privileges on every
target platform, which is incompatible with a portable, unprivileged
CLI. See README.txt for details and roadmap items (firewall visibility,
per-app bandwidth, WFP route diagnostics) that need OS-privileged APIs.
`)
}

// reorderFlags moves all flag tokens (and their values, for flags in
// valueFlags) to the front of args and all positional tokens to the back.
// This works around a quirk of the standard flag package: it stops parsing
// at the first non-flag argument, but NetLens's subcommands legitimately
// take positional host arguments before their flags.
func reorderFlags(args []string, valueFlags map[string]bool) []string {
	var flags, positional []string
	for i := 0; i < len(args); i++ {
		a := args[i]
		name := strings.TrimLeft(a, "-")
		if strings.HasPrefix(a, "-") && valueFlags[name] {
			flags = append(flags, a)
			if i+1 < len(args) {
				i++
				flags = append(flags, args[i])
			}
			continue
		}
		if strings.HasPrefix(a, "-") {
			flags = append(flags, a)
			continue
		}
		positional = append(positional, a)
	}
	return append(flags, positional...)
}

// ---------------------------------------------------------------------
// scan
// ---------------------------------------------------------------------

type portResult struct {
	Port      int     `json:"port"`
	Status    string  `json:"status"` // OPEN, CLOSED, FILTERED
	LatencyMs float64 `json:"latency_ms,omitempty"`
	Error     string  `json:"error,omitempty"`
}

type scanReport struct {
	Host       string       `json:"host"`
	Ports      []portResult `json:"ports"`
	OpenCount  int          `json:"open_count"`
	Closed     int          `json:"closed_count"`
	Filtered   int          `json:"filtered_count"`
	DurationMs float64      `json:"duration_ms"`
}

func scanUsage() {
	fmt.Fprint(os.Stderr, `Usage: netlens scan <host> --ports <spec> [flags]

Scans a host for open TCP ports using net.DialTimeout (a TCP connect
scan). This does not use raw sockets and does not require elevated
privileges.

ARGUMENTS:
    <host>              Hostname or IP address to scan

FLAGS:
    --ports <spec>      Comma-separated ports and/or ranges, e.g.
                         "22,80,443,8000-8010" (required)
    --timeout <dur>     Per-port dial timeout (default 500ms)
    --concurrency <n>   Max concurrent dials (default 100)
    --json              Emit a JSON report instead of text
    -h, --help          Show this help message

CLASSIFICATION:
    OPEN      dial succeeded; latency is the connect time
    CLOSED    dial failed immediately (connection refused)
    FILTERED  dial timed out with no response (likely firewalled)

EXAMPLE:
    netlens scan example.com --ports 20-25,80,443 --timeout 500ms
`)
}

func runScan(args []string) {
	for _, a := range args {
		if a == "-h" || a == "--help" || a == "help" {
			scanUsage()
			return
		}
	}

	valueFlags := map[string]bool{"ports": true, "timeout": true, "concurrency": true}
	args = reorderFlags(args, valueFlags)

	fs := flag.NewFlagSet("scan", flag.ExitOnError)
	fs.Usage = scanUsage
	portsSpec := fs.String("ports", "", "comma-separated ports and/or ranges, e.g. 20-25,80,443")
	timeout := fs.Duration("timeout", 500*time.Millisecond, "per-port dial timeout")
	concurrency := fs.Int("concurrency", 100, "max concurrent dials")
	jsonOut := fs.Bool("json", false, "emit JSON report")
	fs.Parse(args)

	positional := fs.Args()
	if len(positional) < 1 {
		fmt.Fprintln(os.Stderr, "netlens scan: missing <host>")
		scanUsage()
		os.Exit(1)
	}
	host := positional[0]

	if *portsSpec == "" {
		fmt.Fprintln(os.Stderr, "netlens scan: --ports is required")
		os.Exit(1)
	}

	ports, err := parsePorts(*portsSpec)
	if err != nil {
		fmt.Fprintf(os.Stderr, "netlens scan: %v\n", err)
		os.Exit(1)
	}
	if len(ports) == 0 {
		fmt.Fprintln(os.Stderr, "netlens scan: --ports produced no ports")
		os.Exit(1)
	}
	if *concurrency < 1 {
		*concurrency = 1
	}

	start := time.Now()
	results := scanPorts(host, ports, *timeout, *concurrency)
	elapsed := time.Since(start)

	sort.Slice(results, func(i, j int) bool { return results[i].Port < results[j].Port })

	report := scanReport{Host: host, Ports: results, DurationMs: msf(elapsed)}
	for _, r := range results {
		switch r.Status {
		case "OPEN":
			report.OpenCount++
		case "CLOSED":
			report.Closed++
		case "FILTERED":
			report.Filtered++
		}
	}

	if *jsonOut {
		enc := json.NewEncoder(os.Stdout)
		enc.SetIndent("", "  ")
		enc.Encode(report)
		return
	}

	fmt.Printf("NetLens scan: %s (%d ports, timeout %s, concurrency %d)\n\n", host, len(ports), *timeout, *concurrency)
	for _, r := range results {
		if r.Status == "OPEN" {
			fmt.Printf("  OPEN     %5d   %.2fms\n", r.Port, r.LatencyMs)
		}
	}
	fmt.Println()
	fmt.Printf("Summary: %d open, %d closed, %d filtered, %d total (%.2fs)\n",
		report.OpenCount, report.Closed, report.Filtered, len(ports), elapsed.Seconds())
}

// parsePorts parses a comma-separated list of ports and/or N-M ranges into
// a sorted, de-duplicated slice of port numbers.
func parsePorts(spec string) ([]int, error) {
	seen := map[int]bool{}
	var out []int
	for _, part := range strings.Split(spec, ",") {
		part = strings.TrimSpace(part)
		if part == "" {
			continue
		}
		if strings.Contains(part, "-") {
			bounds := strings.SplitN(part, "-", 2)
			if len(bounds) != 2 {
				return nil, fmt.Errorf("invalid port range %q", part)
			}
			lo, err := strconv.Atoi(strings.TrimSpace(bounds[0]))
			if err != nil {
				return nil, fmt.Errorf("invalid port range %q: %v", part, err)
			}
			hi, err := strconv.Atoi(strings.TrimSpace(bounds[1]))
			if err != nil {
				return nil, fmt.Errorf("invalid port range %q: %v", part, err)
			}
			if lo > hi {
				lo, hi = hi, lo
			}
			if lo < 1 || hi > 65535 {
				return nil, fmt.Errorf("port range %q out of bounds (1-65535)", part)
			}
			for p := lo; p <= hi; p++ {
				if !seen[p] {
					seen[p] = true
					out = append(out, p)
				}
			}
		} else {
			p, err := strconv.Atoi(part)
			if err != nil {
				return nil, fmt.Errorf("invalid port %q: %v", part, err)
			}
			if p < 1 || p > 65535 {
				return nil, fmt.Errorf("port %d out of bounds (1-65535)", p)
			}
			if !seen[p] {
				seen[p] = true
				out = append(out, p)
			}
		}
	}
	return out, nil
}

// scanPorts dials each port concurrently, bounded by a worker pool of size
// concurrency, and returns one result per port.
func scanPorts(host string, ports []int, timeout time.Duration, concurrency int) []portResult {
	results := make([]portResult, len(ports))
	sem := make(chan struct{}, concurrency)
	var wg sync.WaitGroup

	for i, port := range ports {
		wg.Add(1)
		sem <- struct{}{}
		go func(i, port int) {
			defer wg.Done()
			defer func() { <-sem }()
			results[i] = probePort(host, port, timeout)
		}(i, port)
	}
	wg.Wait()
	return results
}

func probePort(host string, port int, timeout time.Duration) portResult {
	addr := net.JoinHostPort(host, strconv.Itoa(port))
	start := time.Now()
	conn, err := net.DialTimeout("tcp", addr, timeout)
	elapsed := time.Since(start)
	if err == nil {
		conn.Close()
		return portResult{Port: port, Status: "OPEN", LatencyMs: msf(elapsed)}
	}
	status := classifyDialErr(err)
	return portResult{Port: port, Status: status, Error: err.Error()}
}

// classifyDialErr maps a dial error to CLOSED (connection actively
// refused) or FILTERED (timed out or otherwise unreachable / dropped,
// consistent with no response ever arriving - the hallmark of a
// firewall silently dropping packets).
func classifyDialErr(err error) string {
	var netErr net.Error
	if errors.As(err, &netErr) && netErr.Timeout() {
		return "FILTERED"
	}
	if strings.Contains(err.Error(), "refused") {
		return "CLOSED"
	}
	return "FILTERED"
}

func msf(d time.Duration) float64 {
	return float64(d.Microseconds()) / 1000.0
}

// ---------------------------------------------------------------------
// ping
// ---------------------------------------------------------------------

type pingAttempt struct {
	Seq       int     `json:"seq"`
	Success   bool    `json:"success"`
	LatencyMs float64 `json:"latency_ms,omitempty"`
	Error     string  `json:"error,omitempty"`
}

type pingHostReport struct {
	Host        string        `json:"host"`
	Port        int           `json:"port"`
	Sent        int           `json:"sent"`
	Received    int           `json:"received"`
	LossPercent float64       `json:"loss_percent"`
	MinMs       float64       `json:"min_ms,omitempty"`
	AvgMs       float64       `json:"avg_ms,omitempty"`
	MaxMs       float64       `json:"max_ms,omitempty"`
	Attempts    []pingAttempt `json:"attempts"`
}

func pingUsage() {
	fmt.Fprint(os.Stderr, `Usage: netlens ping <host> [<host2> ...] [flags]

Measures TCP-connect latency to one or more hosts. This is a portable
proxy for ICMP ping: real ICMP echo requires a raw socket and
administrator/root privileges, which a dependency-free, unprivileged
CLI cannot assume. NetLens instead times how long net.DialTimeout takes
to complete a TCP handshake on the given port.

ARGUMENTS:
    <host> [<host2> ...]   One or more hostnames or IP addresses

FLAGS:
    --port <n>          TCP port to connect to (default 80)
    --count <n>          Number of attempts per host (default 4)
    --timeout <dur>      Per-attempt dial timeout (default 1s)
    --interval <dur>     Delay between attempts (default 200ms)
    --json                Emit a JSON report instead of text
    -h, --help            Show this help message

Both refused connections and timed-out attempts count as loss.

EXAMPLE:
    netlens ping example.com --port 443 --count 4
`)
}

func runPing(args []string) {
	for _, a := range args {
		if a == "-h" || a == "--help" || a == "help" {
			pingUsage()
			return
		}
	}

	valueFlags := map[string]bool{"port": true, "count": true, "timeout": true, "interval": true}
	args = reorderFlags(args, valueFlags)

	fs := flag.NewFlagSet("ping", flag.ExitOnError)
	fs.Usage = pingUsage
	port := fs.Int("port", 80, "TCP port to connect to")
	count := fs.Int("count", 4, "number of attempts per host")
	timeout := fs.Duration("timeout", 1*time.Second, "per-attempt dial timeout")
	interval := fs.Duration("interval", 200*time.Millisecond, "delay between attempts")
	jsonOut := fs.Bool("json", false, "emit JSON report")
	fs.Parse(args)

	hosts := fs.Args()
	if len(hosts) < 1 {
		fmt.Fprintln(os.Stderr, "netlens ping: missing <host>")
		pingUsage()
		os.Exit(1)
	}
	if *count < 1 {
		*count = 1
	}

	var reports []pingHostReport
	for _, host := range hosts {
		reports = append(reports, pingHost(host, *port, *count, *timeout, *interval))
	}

	if *jsonOut {
		enc := json.NewEncoder(os.Stdout)
		enc.SetIndent("", "  ")
		if len(reports) == 1 {
			enc.Encode(reports[0])
		} else {
			enc.Encode(reports)
		}
		return
	}

	fmt.Println("NetLens ping uses TCP connect timing, not ICMP - see README for why.")
	fmt.Println()
	for _, r := range reports {
		printPingReport(r)
		fmt.Println()
	}
}

func pingHost(host string, port, count int, timeout, interval time.Duration) pingHostReport {
	report := pingHostReport{Host: host, Port: port, Sent: count}
	var latencies []float64

	for seq := 1; seq <= count; seq++ {
		addr := net.JoinHostPort(host, strconv.Itoa(port))
		start := time.Now()
		conn, err := net.DialTimeout("tcp", addr, timeout)
		elapsed := time.Since(start)

		if err == nil {
			conn.Close()
			lat := msf(elapsed)
			report.Attempts = append(report.Attempts, pingAttempt{Seq: seq, Success: true, LatencyMs: lat})
			latencies = append(latencies, lat)
			report.Received++
		} else {
			report.Attempts = append(report.Attempts, pingAttempt{Seq: seq, Success: false, Error: err.Error()})
		}

		if seq < count {
			time.Sleep(interval)
		}
	}

	if count > 0 {
		report.LossPercent = 100 * float64(count-report.Received) / float64(count)
	}
	if len(latencies) > 0 {
		min, max, sum := latencies[0], latencies[0], 0.0
		for _, l := range latencies {
			if l < min {
				min = l
			}
			if l > max {
				max = l
			}
			sum += l
		}
		report.MinMs = min
		report.MaxMs = max
		report.AvgMs = sum / float64(len(latencies))
	}
	return report
}

func printPingReport(r pingHostReport) {
	fmt.Printf("--- %s:%d ---\n", r.Host, r.Port)
	for _, a := range r.Attempts {
		if a.Success {
			fmt.Printf("  seq=%-3d connect from %s:%d: time=%.2fms\n", a.Seq, r.Host, r.Port, a.LatencyMs)
		} else {
			fmt.Printf("  seq=%-3d connect from %s:%d: %s\n", a.Seq, r.Host, r.Port, a.Error)
		}
	}
	if r.Received > 0 {
		fmt.Printf("%d probes sent, %d received, %.1f%% loss, min/avg/max = %.2f/%.2f/%.2f ms\n",
			r.Sent, r.Received, r.LossPercent, r.MinMs, r.AvgMs, r.MaxMs)
	} else {
		fmt.Printf("%d probes sent, %d received, %.1f%% loss\n", r.Sent, r.Received, r.LossPercent)
	}
}
