// Command routewatch is a multi-site latency matrix. A probe agent runs at
// every office and writes a report file; routewatch merges every report into
// one grid whose rows are the reporting sites and whose columns are the
// targets, then separates a slow SITE (a bad row) from a slow TARGET (a bad
// column) from a single bad LINK (one cell) -- a question no single-machine
// tool can answer, because it needs several vantage points at once.
package main

import (
	"context"
	"encoding/json"
	"errors"
	"flag"
	"fmt"
	"io"
	"math"
	"net"
	"os"
	"os/signal"
	"path/filepath"
	"sort"
	"strings"
	"syscall"
	"time"
)

const version = "1.0.0"

const reportSchema = "routewatch.report/1"

// Grid cell markers.
const (
	markUnreachable = "X"
	markMissing     = "."
)

// ---------------------------------------------------------------------------
// Shared Techlosoft UX helpers (identical across the tool family).
// ---------------------------------------------------------------------------

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...)
}

func humanBytes(n int64) string {
	const unit = 1024
	if n < unit {
		return fmt.Sprintf("%d B", n)
	}
	div, exp := int64(unit), 0
	for x := n / unit; x >= unit; x /= unit {
		div *= unit
		exp++
	}
	return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp])
}

// ---------------------------------------------------------------------------
// Statistics
//
// Percentile definition (used EVERYWHERE in this program, including the
// median): NEAREST RANK on the ascending-sorted list of SUCCESSFUL samples.
//
//	rank  = ceil(p/100 * n)   clamped to [1, n]
//	value = sorted[rank-1]
//
// median = percentile(50), p95 = percentile(95). No interpolation is done, so
// every reported figure is one of the raw samples and can be found in
// raw_samples_ms. Failed samples are excluded from min/median/p95/max and are
// reported separately as loss_percent, so an unreachable target cannot corrupt
// the statistics of the reachable ones.
// ---------------------------------------------------------------------------

func percentile(sorted []float64, p float64) float64 {
	n := len(sorted)
	if n == 0 {
		return 0
	}
	rank := int(math.Ceil(p / 100.0 * float64(n)))
	if rank < 1 {
		rank = 1
	}
	if rank > n {
		rank = n
	}
	return sorted[rank-1]
}

// medianOf sorts a copy and returns percentile(50) of it.
func medianOf(vals []float64) float64 {
	if len(vals) == 0 {
		return 0
	}
	cp := append([]float64(nil), vals...)
	sort.Float64s(cp)
	return percentile(cp, 50)
}

// ---------------------------------------------------------------------------
// Target list
// ---------------------------------------------------------------------------

// Target is one endpoint a site probes.
type Target struct {
	Name string `json:"name"`
	Addr string `json:"addr"`
	// Target is accepted as an alias for addr so target lists written for the
	// sibling tools can be reused unchanged.
	TargetAlias string `json:"target,omitempty"`
}

// TargetFile is the on-disk target list.
type TargetFile struct {
	Targets []Target `json:"targets"`
}

func loadTargets(path string) ([]Target, error) {
	raw, err := os.ReadFile(path)
	if err != nil {
		if errors.Is(err, os.ErrNotExist) {
			return nil, fmt.Errorf("targets file not found: %s", path)
		}
		return nil, fmt.Errorf("cannot read targets %s: %w", path, err)
	}
	trimmed := strings.TrimSpace(string(raw))
	if trimmed == "" {
		return nil, fmt.Errorf("targets file %s is empty", path)
	}
	var list []Target
	if strings.HasPrefix(trimmed, "[") {
		if err := json.Unmarshal(raw, &list); err != nil {
			return nil, fmt.Errorf("malformed targets %s: %v", path, err)
		}
	} else {
		tf := TargetFile{}
		if err := json.Unmarshal(raw, &tf); err != nil {
			return nil, fmt.Errorf("malformed targets %s: %v", path, err)
		}
		list = tf.Targets
	}
	if len(list) == 0 {
		return nil, fmt.Errorf("targets file %s defines no targets", path)
	}
	seen := map[string]bool{}
	for i := range list {
		if list[i].Addr == "" {
			list[i].Addr = list[i].TargetAlias
		}
		list[i].TargetAlias = ""
		t := list[i]
		if strings.TrimSpace(t.Name) == "" {
			return nil, fmt.Errorf("targets %s: target #%d has no name", path, i+1)
		}
		if seen[t.Name] {
			return nil, fmt.Errorf("targets %s: duplicate target name %q", path, t.Name)
		}
		seen[t.Name] = true
		if strings.TrimSpace(t.Addr) == "" {
			return nil, fmt.Errorf("targets %s: target %q has an empty address", path, t.Name)
		}
		if _, _, err := net.SplitHostPort(t.Addr); err != nil {
			return nil, fmt.Errorf("targets %s: target %q must be host:port, got %q", path, t.Name, t.Addr)
		}
	}
	return list, nil
}

// ---------------------------------------------------------------------------
// Report format
// ---------------------------------------------------------------------------

// Link holds one site->target measurement set.
type Link struct {
	Target      string    `json:"target"`
	Addr        string    `json:"addr"`
	Samples     int       `json:"samples"`
	OK          int       `json:"ok"`
	Failed      int       `json:"failed"`
	LossPercent float64   `json:"loss_percent"`
	Reachable   bool      `json:"reachable"`
	RawSamples  []float64 `json:"raw_samples_ms"`
	MinMS       float64   `json:"min_ms"`
	MedianMS    float64   `json:"median_ms"`
	P95MS       float64   `json:"p95_ms"`
	MaxMS       float64   `json:"max_ms"`
	Reason      string    `json:"reason,omitempty"`
}

// Report is what one site's agent writes.
type Report struct {
	Schema      string  `json:"schema"`
	Version     string  `json:"routewatch_version"`
	Site        string  `json:"site"`
	GeneratedAt string  `json:"generated_at"`
	Samples     int     `json:"samples"`
	TimeoutMS   float64 `json:"timeout_ms"`
	Percentile  string  `json:"percentile_method"`
	Links       []Link  `json:"links"`

	// path/size are filled in when a report is loaded, not serialised.
	path string
	size int64
	gen  time.Time
}

func validateReport(r *Report) error {
	if r.Schema != reportSchema {
		return fmt.Errorf("wrong schema %q (want %q)", r.Schema, reportSchema)
	}
	if strings.TrimSpace(r.Site) == "" {
		return errors.New("report has no site name")
	}
	if len(r.Links) == 0 {
		return errors.New("report contains no links")
	}
	seen := map[string]bool{}
	for i, l := range r.Links {
		if strings.TrimSpace(l.Target) == "" {
			return fmt.Errorf("link #%d has no target name", i+1)
		}
		if seen[l.Target] {
			return fmt.Errorf("duplicate target %q", l.Target)
		}
		seen[l.Target] = true
		if l.Samples <= 0 {
			return fmt.Errorf("link %q records %d samples", l.Target, l.Samples)
		}
		if l.OK < 0 || l.Failed < 0 || l.OK+l.Failed != l.Samples {
			return fmt.Errorf("link %q: ok(%d)+failed(%d) != samples(%d)", l.Target, l.OK, l.Failed, l.Samples)
		}
		if l.Reachable && l.OK == 0 {
			return fmt.Errorf("link %q claims reachable with 0 successful samples", l.Target)
		}
	}
	if r.GeneratedAt != "" {
		ts, err := time.Parse(time.RFC3339Nano, r.GeneratedAt)
		if err != nil {
			return fmt.Errorf("bad generated_at %q", r.GeneratedAt)
		}
		r.gen = ts
	}
	return nil
}

func loadReport(path string) (*Report, error) {
	raw, err := os.ReadFile(path)
	if err != nil {
		if errors.Is(err, os.ErrNotExist) {
			return nil, errors.New("file not found")
		}
		return nil, fmt.Errorf("cannot read: %v", err)
	}
	if len(strings.TrimSpace(string(raw))) == 0 {
		return nil, errors.New("file is empty")
	}
	r := &Report{}
	if err := json.Unmarshal(raw, r); err != nil {
		return nil, fmt.Errorf("malformed JSON: %v", err)
	}
	if err := validateReport(r); err != nil {
		return nil, err
	}
	r.path = path
	r.size = int64(len(raw))
	return r, nil
}

// Skipped records a report file that could not be used, and why.
type Skipped struct {
	Path   string `json:"path"`
	Reason string `json:"reason"`
}

// loadReports loads every path plus every *.json in dir, isolating faults: a
// bad file is skipped with a reason and the rest still merge.
func loadReports(paths []string, dir string) ([]*Report, []Skipped, error) {
	all := append([]string(nil), paths...)
	if dir != "" {
		fi, err := os.Stat(dir)
		if err != nil {
			if errors.Is(err, os.ErrNotExist) {
				return nil, nil, fmt.Errorf("--dir not found: %s", dir)
			}
			return nil, nil, fmt.Errorf("cannot read --dir %s: %v", dir, err)
		}
		if !fi.IsDir() {
			return nil, nil, fmt.Errorf("--dir %s is not a directory", dir)
		}
		matches, err := filepath.Glob(filepath.Join(dir, "*.json"))
		if err != nil {
			return nil, nil, fmt.Errorf("cannot scan --dir %s: %v", dir, err)
		}
		sort.Strings(matches)
		all = append(all, matches...)
	}
	if len(all) == 0 {
		return nil, nil, errors.New("no report files given (pass report paths and/or --dir)")
	}
	seenPath := map[string]bool{}
	var reports []*Report
	var skipped []Skipped
	for _, p := range all {
		abs, err := filepath.Abs(p)
		if err != nil {
			abs = p
		}
		if seenPath[abs] {
			continue
		}
		seenPath[abs] = true
		r, err := loadReport(p)
		if err != nil {
			skipped = append(skipped, Skipped{Path: p, Reason: err.Error()})
			continue
		}
		reports = append(reports, r)
	}
	if len(reports) == 0 {
		return nil, skipped, errors.New("no usable reports: every input was skipped (see reasons above)")
	}
	return reports, skipped, nil
}

// ---------------------------------------------------------------------------
// probe
// ---------------------------------------------------------------------------

// sampleOnce performs one TCP connect and returns its wall-clock duration.
func sampleOnce(parent context.Context, addr string, timeout time.Duration) (float64, error) {
	ctx, cancel := context.WithTimeout(parent, timeout)
	defer cancel()
	var d net.Dialer
	start := time.Now()
	conn, err := d.DialContext(ctx, "tcp", addr)
	elapsed := time.Since(start)
	if err != nil {
		if errors.Is(err, context.DeadlineExceeded) || ctx.Err() == context.DeadlineExceeded {
			return 0, fmt.Errorf("timeout after %s", timeout)
		}
		msg := strings.ReplaceAll(err.Error(), "\n", " ")
		if len(msg) > 160 {
			msg = msg[:157] + "..."
		}
		return 0, errors.New(msg)
	}
	_ = conn.Close()
	return float64(elapsed.Microseconds()) / 1000.0, nil
}

func measureLink(ctx context.Context, t Target, samples int, timeout time.Duration) Link {
	l := Link{Target: t.Name, Addr: t.Addr, Samples: samples, RawSamples: []float64{}}
	var ok []float64
	for i := 0; i < samples; i++ {
		if ctx.Err() != nil {
			break
		}
		ms, err := sampleOnce(ctx, t.Addr, timeout)
		if err != nil {
			l.Failed++
			if l.Reason == "" {
				l.Reason = err.Error()
			}
			continue
		}
		l.OK++
		ok = append(ok, ms)
		l.RawSamples = append(l.RawSamples, ms)
	}
	l.LossPercent = 0
	if l.Samples > 0 {
		l.LossPercent = float64(l.Failed) / float64(l.Samples) * 100.0
	}
	if len(ok) == 0 {
		l.Reachable = false
		return l
	}
	l.Reachable = true
	sorted := append([]float64(nil), ok...)
	sort.Float64s(sorted)
	l.MinMS = sorted[0]
	l.MedianMS = percentile(sorted, 50)
	l.P95MS = percentile(sorted, 95)
	l.MaxMS = sorted[len(sorted)-1]
	return l
}

func cmdProbe(args []string) error {
	if wantsHelp(args) {
		usageProbe(os.Stdout)
		return nil
	}
	fs := flag.NewFlagSet("probe", flag.ContinueOnError)
	fs.SetOutput(io.Discard)
	site := fs.String("site", "", "name of the site this agent runs at")
	targetsPath := fs.String("targets", "", "path to the target list JSON")
	out := fs.String("out", "", "path of the report JSON to write")
	samples := fs.Int("samples", 5, "TCP connect samples per target")
	timeout := fs.Duration("timeout", 3*time.Second, "per-sample connect timeout")
	asJSON := fs.Bool("json", false, "emit the report JSON on stdout instead of text")
	args = reorderFlags(args, map[string]bool{"site": true, "targets": true, "out": true, "samples": true, "timeout": true})
	if err := fs.Parse(args); err != nil {
		failUsage(usageProbe, "%v", err)
	}
	if fs.NArg() > 0 {
		failUsage(usageProbe, "unexpected argument %q", fs.Arg(0))
	}
	if *site == "" {
		failUsage(usageProbe, "--site is required")
	}
	if *targetsPath == "" {
		failUsage(usageProbe, "--targets is required")
	}
	if *out == "" {
		failUsage(usageProbe, "--out is required")
	}
	if *samples <= 0 {
		failUsage(usageProbe, "--samples must be at least 1, got %d", *samples)
	}
	if *timeout <= 0 {
		failUsage(usageProbe, "--timeout must be positive")
	}
	targets, err := loadTargets(*targetsPath)
	if err != nil {
		return err
	}

	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	started := time.Now().UTC()
	links := make([]Link, len(targets))
	done := make(chan struct{}, len(targets))
	for i, t := range targets {
		go func(i int, t Target) {
			links[i] = measureLink(ctx, t, *samples, *timeout)
			done <- struct{}{}
		}(i, t)
	}
	for range targets {
		<-done
	}

	rep := Report{
		Schema:      reportSchema,
		Version:     version,
		Site:        *site,
		GeneratedAt: started.Format(time.RFC3339Nano),
		Samples:     *samples,
		TimeoutMS:   float64(timeout.Microseconds()) / 1000.0,
		Percentile:  "nearest-rank: ceil(p/100*n) over successful samples",
		Links:       links,
	}
	blob, err := json.MarshalIndent(rep, "", "  ")
	if err != nil {
		return err
	}
	blob = append(blob, '\n')
	if err := os.WriteFile(*out, blob, 0o644); err != nil {
		return fmt.Errorf("cannot write report %s: %w", *out, err)
	}
	if *asJSON {
		_, err := os.Stdout.Write(blob)
		return err
	}
	fmt.Printf("routewatch probe  site=%s  %s  (%d target(s), %d sample(s) each, timeout %s)\n",
		rep.Site, started.Format(time.RFC3339), len(links), *samples, *timeout)
	fmt.Println(strings.Repeat("-", 96))
	fmt.Printf("%-16s %-24s %8s %8s %8s %8s %7s  %s\n",
		"TARGET", "ADDRESS", "MIN ms", "MED ms", "P95 ms", "MAX ms", "LOSS%", "NOTE")
	for _, l := range links {
		note := ""
		if !l.Reachable {
			note = "UNREACHABLE: " + l.Reason
			fmt.Printf("%-16s %-24s %8s %8s %8s %8s %6.1f%%  %s\n",
				truncate(l.Target, 16), truncate(l.Addr, 24),
				markUnreachable, markUnreachable, markUnreachable, markUnreachable, l.LossPercent, note)
			continue
		}
		if l.Failed > 0 {
			note = fmt.Sprintf("partial loss (%d/%d failed): %s", l.Failed, l.Samples, l.Reason)
		}
		fmt.Printf("%-16s %-24s %8.3f %8.3f %8.3f %8.3f %6.1f%%  %s\n",
			truncate(l.Target, 16), truncate(l.Addr, 24),
			l.MinMS, l.MedianMS, l.P95MS, l.MaxMS, l.LossPercent, note)
	}
	fmt.Println(strings.Repeat("-", 96))
	fi, _ := os.Stat(*out)
	sz := int64(len(blob))
	if fi != nil {
		sz = fi.Size()
	}
	fmt.Printf("report written: %s (%s), round completed in %s\n",
		*out, humanBytes(sz), time.Since(started).Round(time.Millisecond))
	fmt.Println("measurement: TCP connect latency (not ICMP ping, not per-hop path timing)")
	return nil
}

// ---------------------------------------------------------------------------
// Grid
// ---------------------------------------------------------------------------

// Cell is one site->target entry in the merged grid.
type Cell struct {
	Present     bool    `json:"present"`
	Reachable   bool    `json:"reachable"`
	ValueMS     float64 `json:"value_ms"`
	LossPercent float64 `json:"loss_percent"`
	Samples     int     `json:"samples"`
	OK          int     `json:"ok"`
	Addr        string  `json:"addr,omitempty"`
	Reason      string  `json:"reason,omitempty"`
}

// Grid is the merged multi-site matrix.
type Grid struct {
	Metric  string
	Sites   []string
	Targets []string
	Cells   map[string]map[string]Cell
}

func metricValue(l Link, metric string) float64 {
	if metric == "p95" {
		return l.P95MS
	}
	return l.MedianMS
}

func buildGrid(reports []*Report, metric string) (*Grid, []string) {
	g := &Grid{Metric: metric, Cells: map[string]map[string]Cell{}}
	var notes []string
	siteSeen := map[string]*Report{}
	targetSeen := map[string]bool{}
	for _, r := range reports {
		if prev, ok := siteSeen[r.Site]; ok {
			// Same site reported twice: the newer report wins.
			if !r.gen.After(prev.gen) {
				notes = append(notes, fmt.Sprintf("site %q reported twice; kept %s (newer), ignored %s", r.Site, prev.path, r.path))
				continue
			}
			notes = append(notes, fmt.Sprintf("site %q reported twice; kept %s (newer), ignored %s", r.Site, r.path, prev.path))
			delete(g.Cells, r.Site)
		} else {
			g.Sites = append(g.Sites, r.Site)
		}
		siteSeen[r.Site] = r
		row := map[string]Cell{}
		for _, l := range r.Links {
			if !targetSeen[l.Target] {
				targetSeen[l.Target] = true
				g.Targets = append(g.Targets, l.Target)
			}
			row[l.Target] = Cell{
				Present:     true,
				Reachable:   l.Reachable,
				ValueMS:     metricValue(l, metric),
				LossPercent: l.LossPercent,
				Samples:     l.Samples,
				OK:          l.OK,
				Addr:        l.Addr,
				Reason:      l.Reason,
			}
		}
		g.Cells[r.Site] = row
	}
	sort.Strings(g.Sites)
	sort.Strings(g.Targets)
	return g, notes
}

func (g *Grid) at(site, target string) Cell {
	if row, ok := g.Cells[site]; ok {
		if c, ok := row[target]; ok {
			return c
		}
	}
	return Cell{}
}

// ---------------------------------------------------------------------------
// Row-vs-column diagnosis (Tukey median polish)
//
// The grid is decomposed as  cell ~= overall + site_effect + target_effect +
// residual, fitted by iterated median subtraction over rows and columns using
// the SAME nearest-rank median as everywhere else. Missing and unreachable
// cells take no part in the fit. The decomposition is what tells
// "site B is slow to everything" (one large site effect) apart from
// "everyone is slow to target X" (one large target effect) apart from
// "only this one link is bad" (one large residual).
// ---------------------------------------------------------------------------

// Diagnosis is the verdict produced from the fitted effects.
type Diagnosis struct {
	Overall        float64 `json:"overall_ms"`
	WorstSite      string  `json:"worst_site"`
	WorstSiteEff   float64 `json:"worst_site_effect_ms"`
	WorstTarget    string  `json:"worst_target"`
	WorstTargetEff float64 `json:"worst_target_effect_ms"`
	WorstLinkSite  string  `json:"worst_link_site"`
	WorstLinkTgt   string  `json:"worst_link_target"`
	WorstLinkResid float64 `json:"worst_link_residual_ms"`
	Kind           string  `json:"kind"`
	Verdict        string  `json:"verdict"`
	Unreachable    int     `json:"unreachable_cells"`
	Missing        int     `json:"missing_cells"`
	Fitted         int     `json:"fitted_cells"`
}

// Effects holds the fitted median-polish decomposition.
type Effects struct {
	Overall float64
	Site    map[string]float64
	Target  map[string]float64
	Resid   map[string]map[string]float64
}

func medianPolish(g *Grid) Effects {
	e := Effects{
		Site:   map[string]float64{},
		Target: map[string]float64{},
		Resid:  map[string]map[string]float64{},
	}
	for _, s := range g.Sites {
		e.Site[s] = 0
		e.Resid[s] = map[string]float64{}
		for _, t := range g.Targets {
			c := g.at(s, t)
			if c.Present && c.Reachable {
				e.Resid[s][t] = c.ValueMS
			}
		}
	}
	for _, t := range g.Targets {
		e.Target[t] = 0
	}
	for iter := 0; iter < 12; iter++ {
		for _, s := range g.Sites {
			var vals []float64
			for _, t := range g.Targets {
				if v, ok := e.Resid[s][t]; ok {
					vals = append(vals, v)
				}
			}
			if len(vals) == 0 {
				continue
			}
			m := medianOf(vals)
			for t := range e.Resid[s] {
				e.Resid[s][t] -= m
			}
			e.Site[s] += m
		}
		var siteEffs []float64
		for _, s := range g.Sites {
			siteEffs = append(siteEffs, e.Site[s])
		}
		m := medianOf(siteEffs)
		for _, s := range g.Sites {
			e.Site[s] -= m
		}
		e.Overall += m

		for _, t := range g.Targets {
			var vals []float64
			for _, s := range g.Sites {
				if v, ok := e.Resid[s][t]; ok {
					vals = append(vals, v)
				}
			}
			if len(vals) == 0 {
				continue
			}
			mm := medianOf(vals)
			for _, s := range g.Sites {
				if _, ok := e.Resid[s][t]; ok {
					e.Resid[s][t] -= mm
				}
			}
			e.Target[t] += mm
		}
		var tgtEffs []float64
		for _, t := range g.Targets {
			tgtEffs = append(tgtEffs, e.Target[t])
		}
		mm := medianOf(tgtEffs)
		for _, t := range g.Targets {
			e.Target[t] -= mm
		}
		e.Overall += mm
	}
	return e
}

func diagnose(g *Grid, e Effects) Diagnosis {
	d := Diagnosis{Overall: e.Overall}
	for _, s := range g.Sites {
		for _, t := range g.Targets {
			c := g.at(s, t)
			switch {
			case !c.Present:
				d.Missing++
			case !c.Reachable:
				d.Unreachable++
			default:
				d.Fitted++
			}
		}
	}
	for _, s := range g.Sites {
		if d.WorstSite == "" || e.Site[s] > d.WorstSiteEff {
			d.WorstSite, d.WorstSiteEff = s, e.Site[s]
		}
	}
	for _, t := range g.Targets {
		if d.WorstTarget == "" || e.Target[t] > d.WorstTargetEff {
			d.WorstTarget, d.WorstTargetEff = t, e.Target[t]
		}
	}
	for _, s := range g.Sites {
		for _, t := range g.Targets {
			r, ok := e.Resid[s][t]
			if !ok {
				continue
			}
			if d.WorstLinkSite == "" || r > d.WorstLinkResid {
				d.WorstLinkSite, d.WorstLinkTgt, d.WorstLinkResid = s, t, r
			}
		}
	}

	// An unreachable link outranks any amount of slowness.
	var deadSite, deadTgt string
	deadPerSite := map[string]int{}
	deadPerTgt := map[string]int{}
	dead := 0
	for _, s := range g.Sites {
		for _, t := range g.Targets {
			c := g.at(s, t)
			if c.Present && !c.Reachable {
				dead++
				deadPerSite[s]++
				deadPerTgt[t]++
				deadSite, deadTgt = s, t
			}
		}
	}
	if dead > 0 {
		switch {
		case len(g.Sites) > 1 && deadPerTgt[deadTgt] == len(g.Sites) && dead == len(g.Sites):
			d.Kind = "target-down"
			d.Verdict = fmt.Sprintf("TARGET DOWN - every one of the %d site(s) failed to reach target %q. The target is down, not the network at any one site.", len(g.Sites), deadTgt)
			return d
		case len(g.Targets) > 1 && deadPerSite[deadSite] == len(g.Targets) && dead == len(g.Targets):
			d.Kind = "site-isolated"
			d.Verdict = fmt.Sprintf("SITE ISOLATED - site %q failed to reach all %d target(s) while other sites got through. That site's egress is the suspect.", deadSite, len(g.Targets))
			return d
		default:
			d.Kind = "unreachable"
			d.Verdict = fmt.Sprintf("UNREACHABLE LINKS - %d of %d cell(s) never connected (marked %s in the grid). Fix those before reading the latency structure below.", dead, dead+d.Fitted+d.Missing, markUnreachable)
			return d
		}
	}

	if d.Fitted == 0 {
		d.Kind = "no-data"
		d.Verdict = "NO DATA - the grid holds no successful measurements to compare."
		return d
	}
	// Scale of ordinary variation. The bulk of the residual distribution is the
	// noise; an anomaly has to stand clear of it. The upper quartile is used
	// rather than the median because a sparse grid leaves many residuals at
	// exactly zero, which would drag a median-based floor down to nothing.
	var absResid []float64
	for _, s := range g.Sites {
		for _, t := range g.Targets {
			if r, ok := e.Resid[s][t]; ok {
				absResid = append(absResid, math.Abs(r))
			}
		}
	}
	sort.Float64s(absResid)
	noise := percentile(absResid, 75)
	floor := math.Max(4*noise, 0.25*math.Abs(e.Overall))
	if floor <= 0 {
		floor = 1e-9
	}

	siteWins := d.WorstSiteEff
	tgtWins := d.WorstTargetEff
	linkWins := d.WorstLinkResid

	switch {
	case siteWins < floor && tgtWins < floor && linkWins < floor:
		d.Kind = "uniform"
		d.Verdict = fmt.Sprintf("UNIFORM - no site, target or single link stands out above the %.3f ms noise floor; every link behaves like the %.3f ms baseline.", floor, e.Overall)
	case siteWins >= tgtWins && siteWins >= linkWins:
		d.Kind = "site-wide"
		d.Verdict = fmt.Sprintf("SITE-WIDE - site %q is slow to EVERYTHING (+%.3f ms on every target, vs the worst target effect +%.3f ms and the worst single link +%.3f ms). Suspect that site's own uplink, not any target.",
			d.WorstSite, siteWins, tgtWins, linkWins)
	case tgtWins >= siteWins && tgtWins >= linkWins:
		d.Kind = "target-wide"
		d.Verdict = fmt.Sprintf("TARGET-WIDE - EVERY site is slow to target %q (+%.3f ms from all of them, vs the worst site effect +%.3f ms and the worst single link +%.3f ms). Suspect the target itself, not any site.",
			d.WorstTarget, tgtWins, siteWins, linkWins)
	default:
		d.Kind = "single-link"
		d.Verdict = fmt.Sprintf("SINGLE LINK - only %s -> %s is bad (+%.3f ms beyond what that site and that target explain; worst site effect +%.3f ms, worst target effect +%.3f ms). Suspect the path between those two, not either endpoint overall.",
			d.WorstLinkSite, d.WorstLinkTgt, linkWins, siteWins, tgtWins)
	}
	return d
}

// ---------------------------------------------------------------------------
// matrix
// ---------------------------------------------------------------------------

func cellText(c Cell) string {
	if !c.Present {
		return markMissing
	}
	if !c.Reachable {
		return markUnreachable
	}
	s := fmt.Sprintf("%.2f", c.ValueMS)
	if c.LossPercent > 0 {
		s += "!"
	}
	return s
}

func cmdMatrix(args []string) error {
	if wantsHelp(args) {
		usageMatrix(os.Stdout)
		return nil
	}
	fs := flag.NewFlagSet("matrix", flag.ContinueOnError)
	fs.SetOutput(io.Discard)
	dir := fs.String("dir", "", "also ingest every *.json report in this directory")
	metric := fs.String("metric", "median", "cell metric: median or p95")
	asJSON := fs.Bool("json", false, "emit JSON instead of text")
	args = reorderFlags(args, map[string]bool{"dir": true, "metric": true})
	if err := fs.Parse(args); err != nil {
		failUsage(usageMatrix, "%v", err)
	}
	if *metric != "median" && *metric != "p95" {
		failUsage(usageMatrix, "--metric must be median or p95, got %q", *metric)
	}
	if fs.NArg() == 0 && *dir == "" {
		failUsage(usageMatrix, "no reports given: pass report paths and/or --dir")
	}
	reports, skipped, err := loadReports(fs.Args(), *dir)
	if err != nil {
		for _, s := range skipped {
			fmt.Fprintf(os.Stderr, "routewatch: skipped %s: %s\n", s.Path, s.Reason)
		}
		return err
	}
	g, notes := buildGrid(reports, *metric)
	eff := medianPolish(g)
	diag := diagnose(g, eff)
	ranked := rankLinks(g)

	var totalSize int64
	for _, r := range reports {
		totalSize += r.size
	}

	if *asJSON {
		type rowOut struct {
			Site       string           `json:"site"`
			Report     string           `json:"report"`
			Cells      map[string]*Cell `json:"cells"`
			MedianMS   float64          `json:"row_median_ms"`
			EffectMS   float64          `json:"site_effect_ms"`
			Measured   int              `json:"measured"`
			Unreach    int              `json:"unreachable"`
			MissingCnt int              `json:"missing"`
		}
		type colOut struct {
			Target     string  `json:"target"`
			MedianMS   float64 `json:"column_median_ms"`
			EffectMS   float64 `json:"target_effect_ms"`
			Measured   int     `json:"measured"`
			Unreach    int     `json:"unreachable"`
			MissingCnt int     `json:"missing"`
		}
		payload := struct {
			Schema      string      `json:"schema"`
			Metric      string      `json:"metric"`
			Percentile  string      `json:"percentile_method"`
			ReportsUsed int         `json:"reports_used"`
			ReportBytes int64       `json:"reports_bytes"`
			Sites       []string    `json:"sites"`
			Targets     []string    `json:"targets"`
			Rows        []rowOut    `json:"rows"`
			Columns     []colOut    `json:"columns"`
			Diagnosis   Diagnosis   `json:"diagnosis"`
			WorstLinks  []RankedLnk `json:"worst_links"`
			Notes       []string    `json:"notes"`
			Skipped     []Skipped   `json:"skipped"`
		}{
			Schema:      "routewatch.matrix/1",
			Metric:      *metric,
			Percentile:  "nearest-rank: ceil(p/100*n)",
			ReportsUsed: len(reports),
			ReportBytes: totalSize,
			Sites:       g.Sites,
			Targets:     g.Targets,
			Diagnosis:   diag,
			Notes:       notes,
			Skipped:     skipped,
		}
		if payload.Notes == nil {
			payload.Notes = []string{}
		}
		if payload.Skipped == nil {
			payload.Skipped = []Skipped{}
		}
		pathOf := map[string]string{}
		for _, r := range reports {
			pathOf[r.Site] = r.path
		}
		for _, s := range g.Sites {
			ro := rowOut{Site: s, Report: pathOf[s], Cells: map[string]*Cell{}}
			var vals []float64
			for _, t := range g.Targets {
				c := g.at(s, t)
				if !c.Present {
					ro.MissingCnt++
					ro.Cells[t] = nil
					continue
				}
				cc := c
				ro.Cells[t] = &cc
				if !c.Reachable {
					ro.Unreach++
					continue
				}
				ro.Measured++
				vals = append(vals, c.ValueMS)
			}
			ro.MedianMS = medianOf(vals)
			ro.EffectMS = eff.Site[s]
			payload.Rows = append(payload.Rows, ro)
		}
		for _, t := range g.Targets {
			co := colOut{Target: t, EffectMS: eff.Target[t]}
			var vals []float64
			for _, s := range g.Sites {
				c := g.at(s, t)
				if !c.Present {
					co.MissingCnt++
					continue
				}
				if !c.Reachable {
					co.Unreach++
					continue
				}
				co.Measured++
				vals = append(vals, c.ValueMS)
			}
			co.MedianMS = medianOf(vals)
			payload.Columns = append(payload.Columns, co)
		}
		payload.WorstLinks = ranked
		if len(payload.WorstLinks) > 5 {
			payload.WorstLinks = payload.WorstLinks[:5]
		}
		enc := json.NewEncoder(os.Stdout)
		enc.SetIndent("", "  ")
		return enc.Encode(payload)
	}

	for _, s := range skipped {
		fmt.Fprintf(os.Stderr, "routewatch: skipped %s: %s\n", s.Path, s.Reason)
	}
	fmt.Printf("routewatch matrix  metric=%s  %d site(s) x %d target(s)  from %d report(s) (%s)",
		*metric, len(g.Sites), len(g.Targets), len(reports), humanBytes(totalSize))
	if len(skipped) > 0 {
		fmt.Printf("  [%d skipped]", len(skipped))
	}
	fmt.Println()
	for _, n := range notes {
		fmt.Printf("note: %s\n", n)
	}

	// Column widths.
	siteW := 12
	for _, s := range g.Sites {
		if len(s) > siteW {
			siteW = len(s)
		}
	}
	if siteW > 22 {
		siteW = 22
	}
	colW := make([]int, len(g.Targets))
	for j, t := range g.Targets {
		w := len(t)
		if w < 9 {
			w = 9
		}
		if w > 18 {
			w = 18
		}
		colW[j] = w
	}
	header := fmt.Sprintf("%-*s", siteW, "SITE \\ TARGET")
	for j, t := range g.Targets {
		header += fmt.Sprintf(" %*s", colW[j], truncate(t, colW[j]))
	}
	header += fmt.Sprintf("  | %9s %9s  %s", "ROW med", "SITE eff", "ROW VERDICT")
	fmt.Println(strings.Repeat("=", len(header)))
	fmt.Println(header)
	fmt.Println(strings.Repeat("-", len(header)))

	for _, s := range g.Sites {
		line := fmt.Sprintf("%-*s", siteW, truncate(s, siteW))
		var vals []float64
		unreach, missing := 0, 0
		for j, t := range g.Targets {
			c := g.at(s, t)
			line += fmt.Sprintf(" %*s", colW[j], cellText(c))
			switch {
			case !c.Present:
				missing++
			case !c.Reachable:
				unreach++
			default:
				vals = append(vals, c.ValueMS)
			}
		}
		rowMed := medianOf(vals)
		rv := "ok"
		switch {
		case len(vals) == 0:
			rv = "no successful measurement from this site"
		case unreach > 0:
			rv = fmt.Sprintf("%d unreachable target(s) from here", unreach)
		}
		if missing > 0 {
			rv += fmt.Sprintf(" (%d target(s) not probed)", missing)
		}
		medTxt := fmt.Sprintf("%9.2f", rowMed)
		if len(vals) == 0 {
			medTxt = fmt.Sprintf("%9s", markUnreachable)
		}
		line += fmt.Sprintf("  | %s %+9.2f  %s", medTxt, eff.Site[s], rv)
		fmt.Println(line)
	}
	fmt.Println(strings.Repeat("-", len(header)))

	colMedLine := fmt.Sprintf("%-*s", siteW, "COLUMN med")
	colEffLine := fmt.Sprintf("%-*s", siteW, "TARGET eff")
	colNoteLine := fmt.Sprintf("%-*s", siteW, "COLUMN state")
	for j, t := range g.Targets {
		var vals []float64
		unreach, missing := 0, 0
		for _, s := range g.Sites {
			c := g.at(s, t)
			switch {
			case !c.Present:
				missing++
			case !c.Reachable:
				unreach++
			default:
				vals = append(vals, c.ValueMS)
			}
		}
		if len(vals) == 0 {
			colMedLine += fmt.Sprintf(" %*s", colW[j], markUnreachable)
		} else {
			colMedLine += fmt.Sprintf(" %*.2f", colW[j], medianOf(vals))
		}
		colEffLine += fmt.Sprintf(" %*s", colW[j], fmt.Sprintf("%+.2f", eff.Target[t]))
		state := "ok"
		if unreach > 0 {
			state = fmt.Sprintf("%dX", unreach)
		}
		if missing > 0 {
			state += fmt.Sprintf("/%d.", missing)
		}
		colNoteLine += fmt.Sprintf(" %*s", colW[j], state)
	}
	fmt.Println(colMedLine)
	fmt.Println(colEffLine)
	fmt.Println(colNoteLine)
	fmt.Println(strings.Repeat("=", len(header)))

	fmt.Println()
	fmt.Println("DIAGNOSIS  (cell ~= overall + site effect + target effect + residual, Tukey median polish)")
	fmt.Printf("  overall baseline link  : %8.3f ms\n", eff.Overall)
	fmt.Printf("  largest SITE effect    : %8.3f ms   site %q (slow to everything?)\n", diag.WorstSiteEff, diag.WorstSite)
	fmt.Printf("  largest TARGET effect  : %8.3f ms   target %q (slow from everywhere?)\n", diag.WorstTargetEff, diag.WorstTarget)
	if diag.WorstLinkSite != "" {
		fmt.Printf("  largest SINGLE-LINK    : %8.3f ms   %s -> %s (residual after both effects)\n",
			diag.WorstLinkResid, diag.WorstLinkSite, diag.WorstLinkTgt)
	}
	fmt.Printf("  cells: %d measured, %d unreachable, %d not probed\n", diag.Fitted, diag.Unreachable, diag.Missing)
	fmt.Println()
	fmt.Printf("  VERDICT: %s\n", diag.Verdict)
	if len(ranked) > 0 {
		w := ranked[0]
		fmt.Printf("  WORST LINK: %s -> %s  %s\n", w.Site, w.Target, w.Detail)
	}
	fmt.Println()
	fmt.Printf("legend: %s = unreachable (100%% loss)   %s = that site did not probe that target   ! = partial loss\n",
		markUnreachable, markMissing)
	fmt.Println("        cells are TCP connect latency in ms; median/p95 use nearest rank ceil(p/100*n)")
	return nil
}

// ---------------------------------------------------------------------------
// worst
// ---------------------------------------------------------------------------

// RankedLnk is one entry of the worst-links ranking.
type RankedLnk struct {
	Rank        int     `json:"rank"`
	Site        string  `json:"site"`
	Target      string  `json:"target"`
	Addr        string  `json:"addr,omitempty"`
	Reachable   bool    `json:"reachable"`
	ValueMS     float64 `json:"value_ms"`
	LossPercent float64 `json:"loss_percent"`
	Samples     int     `json:"samples"`
	OK          int     `json:"ok"`
	Reason      string  `json:"reason,omitempty"`
	Detail      string  `json:"detail"`
}

func rankLinks(g *Grid) []RankedLnk {
	var out []RankedLnk
	for _, s := range g.Sites {
		for _, t := range g.Targets {
			c := g.at(s, t)
			if !c.Present {
				continue
			}
			r := RankedLnk{
				Site: s, Target: t, Addr: c.Addr,
				Reachable: c.Reachable, ValueMS: c.ValueMS,
				LossPercent: c.LossPercent, Samples: c.Samples, OK: c.OK,
				Reason: c.Reason,
			}
			if !c.Reachable {
				r.Detail = fmt.Sprintf("UNREACHABLE, %.0f%% loss (%d/%d failed): %s",
					c.LossPercent, c.Samples-c.OK, c.Samples, c.Reason)
			} else if c.LossPercent > 0 {
				r.Detail = fmt.Sprintf("%s %.3f ms with %.0f%% loss (%d/%d failed): %s",
					g.Metric, c.ValueMS, c.LossPercent, c.Samples-c.OK, c.Samples, c.Reason)
			} else {
				r.Detail = fmt.Sprintf("%s %.3f ms over %d sample(s), 0%% loss", g.Metric, c.ValueMS, c.Samples)
			}
			out = append(out, r)
		}
	}
	sort.SliceStable(out, func(i, j int) bool {
		a, b := out[i], out[j]
		if a.Reachable != b.Reachable {
			return !a.Reachable // unreachable first
		}
		if a.LossPercent != b.LossPercent {
			return a.LossPercent > b.LossPercent
		}
		if a.ValueMS != b.ValueMS {
			return a.ValueMS > b.ValueMS
		}
		if a.Site != b.Site {
			return a.Site < b.Site
		}
		return a.Target < b.Target
	})
	for i := range out {
		out[i].Rank = i + 1
	}
	return out
}

func cmdWorst(args []string) error {
	if wantsHelp(args) {
		usageWorst(os.Stdout)
		return nil
	}
	fs := flag.NewFlagSet("worst", flag.ContinueOnError)
	fs.SetOutput(io.Discard)
	dir := fs.String("dir", "", "also ingest every *.json report in this directory")
	metric := fs.String("metric", "median", "ranking metric: median or p95")
	top := fs.Int("top", 5, "how many links to list")
	asJSON := fs.Bool("json", false, "emit JSON instead of text")
	args = reorderFlags(args, map[string]bool{"dir": true, "metric": true, "top": true})
	if err := fs.Parse(args); err != nil {
		failUsage(usageWorst, "%v", err)
	}
	if *metric != "median" && *metric != "p95" {
		failUsage(usageWorst, "--metric must be median or p95, got %q", *metric)
	}
	if *top <= 0 {
		failUsage(usageWorst, "--top must be at least 1, got %d", *top)
	}
	if fs.NArg() == 0 && *dir == "" {
		failUsage(usageWorst, "no reports given: pass report paths and/or --dir")
	}
	reports, skipped, err := loadReports(fs.Args(), *dir)
	if err != nil {
		for _, s := range skipped {
			fmt.Fprintf(os.Stderr, "routewatch: skipped %s: %s\n", s.Path, s.Reason)
		}
		return err
	}
	g, _ := buildGrid(reports, *metric)
	ranked := rankLinks(g)
	shown := ranked
	if len(shown) > *top {
		shown = shown[:*top]
	}
	if *asJSON {
		payload := struct {
			Schema      string      `json:"schema"`
			Metric      string      `json:"metric"`
			ReportsUsed int         `json:"reports_used"`
			TotalLinks  int         `json:"total_links"`
			Top         int         `json:"top"`
			Links       []RankedLnk `json:"links"`
			Skipped     []Skipped   `json:"skipped"`
		}{
			Schema: "routewatch.worst/1", Metric: *metric,
			ReportsUsed: len(reports), TotalLinks: len(ranked), Top: *top,
			Links: shown, Skipped: skipped,
		}
		if payload.Links == nil {
			payload.Links = []RankedLnk{}
		}
		if payload.Skipped == nil {
			payload.Skipped = []Skipped{}
		}
		enc := json.NewEncoder(os.Stdout)
		enc.SetIndent("", "  ")
		return enc.Encode(payload)
	}
	for _, s := range skipped {
		fmt.Fprintf(os.Stderr, "routewatch: skipped %s: %s\n", s.Path, s.Reason)
	}
	fmt.Printf("routewatch worst  metric=%s  top %d of %d link(s) from %d report(s)\n",
		*metric, len(shown), len(ranked), len(reports))
	fmt.Println(strings.Repeat("-", 104))
	fmt.Printf("%4s  %-16s %-16s %10s %8s %7s  %s\n", "RANK", "SITE", "TARGET", *metric+" ms", "LOSS%", "SAMPLES", "NOTE")
	for _, r := range shown {
		val := fmt.Sprintf("%10.3f", r.ValueMS)
		note := ""
		if !r.Reachable {
			val = fmt.Sprintf("%10s", markUnreachable)
			note = "UNREACHABLE: " + r.Reason
		} else if r.LossPercent > 0 {
			note = "partial loss: " + r.Reason
		}
		fmt.Printf("%4d  %-16s %-16s %s %7.1f%% %4d/%-3d %s\n",
			r.Rank, truncate(r.Site, 16), truncate(r.Target, 16), val,
			r.LossPercent, r.OK, r.Samples, note)
	}
	fmt.Println(strings.Repeat("-", 104))
	fmt.Println("ranking: unreachable links first, then by loss, then by the chosen metric descending")
	return nil
}

// ---------------------------------------------------------------------------
// Usage
// ---------------------------------------------------------------------------

func wantsHelp(args []string) bool {
	for _, a := range args {
		if a == "-h" || a == "--help" || a == "help" {
			return true
		}
	}
	return false
}

func failUsage(u func(io.Writer), format string, a ...any) {
	fmt.Fprintf(os.Stderr, "routewatch: "+format+"\n\n", a...)
	u(os.Stderr)
	os.Exit(1)
}

func truncate(s string, n int) string {
	if len(s) <= n {
		return s
	}
	if n <= 3 {
		return s[:n]
	}
	return s[:n-3] + "..."
}

func usage(w io.Writer) {
	fmt.Fprintf(w, `routewatch %s - multi-site latency matrix

USAGE
    routewatch <command> [flags]

COMMANDS
    probe     Agent command: measure TCP connect latency to every target and
              write this site's report file
    matrix    Merge every site's report into one grid (rows = reporting sites,
              columns = targets) and say whether a SITE, a TARGET or a single
              LINK is the problem
    worst     Rank the worst site->target links across all reports
    help      Show this help

WHY A MATRIX
    One machine can only tell you that it is slow. Several vantage points
    measuring the same targets can tell you WHOSE fault it is: a whole slow row
    means that site's uplink, a whole slow column means the target itself, and a
    single hot cell means the path between just those two.

TARGET LIST (JSON)
    {
      "targets": [
        {"name": "dc-east", "addr": "10.0.0.10:443"},
        {"name": "dc-west", "addr": "10.1.0.10:443"}
      ]
    }
    A bare JSON array of target objects is also accepted, and "target" works as
    an alias for "addr".

EXAMPLES
    routewatch probe  --site hq --targets targets.json --out reports/hq.json
    routewatch matrix reports/hq.json reports/branch.json
    routewatch matrix --dir reports --metric p95
    routewatch worst  --dir reports --top 3 --json

Run "routewatch <command> --help" for per-command flags.
`, version)
}

func usageProbe(w io.Writer) {
	fmt.Fprint(w, `routewatch probe - the agent each site runs

USAGE
    routewatch probe --site <name> --targets <targets.json> --out <report.json> [flags]

FLAGS
    --site <name>       Name of the site this agent runs at (required)
    --targets <path>    Target list JSON (required)
    --out <path>        Report JSON to write (required)
    --samples <n>       TCP connect samples per target (default 5)
    --timeout <dur>     Per-sample connect timeout (default 3s)
    --json              Print the report JSON on stdout as well as writing --out
    -h, --help          Show this help

Each sample opens a TCP connection, times how long the connect took, and closes
it. Targets are measured concurrently; the samples for one target are taken in
sequence. Failed samples are counted as loss and excluded from min/median/p95/max,
so an unreachable target cannot distort the reachable ones.

This measures TCP CONNECT latency, not ICMP ping and not per-hop path timing.
`)
}

func usageMatrix(w io.Writer) {
	fmt.Fprint(w, `routewatch matrix - merge every site's report into one grid

USAGE
    routewatch matrix <report1.json> [report2.json ...] [flags]

FLAGS
    --dir <path>        Also ingest every *.json report in this directory
    --metric <name>     Cell metric: median (default) or p95
    --json              Emit JSON instead of text
    -h, --help          Show this help

GRID
    rows      the reporting sites
    columns   the targets (the union across all reports)
    cells     the chosen metric in ms, or a marker:
                X   unreachable, 100%% loss
                .   that site did not probe that target
                !   suffix: some samples were lost

ROW vs COLUMN DIAGNOSIS
    The grid is decomposed as cell ~= overall + site effect + target effect +
    residual by Tukey median polish. Whichever term is largest names the fault:
      SITE-WIDE     one site is slow to everything      -> its uplink
      TARGET-WIDE   everyone is slow to one target      -> the target
      SINGLE LINK   one cell stands out after both      -> that path
      UNIFORM       nothing exceeds the noise floor
    Unreachable cells outrank slowness and are reported first.

A corrupt or unreadable report is skipped with a reason on stderr; the rest
still merge.
`)
}

func usageWorst(w io.Writer) {
	fmt.Fprint(w, `routewatch worst - rank the worst site->target links

USAGE
    routewatch worst <report...> [flags]

FLAGS
    --dir <path>        Also ingest every *.json report in this directory
    --metric <name>     Ranking metric: median (default) or p95
    --top <n>           How many links to list (default 5)
    --json              Emit JSON instead of text
    -h, --help          Show this help

Ordering: unreachable links first, then by loss percentage, then by the chosen
metric descending, then alphabetically for a stable result.
`)
}

func main() {
	args := os.Args[1:]
	if len(args) == 0 {
		// 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.Stderr)
		os.Exit(1)
	}
	switch args[0] {
	case "-h", "--help", "help":
		usage(os.Stdout)
		return
	case "-v", "--version", "version":
		fmt.Printf("routewatch %s\n", version)
		return
	}
	var err error
	switch args[0] {
	case "probe":
		err = cmdProbe(args[1:])
	case "matrix":
		err = cmdMatrix(args[1:])
	case "worst":
		err = cmdWorst(args[1:])
	default:
		fmt.Fprintf(os.Stderr, "routewatch: unknown command %q\n\n", args[0])
		usage(os.Stderr)
		os.Exit(1)
	}
	if err != nil {
		fmt.Fprintf(os.Stderr, "routewatch: %v\n", err)
		os.Exit(1)
	}
}
