package main

import (
	"bytes"
	"encoding/json"
	"errors"
	"flag"
	"fmt"
	"io"
	"net/http"
	"os"
	"sort"
	"strings"
	"text/tabwriter"
	"time"
)

const defaultBaseURL = "https://uisp.vntx.net"

type Client struct {
	BaseURL string
	Token   string
	HTTP    *http.Client
}

func NewClient() (*Client, error) {
	tok := os.Getenv("UISP_KEY")
	if tok == "" {
		tok = os.Getenv("UISP_TOKEN")
	}
	if tok == "" {
		return nil, errors.New("UISP_KEY env var is not set")
	}
	base := os.Getenv("UISP_URL")
	if base == "" {
		base = defaultBaseURL
	}
	return &Client{
		BaseURL: strings.TrimRight(base, "/"),
		Token:   tok,
		HTTP:    &http.Client{Timeout: 60 * time.Second},
	}, nil
}

func (c *Client) do(method, path string, body any, out any) error {
	var rdr io.Reader
	if body != nil {
		b, err := json.Marshal(body)
		if err != nil {
			return err
		}
		rdr = bytes.NewReader(b)
	}
	url := c.BaseURL + "/nms/api/v2.1" + path
	req, err := http.NewRequest(method, url, rdr)
	if err != nil {
		return err
	}
	req.Header.Set("X-Auth-Token", c.Token)
	req.Header.Set("Accept", "application/json")
	if body != nil {
		req.Header.Set("Content-Type", "application/json")
	}
	resp, err := c.HTTP.Do(req)
	if err != nil {
		return err
	}
	defer resp.Body.Close()
	data, _ := io.ReadAll(resp.Body)
	if resp.StatusCode >= 400 {
		return fmt.Errorf("%s %s: %s: %s", method, url, resp.Status, strings.TrimSpace(string(data)))
	}
	if out != nil && len(data) > 0 {
		if err := json.Unmarshal(data, out); err != nil {
			return fmt.Errorf("decode %s: %w (body: %s)", url, err, truncate(string(data), 200))
		}
	}
	return nil
}

func firstNonEmpty(vals ...string) string {
	for _, v := range vals {
		if v != "" {
			return v
		}
	}
	return ""
}

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

// Device shapes — UISP returns rich nested JSON; we only model what we use.
type Device struct {
	Identification struct {
		ID              string `json:"id"`
		Name            string `json:"name"`
		Hostname        string `json:"hostname"`
		DisplayName     string `json:"displayName"`
		Model           string `json:"model"`
		ModelName       string `json:"modelName"`
		Type            string `json:"type"`
		Category        string `json:"category"`
		MAC             string `json:"mac"`
		Authorized      bool   `json:"authorized"`
		FirmwareVersion string `json:"firmwareVersion"`
		PlatformID      string `json:"platformId"`
		PlatformName    string `json:"platformName"`
		Site            *struct {
			ID   string `json:"id"`
			Name string `json:"name"`
		} `json:"site"`
	} `json:"identification"`
	Firmware struct {
		Current      string `json:"current"`
		Latest       string `json:"latest"`
		LatestStatus string `json:"latestStatus"`
		Compatible   bool   `json:"compatible"`
	} `json:"firmware"`
	Overview struct {
		Status       string `json:"status"`
		Unauthorized bool   `json:"unauthorized"`
		CanUpgrade   bool   `json:"canUpgrade"`
	} `json:"overview"`
	Discovery *struct {
		Status       string `json:"status"`
		Error        string `json:"error"`
		Protocol     string `json:"protocol"`
		IsProcessing bool   `json:"isProcessing"`
	} `json:"discovery"`
}

// Firmware is one entry from /nms/api/v2.1/firmwares.
// UISP wraps everything under "identification".
type Firmware struct {
	Identification struct {
		ID                    string   `json:"id"`
		Version               string   `json:"version"`
		Stable                bool     `json:"stable"`
		Lite                  bool     `json:"lite"`
		PlatformID            string   `json:"platformId"`
		Models                []string `json:"models"`
		Origin                string   `json:"origin"`
		FirmwareCompatibility string   `json:"firmwareCompatibility"`
	} `json:"identification"`
	Semver struct {
		Major int `json:"major"`
		Minor int `json:"minor"`
		Patch int `json:"patch"`
	} `json:"semver"`
}

func (c *Client) ListFirmwares() ([]Firmware, error) {
	var fws []Firmware
	if err := c.do("GET", "/firmwares", nil, &fws); err != nil {
		return nil, err
	}
	return fws, nil
}

// latestFirmwareFor finds the highest-version stable firmware matching the
// device's platformId and model. Returns "" if none found.
func latestFirmwareFor(fws []Firmware, d Device) string {
	plat := d.Identification.PlatformID
	model := d.Identification.Model
	if plat == "" || model == "" {
		return ""
	}
	var best string
	for _, fw := range fws {
		if fw.Identification.PlatformID != plat {
			continue
		}
		if !modelMatches(fw, model) {
			continue
		}
		v := fw.Identification.Version
		if best == "" || versionLess(best, v) {
			best = v
		}
	}
	return best
}

func modelMatches(fw Firmware, model string) bool {
	if len(fw.Identification.Models) == 0 {
		return true // some firmwares apply to a whole platform
	}
	for _, m := range fw.Identification.Models {
		if m == model {
			return true
		}
	}
	return false
}

// versionLess compares two firmware version strings (e.g. "6.3.24", "8.7.13").
// Returns true if a < b. Treats trailing non-numeric segments as 0.
func versionLess(a, b string) bool {
	ap, bp := parseVersion(a), parseVersion(b)
	for i := 0; i < len(ap) || i < len(bp); i++ {
		var av, bv int
		if i < len(ap) {
			av = ap[i]
		}
		if i < len(bp) {
			bv = bp[i]
		}
		if av != bv {
			return av < bv
		}
	}
	return false
}

func parseVersion(v string) []int {
	v = strings.TrimPrefix(v, "v")
	// Cut at first non-version separator like "-" or "+".
	if i := strings.IndexAny(v, "-+ "); i >= 0 {
		v = v[:i]
	}
	parts := strings.Split(v, ".")
	out := make([]int, 0, len(parts))
	for _, p := range parts {
		n := 0
		for _, r := range p {
			if r < '0' || r > '9' {
				break
			}
			n = n*10 + int(r-'0')
		}
		out = append(out, n)
	}
	return out
}

func (c *Client) ListDevices() ([]Device, error) {
	var devs []Device
	if err := c.do("GET", "/devices?withInterfaces=false", nil, &devs); err != nil {
		return nil, err
	}
	return devs, nil
}

// ListDiscovered returns devices UISP has detected but not yet adopted
// (the "Pending Adoption" list in the UI).
func (c *Client) ListDiscovered() ([]Device, error) {
	var devs []Device
	if err := c.do("GET", "/devices/discovered", nil, &devs); err != nil {
		return nil, err
	}
	return devs, nil
}

// ConnectUbnt adopts one or more discovered Ubiquiti devices using the
// supplied credentials. UISP attempts to log in with these creds; on success
// the devices appear in /devices and are assignable to a site.
func (c *Client) ConnectUbnt(deviceIDs []string, username, password string, httpsPort int) error {
	if httpsPort == 0 {
		httpsPort = 443
	}
	body := map[string]any{
		"deviceIds": deviceIDs,
		"username":  username,
		"password":  password,
		"httpsPort": httpsPort,
	}
	return c.do("POST", "/discovery/connect/ubnt", body, nil)
}

type Site struct {
	ID         string `json:"id"`
	Identification struct {
		ID   string `json:"id"`
		Name string `json:"name"`
		Type string `json:"type"`
	} `json:"identification"`
	Name string `json:"name"`
}

func (c *Client) ListSites() ([]Site, error) {
	var sites []Site
	if err := c.do("GET", "/sites", nil, &sites); err != nil {
		return nil, err
	}
	return sites, nil
}

func (c *Client) FindSiteByName(name string) (string, error) {
	sites, err := c.ListSites()
	if err != nil {
		return "", err
	}
	want := strings.ToLower(strings.TrimSpace(name))
	for _, s := range sites {
		// API returns site name under identification.name; fall back to top-level fields.
		n := s.Identification.Name
		if n == "" {
			n = s.Name
		}
		if strings.ToLower(n) == want {
			id := s.Identification.ID
			if id == "" {
				id = s.ID
			}
			return id, nil
		}
	}
	return "", fmt.Errorf("site %q not found", name)
}

// AssignDevicesToSite uses the same /devices/authorize endpoint the UI
// hits after adoption — supplying siteId + deviceIds bulk-assigns them.
func (c *Client) AssignDevicesToSite(deviceIDs []string, siteID string) error {
	body := map[string]any{
		"siteId":    siteID,
		"deviceIds": deviceIDs,
	}
	return c.do("POST", "/devices/authorize", body, nil)
}

// StandardCredentials are the canonical username/password combos used across
// the VNTX fleet. They're tried in order; first 2xx wins.
var StandardCredentials = []struct{ User, Pass string }{
	{"ubnt", "fngckewl"},
	{"ubnt", "vntx1830"},
	{"ubnt", "Vntx1830"},
	{"ubnt", "vntx1830vntx"},
}

func (c *Client) SetDeviceCredentials(id, user, pw string) error {
	body := map[string]any{"username": user, "password": pw}
	return c.do("POST", "/devices/"+id+"/credentials", body, nil)
}

// TryStandardCredentials walks the StandardCredentials list and stops on the
// first one the API accepts (2xx). Returns the user/pw that worked.
func (c *Client) TryStandardCredentials(id string) (string, string, error) {
	var lastErr error
	for _, kp := range StandardCredentials {
		if err := c.SetDeviceCredentials(id, kp.User, kp.Pass); err != nil {
			lastErr = err
			continue
		}
		return kp.User, kp.Pass, nil
	}
	if lastErr == nil {
		lastErr = errors.New("no credentials attempted")
	}
	return "", "", lastErr
}

func (c *Client) UpgradeDevice(id string) error {
	return c.do("POST", "/devices/"+id+"/system/upgrade", nil, nil)
}

func dispName(d Device) string {
	if d.Identification.Hostname != "" {
		return d.Identification.Hostname
	}
	if d.Identification.Name != "" {
		return d.Identification.Name
	}
	return d.Identification.MAC
}

func cmdList(args []string) error {
	fs := flag.NewFlagSet("list", flag.ExitOnError)
	unauth := fs.Bool("unauthorized", false, "show only unauthorized devices")
	upgradable := fs.Bool("upgradable", false, "show only devices with a firmware update available")
	jsonOut := fs.Bool("json", false, "output parsed JSON (struct shape)")
	rawOut := fs.Bool("raw", false, "dump raw JSON from the API (use to debug field paths)")
	if err := fs.Parse(args); err != nil {
		return err
	}
	c, err := NewClient()
	if err != nil {
		return err
	}
	if *rawOut {
		req, _ := http.NewRequest("GET", c.BaseURL+"/nms/api/v2.1/devices?withInterfaces=false", nil)
		req.Header.Set("X-Auth-Token", c.Token)
		req.Header.Set("Accept", "application/json")
		resp, err := c.HTTP.Do(req)
		if err != nil {
			return err
		}
		defer resp.Body.Close()
		var raw any
		if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
			return err
		}
		enc := json.NewEncoder(os.Stdout)
		enc.SetIndent("", "  ")
		return enc.Encode(raw)
	}
	var devs []Device
	if *unauth {
		devs, err = c.ListDiscovered()
	} else {
		devs, err = c.ListDevices()
	}
	if err != nil {
		return err
	}
	fws, fwErr := c.ListFirmwares()
	if fwErr != nil {
		fmt.Fprintf(os.Stderr, "warning: could not list firmwares: %v\n", fwErr)
	}
	filtered := devs[:0]
	for _, d := range devs {
		if *upgradable && !needsUpgrade(d, fws) {
			continue
		}
		filtered = append(filtered, d)
	}
	sort.Slice(filtered, func(i, j int) bool { return dispName(filtered[i]) < dispName(filtered[j]) })

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

	tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
	fmt.Fprintln(tw, "ID\tNAME\tMODEL\tSITE\tFW (cur → latest)\tSTATUS\tAUTH\tUPGRADE?")
	for _, d := range filtered {
		site := ""
		if d.Identification.Site != nil {
			site = d.Identification.Site.Name
		}
		cur := firstNonEmpty(d.Firmware.Current, d.Identification.FirmwareVersion)
		latest := firstNonEmpty(d.Firmware.Latest, latestFirmwareFor(fws, d))
		fw := cur
		if latest != "" && latest != cur && versionLess(cur, latest) {
			fw = cur + " → " + latest
		}
		if fw == "" {
			fw = "-"
		}
		auth := "yes"
		if isUnauthorized(d) {
			auth = "NO"
		}
		up := ""
		if needsUpgrade(d, fws) {
			up = "yes"
		}
		fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
			d.Identification.ID, dispName(d), d.Identification.ModelName,
			site, fw, d.Overview.Status, auth, up)
	}
	return tw.Flush()
}

func isUnauthorized(d Device) bool {
	return d.Overview.Unauthorized || !d.Identification.Authorized
}

func canUpgrade(d Device) bool {
	if d.Overview.CanUpgrade {
		return true
	}
	if d.Firmware.Latest != "" && d.Firmware.Current != "" && d.Firmware.Latest != d.Firmware.Current {
		return true
	}
	return false
}

// needsUpgrade decides whether a device should be upgraded. We require a
// real model and current version, then compare against the /firmwares
// catalog. UISP's overview.canUpgrade flag is unreliable — it stays true
// for blackBox/UNKNOWN devices we can't actually update — so we ignore it
// when there's no catalog answer.
func needsUpgrade(d Device, fws []Firmware) bool {
	if d.Identification.Type == "blackBox" {
		return false
	}
	model := d.Identification.Model
	if model == "" || model == "UNKNOWN" {
		return false
	}
	cur := firstNonEmpty(d.Firmware.Current, d.Identification.FirmwareVersion)
	if cur == "" {
		return false
	}
	latest := firstNonEmpty(d.Firmware.Latest, latestFirmwareFor(fws, d))
	if latest == "" {
		return false
	}
	return versionLess(cur, latest)
}

func cmdApprove(args []string) error {
	fs := flag.NewFlagSet("approve", flag.ExitOnError)
	all := fs.Bool("all", false, "approve all discovered (pending-adoption) devices")
	dry := fs.Bool("dry-run", false, "show what would be approved without doing it")
	site := fs.String("site", "vntx", "site name to assign devices to (empty = skip)")
	port := fs.Int("port", 443, "HTTPS port UISP uses to reach the device")
	if err := fs.Parse(args); err != nil {
		return err
	}
	c, err := NewClient()
	if err != nil {
		return err
	}

	discovered, err := c.ListDiscovered()
	if err != nil {
		return fmt.Errorf("listing discovered devices: %w", err)
	}

	// Build the working set: --all = every discovered device; otherwise the
	// IDs passed on the CLI must be in the discovered list.
	byID := make(map[string]Device, len(discovered))
	for _, d := range discovered {
		byID[d.Identification.ID] = d
	}
	var queue []Device
	if *all {
		queue = discovered
	} else {
		ids := fs.Args()
		if len(ids) == 0 {
			return errors.New("usage: uisp approve <device-id>... | --all")
		}
		for _, id := range ids {
			d, ok := byID[id]
			if !ok {
				fmt.Fprintf(os.Stderr, "skip %s: not in discovered/pending list\n", id)
				continue
			}
			queue = append(queue, d)
		}
	}

	if len(queue) == 0 {
		fmt.Println("no devices pending adoption.")
		return nil
	}

	for _, d := range queue {
		fmt.Printf("  pending: %s  %s  %s\n", d.Identification.ID, dispName(d), d.Identification.ModelName)
	}

	var siteID string
	if *site != "" {
		siteID, err = c.FindSiteByName(*site)
		if err != nil {
			return fmt.Errorf("looking up site %q: %w", *site, err)
		}
		fmt.Printf("site %q resolved to id %s\n", *site, siteID)
	}

	if *dry {
		fmt.Printf("\ndry-run: would adopt %d device(s) by trying %d credential combo(s)",
			len(queue), len(StandardCredentials))
		if siteID != "" {
			fmt.Printf(", then assign to site %q", *site)
		}
		fmt.Println()
		return nil
	}

	// Walk the credential list; each pass triggers UISP to retry connect on
	// every queued device with the new creds. UISP de-dupes — it will only
	// actually reconnect devices that haven't been adopted yet. After each
	// pass we poll discovery.isProcessing until everything settles.
	queueIDs := make([]string, 0, len(queue))
	for _, d := range queue {
		queueIDs = append(queueIDs, d.Identification.ID)
	}

	for i, kp := range StandardCredentials {
		fmt.Printf("\n[%d/%d] trying user=%s pw=%s on %d device(s)...\n",
			i+1, len(StandardCredentials), kp.User, kp.Pass, len(queueIDs))
		if err := c.ConnectUbnt(queueIDs, kp.User, kp.Pass, *port); err != nil {
			fmt.Fprintf(os.Stderr, "  connect call failed: %v\n", err)
			continue
		}
		statuses, err := waitForDiscoverySettle(c, queueIDs, 60*time.Second)
		if err != nil {
			fmt.Fprintf(os.Stderr, "  poll failed: %v\n", err)
			continue
		}
		// Stop early if every device has either succeeded or hit a state
		// that won't change with new credentials.
		stillRetryable := false
		for _, s := range statuses {
			if s.Status == "authentication-failed" || s.Status == "authenticating" || s.Status == "starting" {
				stillRetryable = true
				break
			}
		}
		if !stillRetryable {
			fmt.Println("  no more credential-retryable devices, stopping cred sweep.")
			break
		}
	}

	// Final state: re-fetch /devices (where adopted devices live) and the
	// discovered list (residual + status). A device is "adopted" if it shows
	// up in /devices with a real type (not blackBox) — UISP creates the
	// blackBox SNMP record before adoption succeeds.
	allDevs, err := c.ListDevices()
	if err != nil {
		return fmt.Errorf("post-adoption ListDevices: %w", err)
	}
	devByID := map[string]Device{}
	for _, d := range allDevs {
		devByID[d.Identification.ID] = d
	}
	finalDiscovered, _ := c.ListDiscovered()
	discByID := map[string]Device{}
	for _, d := range finalDiscovered {
		discByID[d.Identification.ID] = d
	}

	var adoptedIDs, stillFailed []string
	for _, id := range queueIDs {
		d, inFleet := devByID[id]
		if inFleet && d.Identification.Type != "blackBox" {
			adoptedIDs = append(adoptedIDs, id)
		} else {
			stillFailed = append(stillFailed, id)
		}
	}

	if len(adoptedIDs) > 0 {
		fmt.Printf("\nadopted %d device(s):\n", len(adoptedIDs))
		for _, id := range adoptedIDs {
			fmt.Printf("  %s  %s\n", id, dispName(devByID[id]))
		}
	}

	if len(stillFailed) > 0 {
		fmt.Printf("\n%d device(s) still pending:\n", len(stillFailed))
		for _, id := range stillFailed {
			d := byID[id]
			cur := discByID[id]
			status, errMsg := "?", ""
			if cur.Discovery != nil {
				status = cur.Discovery.Status
				errMsg = cur.Discovery.Error
			}
			line := fmt.Sprintf("  %s  %-32s  status=%s", id, dispName(d), status)
			if errMsg != "" {
				line += "  err=" + errMsg
			}
			fmt.Println(line)
		}
	}

	if siteID != "" && len(adoptedIDs) > 0 {
		fmt.Printf("\nassigning %d adopted device(s) to site %q...\n", len(adoptedIDs), *site)
		if err := c.AssignDevicesToSite(adoptedIDs, siteID); err != nil {
			return fmt.Errorf("site assign: %w", err)
		}
		fmt.Println("  done.")
	}
	return nil
}

// waitForDiscoverySettle polls /devices/discovered for the given IDs until
// none of them have isProcessing=true, or until the timeout fires.
func waitForDiscoverySettle(c *Client, ids []string, timeout time.Duration) (map[string]struct{ Status, Error string }, error) {
	want := make(map[string]struct{}, len(ids))
	for _, id := range ids {
		want[id] = struct{}{}
	}
	deadline := time.Now().Add(timeout)
	for {
		discovered, err := c.ListDiscovered()
		if err != nil {
			return nil, err
		}
		statuses := map[string]struct{ Status, Error string }{}
		stillProcessing := 0
		for _, d := range discovered {
			if _, ok := want[d.Identification.ID]; !ok {
				continue
			}
			if d.Discovery != nil {
				statuses[d.Identification.ID] = struct{ Status, Error string }{d.Discovery.Status, d.Discovery.Error}
				if d.Discovery.IsProcessing {
					stillProcessing++
				}
			}
		}
		if stillProcessing == 0 || time.Now().After(deadline) {
			fmt.Printf("  settled (%d still processing at timeout)\n", stillProcessing)
			return statuses, nil
		}
		fmt.Printf("  ...waiting on %d device(s)\n", stillProcessing)
		time.Sleep(5 * time.Second)
	}
}

func cmdUpgrade(args []string) error {
	fs := flag.NewFlagSet("upgrade", flag.ExitOnError)
	all := fs.Bool("all", false, "upgrade every device with an available update")
	yes := fs.Bool("yes", false, "actually perform upgrades (default is dry-run)")
	force := fs.Bool("force", false, "upgrade even if current == latest firmware")
	if err := fs.Parse(args); err != nil {
		return err
	}
	c, err := NewClient()
	if err != nil {
		return err
	}

	type target struct {
		id, name, fw string
	}
	var targets []target

	devs, err := c.ListDevices()
	if err != nil {
		return err
	}
	fws, fwErr := c.ListFirmwares()
	if fwErr != nil {
		fmt.Fprintf(os.Stderr, "warning: could not list firmwares: %v\n", fwErr)
	}
	byID := make(map[string]Device, len(devs))
	for _, d := range devs {
		byID[d.Identification.ID] = d
	}

	makeTarget := func(d Device) target {
		cur := firstNonEmpty(d.Firmware.Current, d.Identification.FirmwareVersion)
		latest := firstNonEmpty(d.Firmware.Latest, latestFirmwareFor(fws, d))
		arrow := cur
		if latest != "" && latest != cur {
			arrow = cur + " → " + latest
		}
		return target{id: d.Identification.ID, name: dispName(d), fw: arrow}
	}

	if *all {
		for _, d := range devs {
			if !needsUpgrade(d, fws) {
				continue
			}
			targets = append(targets, makeTarget(d))
		}
	} else {
		ids := fs.Args()
		if len(ids) == 0 {
			return errors.New("usage: uisp upgrade <device-id>... [--yes] [--force] | --all [--yes]")
		}
		for _, id := range ids {
			d, ok := byID[id]
			if !ok {
				fmt.Fprintf(os.Stderr, "skip %s: device not found\n", id)
				continue
			}
			if !needsUpgrade(d, fws) && !*force {
				cur := firstNonEmpty(d.Firmware.Current, d.Identification.FirmwareVersion)
				latest := firstNonEmpty(d.Firmware.Latest, latestFirmwareFor(fws, d))
				fmt.Fprintf(os.Stderr, "skip %s (%s): already on %s (latest %s) — pass --force to override\n",
					id, dispName(d), cur, firstNonEmpty(latest, "?"))
				continue
			}
			targets = append(targets, makeTarget(d))
		}
	}

	if len(targets) == 0 {
		fmt.Println("no devices need upgrade.")
		return nil
	}

	for _, t := range targets {
		fmt.Printf("  %s  %s  %s\n", t.id, t.name, t.fw)
	}
	if !*yes {
		fmt.Printf("\ndry-run: %d device(s) would be upgraded. Pass --yes to execute.\n", len(targets))
		return nil
	}

	var failed int
	for _, t := range targets {
		if err := c.UpgradeDevice(t.id); err != nil {
			fmt.Fprintf(os.Stderr, "FAIL %s: %v\n", t.id, err)
			failed++
			continue
		}
		fmt.Printf("OK   %s upgrade triggered\n", t.id)
	}
	if failed > 0 {
		return fmt.Errorf("%d/%d upgrades failed", failed, len(targets))
	}
	return nil
}

func cmdCreds(args []string) error {
	fs := flag.NewFlagSet("creds", flag.ExitOnError)
	all := fs.Bool("all", false, "try standard creds against every device")
	user := fs.String("user", "", "single username (overrides standard list)")
	pass := fs.String("pass", "", "single password (used with --user)")
	if err := fs.Parse(args); err != nil {
		return err
	}
	c, err := NewClient()
	if err != nil {
		return err
	}

	var ids []string
	if *all {
		devs, err := c.ListDevices()
		if err != nil {
			return err
		}
		for _, d := range devs {
			ids = append(ids, d.Identification.ID)
		}
	} else {
		ids = fs.Args()
		if len(ids) == 0 {
			return errors.New("usage: uisp creds <device-id>... | --all  [--user U --pass P]")
		}
	}

	manual := *user != "" || *pass != ""
	if manual && (*user == "" || *pass == "") {
		return errors.New("--user and --pass must be used together")
	}

	for _, id := range ids {
		if manual {
			if err := c.SetDeviceCredentials(id, *user, *pass); err != nil {
				fmt.Fprintf(os.Stderr, "FAIL %s: %v\n", id, err)
				continue
			}
			fmt.Printf("OK   %s  (user=%s)\n", id, *user)
			continue
		}
		u, p, err := c.TryStandardCredentials(id)
		if err != nil {
			fmt.Fprintf(os.Stderr, "FAIL %s: %v\n", id, err)
			continue
		}
		fmt.Printf("OK   %s  (user=%s pw=%s)\n", id, u, p)
	}
	return nil
}

func usage() {
	fmt.Fprint(os.Stderr, `uisp - CLI for UISP at $UISP_URL (default `+defaultBaseURL+`)

Auth:  export UISP_KEY=<x-auth-token from UISP user settings>

Commands:
  list    [--unauthorized] [--upgradable] [--json]
  approve <device-id>... | --all  [--site vntx] [--try-creds] [--dry-run]
  creds   <device-id>... | --all  [--user U --pass P]
  upgrade <device-id>... | --all  [--yes]   (dry-run unless --yes)

Examples:
  uisp list --upgradable
  uisp approve --all                         # approve, assign to "vntx", try standard creds
  uisp approve --all --site vntx --dry-run
  uisp creds --all                           # retry standard creds across the fleet
  uisp upgrade --all --yes
`)
}

func main() {
	if len(os.Args) < 2 {
		usage()
		os.Exit(2)
	}
	var err error
	switch os.Args[1] {
	case "list", "ls":
		err = cmdList(os.Args[2:])
	case "approve":
		err = cmdApprove(os.Args[2:])
	case "creds":
		err = cmdCreds(os.Args[2:])
	case "upgrade":
		err = cmdUpgrade(os.Args[2:])
	case "-h", "--help", "help":
		usage()
		return
	default:
		usage()
		os.Exit(2)
	}
	if err != nil {
		fmt.Fprintln(os.Stderr, "error:", err)
		os.Exit(1)
	}
}
