// FetchForge - a shared TEAM download queue.
//
// One machine runs "fetchforge serve", which holds a single shared queue and
// drains it with a pool of concurrent workers. Teammates on other machines
// submit URLs to that server with "fetchforge submit" and watch the whole
// team's progress with "fetchforge status".
package main

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

const version = "1.0.0"

// Item states.
const (
	stateQueued      = "queued"
	stateDownloading = "downloading"
	stateDone        = "done"
	stateFailed      = "failed"
)

const tokenHeader = "X-Fetch-Token"

// Item is one download request in the shared team queue.
type Item struct {
	ID          int    `json:"id"`
	URL         string `json:"url"`
	State       string `json:"state"`
	SubmittedBy string `json:"submitted_by"`
	SubmittedAt string `json:"submitted_at"`
	StartedAt   string `json:"started_at,omitempty"`
	FinishedAt  string `json:"finished_at,omitempty"`
	File        string `json:"file,omitempty"`
	Size        int64  `json:"size"`
	Worker      int    `json:"worker,omitempty"`
	Error       string `json:"error,omitempty"`
}

// Queue is the on-disk shape of the shared queue.
type Queue struct {
	NextID int     `json:"next_id"`
	Items  []*Item `json:"items"`
}

// Status is what GET /status returns.
type Status struct {
	Version   string         `json:"version"`
	OutDir    string         `json:"out_dir"`
	Workers   int            `json:"workers"`
	Counts    map[string]int `json:"counts"`
	Total     int            `json:"total"`
	Pending   int            `json:"pending"`
	Items     []*Item        `json:"items"`
	Timestamp string         `json:"timestamp"`
}

func usage() {
	fmt.Fprint(os.Stderr, `fetchforge `+version+` - shared TEAM download queue

One server holds ONE queue. Teammates submit to it from their own machines and
a pool of workers drains it concurrently into a shared output directory.

USAGE
  fetchforge serve  --queue <file> --out <dir> --token SECRET --port N [--workers 4]
  fetchforge submit <url> --server http://host:port --token SECRET [--as NAME]
  fetchforge status --server http://host:port --token SECRET [--json]
  fetchforge help | -h | --help

SERVE FLAGS
  --queue   <file>  shared queue state, persisted across restarts (required)
  --out     <dir>   directory downloads land in (required, created if absent)
  --token   SECRET  shared secret every client must send (required)
  --port    N       TCP port to listen on (required)
  --workers N       concurrent download workers (default 4)
  --host    ADDR    interface to bind (default 0.0.0.0, all interfaces)

SUBMIT / STATUS FLAGS
  --server  URL     base URL of the team server, e.g. http://10.0.0.5:8080
  --token   SECRET  shared secret, must match the server's --token
  --as      NAME    teammate name recorded with the item (submit only)
  --json            print the raw status JSON instead of a table (status only)

HTTP API (all endpoints require header `+tokenHeader+`: SECRET, else 401)
  POST /submit   body: {"url":"..."} or a bare URL   -> queues one item
  GET  /status   the whole team's queue plus counts
  GET  /queue    the raw persisted queue document

EXAMPLES
  # on the build box
  fetchforge serve --queue /srv/team.json --out /srv/downloads \
      --token hunter2 --port 8080 --workers 6

  # on each teammate's laptop
  fetchforge submit https://example.com/dataset.tar.gz \
      --server http://buildbox:8080 --token hunter2 --as dana
  fetchforge status --server http://buildbox:8080 --token hunter2

EXIT CODES
  0 ok    1 usage error, auth failure, network error, or a failed item
`)
}

func main() {
	args := os.Args[1:]
	if len(args) == 0 {
		// Double-clicked in Explorer rather than run from a prompt: ask the
		// questions 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 args[0] {
	case "-h", "--help", "help":
		usage()
		os.Exit(0)
	case "-v", "--version", "version":
		fmt.Println("fetchforge " + version)
		os.Exit(0)
	}

	var err error
	switch args[0] {
	case "serve":
		err = cmdServe(args[1:])
	case "submit":
		err = cmdSubmit(args[1:])
	case "status":
		err = cmdStatus(args[1:])
	default:
		fmt.Fprintf(os.Stderr, "fetchforge: unknown command %q\n\n", args[0])
		usage()
		os.Exit(1)
	}
	if err != nil {
		fmt.Fprintln(os.Stderr, "fetchforge: "+err.Error())
		os.Exit(1)
	}
}

// ---------------------------------------------------------------- shared UX

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

// newFlagSet builds a flag set that routes its own errors through usage().
func newFlagSet(name string) *flag.FlagSet {
	fs := flag.NewFlagSet(name, flag.ContinueOnError)
	fs.SetOutput(io.Discard)
	fs.Usage = usage
	return fs
}

func fail(format string, a ...any) error {
	return fmt.Errorf(format, a...)
}

func usageError(format string, a ...any) error {
	fmt.Fprintf(os.Stderr, "fetchforge: "+format+"\n\n", a...)
	usage()
	os.Exit(1)
	return nil
}

func nowStamp() string { return time.Now().UTC().Format(time.RFC3339) }

// --------------------------------------------------------------- the server

type server struct {
	mu   sync.Mutex
	cond *sync.Cond

	queue     *Queue
	queuePath string
	outDir    string
	token     string
	workers   int
	closing   bool

	// reserved guards against two workers picking the same output file name
	// for two different URLs that share a basename.
	reserved map[string]bool

	client *http.Client
}

func cmdServe(args []string) error {
	fs := newFlagSet("serve")
	queuePath := fs.String("queue", "", "shared queue file")
	outDir := fs.String("out", "", "download directory")
	token := fs.String("token", "", "shared secret")
	port := fs.Int("port", 0, "listen port")
	host := fs.String("host", "0.0.0.0", "bind address")
	workers := fs.Int("workers", 4, "worker goroutines")

	vf := map[string]bool{
		"queue": true, "out": true, "token": true,
		"port": true, "host": true, "workers": true,
	}
	if err := fs.Parse(reorderFlags(args, vf)); err != nil {
		return usageError("serve: %v", err)
	}
	if fs.NArg() > 0 {
		return usageError("serve takes no positional arguments (got %q)", fs.Arg(0))
	}
	if *queuePath == "" || *outDir == "" || *token == "" || *port == 0 {
		return usageError("serve requires --queue, --out, --token and --port")
	}
	if *port < 1 || *port > 65535 {
		return usageError("serve: --port must be 1..65535 (got %d)", *port)
	}
	if *workers < 1 {
		return usageError("serve: --workers must be at least 1 (got %d)", *workers)
	}

	absOut, err := filepath.Abs(*outDir)
	if err != nil {
		return fail("resolving --out: %v", err)
	}
	if err := os.MkdirAll(absOut, 0o755); err != nil {
		return fail("creating --out %s: %v", absOut, err)
	}

	s := &server{
		queuePath: *queuePath,
		outDir:    absOut,
		token:     *token,
		workers:   *workers,
		reserved:  map[string]bool{},
		client:    &http.Client{Timeout: 30 * time.Minute},
	}
	s.cond = sync.NewCond(&s.mu)

	if err := s.loadQueue(); err != nil {
		return fail("loading queue %s: %v", *queuePath, err)
	}

	mux := http.NewServeMux()
	mux.HandleFunc("/submit", s.auth(s.handleSubmit))
	mux.HandleFunc("/status", s.auth(s.handleStatus))
	mux.HandleFunc("/queue", s.auth(s.handleQueue))
	mux.HandleFunc("/", s.auth(s.handleRoot))

	addr := net.JoinHostPort(*host, fmt.Sprintf("%d", *port))
	ln, err := net.Listen("tcp", addr)
	if err != nil {
		return fail("listening on %s: %v", addr, err)
	}

	httpSrv := &http.Server{
		Handler:           mux,
		ReadHeaderTimeout: 15 * time.Second,
	}

	var wg sync.WaitGroup
	for i := 1; i <= s.workers; i++ {
		wg.Add(1)
		go s.worker(i, &wg)
	}

	s.mu.Lock()
	pending := s.pendingLocked()
	total := len(s.queue.Items)
	s.mu.Unlock()

	fmt.Printf("fetchforge %s serving on http://%s\n", version, addr)
	fmt.Printf("  queue   %s (%d items, %d pending)\n", s.queuePath, total, pending)
	fmt.Printf("  out     %s\n", s.outDir)
	fmt.Printf("  workers %d\n", s.workers)
	fmt.Println("  auth    " + tokenHeader + " header required on every endpoint")

	serveErr := make(chan error, 1)
	go func() {
		err := httpSrv.Serve(ln)
		if errors.Is(err, http.ErrServerClosed) {
			err = nil
		}
		serveErr <- err
	}()

	sig := make(chan os.Signal, 1)
	signal.Notify(sig, os.Interrupt, syscall.SIGTERM)

	var exitErr error
	select {
	case exitErr = <-serveErr:
	case sg := <-sig:
		fmt.Printf("\nfetchforge: %v received, shutting down\n", sg)
		if err := httpSrv.Close(); err != nil {
			fmt.Fprintf(os.Stderr, "fetchforge: closing listener: %v\n", err)
		}
		<-serveErr
	}

	s.mu.Lock()
	s.closing = true
	s.mu.Unlock()
	s.cond.Broadcast()
	wg.Wait()

	s.mu.Lock()
	err = s.persistLocked()
	s.mu.Unlock()
	if err != nil {
		fmt.Fprintf(os.Stderr, "fetchforge: final queue save: %v\n", err)
	}
	fmt.Println("fetchforge: queue saved, workers stopped")
	return exitErr
}

// ------------------------------------------------------------- persistence

func (s *server) loadQueue() error {
	s.mu.Lock()
	defer s.mu.Unlock()

	s.queue = &Queue{NextID: 1}
	data, err := os.ReadFile(s.queuePath)
	if err != nil {
		if os.IsNotExist(err) {
			return s.persistLocked()
		}
		return err
	}
	if len(strings.TrimSpace(string(data))) == 0 {
		return s.persistLocked()
	}
	var q Queue
	if err := json.Unmarshal(data, &q); err != nil {
		return fmt.Errorf("%s is not a valid fetchforge queue: %v", s.queuePath, err)
	}
	if q.NextID < 1 {
		q.NextID = 1
	}
	// Anything that was mid-flight when the server died goes back in line.
	for _, it := range q.Items {
		if it == nil {
			continue
		}
		if it.State == stateDownloading {
			it.State = stateQueued
			it.StartedAt = ""
			it.Worker = 0
		}
		if it.ID >= q.NextID {
			q.NextID = it.ID + 1
		}
	}
	kept := q.Items[:0]
	for _, it := range q.Items {
		if it != nil {
			kept = append(kept, it)
		}
	}
	q.Items = kept
	s.queue = &q
	return nil
}

// persistLocked writes the queue atomically. Caller holds s.mu.
func (s *server) persistLocked() error {
	data, err := json.MarshalIndent(s.queue, "", "  ")
	if err != nil {
		return err
	}
	data = append(data, '\n')
	tmp := s.queuePath + ".tmp"
	if err := os.WriteFile(tmp, data, 0o644); err != nil {
		return err
	}
	return os.Rename(tmp, s.queuePath)
}

func (s *server) pendingLocked() int {
	n := 0
	for _, it := range s.queue.Items {
		if it.State == stateQueued || it.State == stateDownloading {
			n++
		}
	}
	return n
}

// ------------------------------------------------------------- worker pool

func (s *server) worker(id int, wg *sync.WaitGroup) {
	defer wg.Done()
	for {
		s.mu.Lock()
		var claimed *Item
		for {
			if s.closing {
				s.mu.Unlock()
				return
			}
			for _, it := range s.queue.Items {
				if it.State == stateQueued {
					claimed = it
					break
				}
			}
			if claimed != nil {
				break
			}
			s.cond.Wait()
		}
		claimed.State = stateDownloading
		claimed.StartedAt = nowStamp()
		claimed.Worker = id
		itemID, itemURL := claimed.ID, claimed.URL
		if err := s.persistLocked(); err != nil {
			fmt.Fprintf(os.Stderr, "fetchforge: saving queue: %v\n", err)
		}
		s.mu.Unlock()

		name, size, err := s.download(itemID, itemURL)

		s.mu.Lock()
		if it := s.findLocked(itemID); it != nil {
			it.FinishedAt = nowStamp()
			if err != nil {
				it.State = stateFailed
				it.Error = err.Error()
			} else {
				it.State = stateDone
				it.File = name
				it.Size = size
				it.Error = ""
			}
		}
		if perr := s.persistLocked(); perr != nil {
			fmt.Fprintf(os.Stderr, "fetchforge: saving queue: %v\n", perr)
		}
		s.mu.Unlock()

		if err != nil {
			fmt.Printf("[worker %d] FAILED #%d %s: %v\n", id, itemID, itemURL, err)
		} else {
			fmt.Printf("[worker %d] done   #%d %s -> %s (%s)\n",
				id, itemID, itemURL, name, humanBytes(size))
		}
	}
}

func (s *server) findLocked(id int) *Item {
	for _, it := range s.queue.Items {
		if it.ID == id {
			return it
		}
	}
	return nil
}

// download fetches one URL into outDir via a .part file plus a rename.
func (s *server) download(id int, rawURL string) (string, int64, error) {
	req, err := http.NewRequest(http.MethodGet, rawURL, nil)
	if err != nil {
		return "", 0, fmt.Errorf("bad url: %v", err)
	}
	req.Header.Set("User-Agent", "fetchforge/"+version)

	resp, err := s.client.Do(req)
	if err != nil {
		return "", 0, cleanNetErr(err)
	}
	defer resp.Body.Close()

	if resp.StatusCode < 200 || resp.StatusCode > 299 {
		io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
		return "", 0, fmt.Errorf("http %d %s", resp.StatusCode,
			strings.TrimSpace(http.StatusText(resp.StatusCode)))
	}

	name := s.reserveName(id, rawURL)
	final := filepath.Join(s.outDir, name)
	part := final + ".part"

	f, err := os.Create(part)
	if err != nil {
		return "", 0, fmt.Errorf("creating %s: %v", filepath.Base(part), err)
	}
	n, err := io.Copy(f, resp.Body)
	cerr := f.Close()
	if err != nil {
		os.Remove(part)
		return "", 0, fmt.Errorf("reading body: %v", cleanNetErr(err))
	}
	if cerr != nil {
		os.Remove(part)
		return "", 0, fmt.Errorf("closing %s: %v", filepath.Base(part), cerr)
	}
	if err := os.Rename(part, final); err != nil {
		os.Remove(part)
		return "", 0, fmt.Errorf("renaming into place: %v", err)
	}
	return name, n, nil
}

// reserveName derives a safe, collision-free file name for an item.
func (s *server) reserveName(id int, rawURL string) string {
	base := ""
	if u, err := url.Parse(rawURL); err == nil {
		base = path.Base(u.Path)
	}
	base = strings.TrimSpace(base)
	if base == "." || base == "/" || base == "" {
		base = fmt.Sprintf("download-%d", id)
	}
	base = strings.Map(func(r rune) rune {
		if r == '/' || r == '\\' || r == 0 || r < 32 {
			return '_'
		}
		return r
	}, base)
	if len(base) > 180 {
		base = base[:180]
	}

	ext := filepath.Ext(base)
	stem := strings.TrimSuffix(base, ext)

	s.mu.Lock()
	defer s.mu.Unlock()
	name := base
	for attempt := 0; ; attempt++ {
		_, statErr := os.Stat(filepath.Join(s.outDir, name))
		if !s.reserved[name] && os.IsNotExist(statErr) {
			break
		}
		if attempt == 0 {
			name = fmt.Sprintf("%s-%d%s", stem, id, ext)
			continue
		}
		name = fmt.Sprintf("%s-%d-%d%s", stem, id, attempt, ext)
	}
	s.reserved[name] = true
	return name
}

func cleanNetErr(err error) error {
	msg := err.Error()
	if i := strings.Index(msg, ": dial tcp"); i >= 0 {
		msg = "connection failed" + msg[i:]
	}
	return errors.New(msg)
}

// ----------------------------------------------------------- http handlers

func (s *server) auth(next http.HandlerFunc) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		if r.Header.Get(tokenHeader) != s.token {
			w.Header().Set("Content-Type", "application/json")
			w.WriteHeader(http.StatusUnauthorized)
			json.NewEncoder(w).Encode(map[string]string{
				"error": "unauthorized: missing or wrong " + tokenHeader + " header",
			})
			return
		}
		next(w, r)
	}
}

func writeJSON(w http.ResponseWriter, code int, v any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(code)
	enc := json.NewEncoder(w)
	enc.SetIndent("", "  ")
	enc.Encode(v)
}

func httpError(w http.ResponseWriter, code int, msg string) {
	writeJSON(w, code, map[string]string{"error": msg})
}

func (s *server) handleRoot(w http.ResponseWriter, r *http.Request) {
	httpError(w, http.StatusNotFound,
		"no such endpoint "+r.URL.Path+" (try /submit, /status or /queue)")
}

func (s *server) handleSubmit(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		httpError(w, http.StatusMethodNotAllowed, "/submit requires POST")
		return
	}
	body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
	if err != nil {
		httpError(w, http.StatusBadRequest, "reading body: "+err.Error())
		return
	}

	var payload struct {
		URL string `json:"url"`
		As  string `json:"as"`
	}
	trimmed := strings.TrimSpace(string(body))
	if strings.HasPrefix(trimmed, "{") {
		if err := json.Unmarshal([]byte(trimmed), &payload); err != nil {
			httpError(w, http.StatusBadRequest, "invalid JSON body: "+err.Error())
			return
		}
	} else {
		payload.URL = trimmed
	}
	payload.URL = strings.TrimSpace(payload.URL)
	if payload.URL == "" {
		httpError(w, http.StatusBadRequest,
			`body must contain a URL, as {"url":"http://..."} or a bare URL`)
		return
	}
	if err := validateURL(payload.URL); err != nil {
		httpError(w, http.StatusBadRequest, err.Error())
		return
	}
	who := strings.TrimSpace(payload.As)
	if who == "" {
		who = "anonymous"
	}

	s.mu.Lock()
	it := &Item{
		ID:          s.queue.NextID,
		URL:         payload.URL,
		State:       stateQueued,
		SubmittedBy: who,
		SubmittedAt: nowStamp(),
	}
	s.queue.NextID++
	s.queue.Items = append(s.queue.Items, it)
	perr := s.persistLocked()
	copyOut := *it
	queued := s.pendingLocked()
	s.mu.Unlock()
	s.cond.Broadcast()

	if perr != nil {
		fmt.Fprintf(os.Stderr, "fetchforge: saving queue: %v\n", perr)
	}
	fmt.Printf("[submit] #%d %s (from %s)\n", copyOut.ID, copyOut.URL, copyOut.SubmittedBy)

	writeJSON(w, http.StatusOK, map[string]any{
		"ok":      true,
		"item":    copyOut,
		"pending": queued,
	})
}

func (s *server) handleStatus(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodGet {
		httpError(w, http.StatusMethodNotAllowed, "/status requires GET")
		return
	}
	s.mu.Lock()
	st := Status{
		Version:   version,
		OutDir:    s.outDir,
		Workers:   s.workers,
		Counts:    map[string]int{stateQueued: 0, stateDownloading: 0, stateDone: 0, stateFailed: 0},
		Total:     len(s.queue.Items),
		Pending:   s.pendingLocked(),
		Timestamp: nowStamp(),
	}
	for _, it := range s.queue.Items {
		st.Counts[it.State]++
		c := *it
		st.Items = append(st.Items, &c)
	}
	s.mu.Unlock()

	sort.Slice(st.Items, func(i, j int) bool { return st.Items[i].ID < st.Items[j].ID })
	if st.Items == nil {
		st.Items = []*Item{}
	}
	writeJSON(w, http.StatusOK, st)
}

func (s *server) handleQueue(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodGet {
		httpError(w, http.StatusMethodNotAllowed, "/queue requires GET")
		return
	}
	s.mu.Lock()
	out := Queue{NextID: s.queue.NextID}
	for _, it := range s.queue.Items {
		c := *it
		out.Items = append(out.Items, &c)
	}
	s.mu.Unlock()
	if out.Items == nil {
		out.Items = []*Item{}
	}
	writeJSON(w, http.StatusOK, out)
}

func validateURL(raw string) error {
	u, err := url.Parse(raw)
	if err != nil {
		return fmt.Errorf("not a valid URL: %v", err)
	}
	if u.Scheme != "http" && u.Scheme != "https" {
		return fmt.Errorf("only http and https URLs are supported (got %q)", raw)
	}
	if u.Host == "" {
		return fmt.Errorf("URL %q has no host", raw)
	}
	return nil
}

// ---------------------------------------------------------------- clients

type client struct {
	base  string
	token string
	http  *http.Client
}

func newClient(server, token string) (*client, error) {
	server = strings.TrimRight(strings.TrimSpace(server), "/")
	if server == "" {
		return nil, fail("--server is required (e.g. --server http://buildbox:8080)")
	}
	if !strings.Contains(server, "://") {
		server = "http://" + server
	}
	u, err := url.Parse(server)
	if err != nil || u.Host == "" {
		return nil, fail("--server %q is not a valid URL", server)
	}
	if token == "" {
		return nil, fail("--token is required and must match the server's --token")
	}
	return &client{
		base:  u.String(),
		token: token,
		http:  &http.Client{Timeout: 60 * time.Second},
	}, nil
}

// do performs one authenticated request and maps transport/HTTP faults to
// friendly errors.
func (c *client) do(method, endpoint, body string) ([]byte, error) {
	var rdr io.Reader
	if body != "" {
		rdr = strings.NewReader(body)
	}
	req, err := http.NewRequest(method, c.base+endpoint, rdr)
	if err != nil {
		return nil, fail("building request: %v", err)
	}
	req.Header.Set(tokenHeader, c.token)
	req.Header.Set("Content-Type", "application/json")

	resp, err := c.http.Do(req)
	if err != nil {
		return nil, fail("cannot reach team server at %s: %v", c.base, cleanNetErr(err))
	}
	defer resp.Body.Close()
	data, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20))
	if err != nil {
		return nil, fail("reading response from %s: %v", c.base, err)
	}
	if resp.StatusCode == http.StatusUnauthorized {
		return nil, fail("authentication rejected by %s: wrong --token "+
			"(server replied 401 Unauthorized)", c.base)
	}
	if resp.StatusCode < 200 || resp.StatusCode > 299 {
		return nil, fail("server %s replied %d %s: %s", c.base, resp.StatusCode,
			http.StatusText(resp.StatusCode), serverMessage(data))
	}
	return data, nil
}

func serverMessage(data []byte) string {
	var e struct {
		Error string `json:"error"`
	}
	if json.Unmarshal(data, &e) == nil && e.Error != "" {
		return e.Error
	}
	msg := strings.TrimSpace(string(data))
	if msg == "" {
		return "(no detail)"
	}
	if len(msg) > 300 {
		msg = msg[:300] + "..."
	}
	return msg
}

func cmdSubmit(args []string) error {
	fs := newFlagSet("submit")
	server := fs.String("server", "", "team server base URL")
	token := fs.String("token", "", "shared secret")
	as := fs.String("as", "", "teammate name")

	vf := map[string]bool{"server": true, "token": true, "as": true}
	if err := fs.Parse(reorderFlags(args, vf)); err != nil {
		return usageError("submit: %v", err)
	}
	if fs.NArg() == 0 {
		return usageError("submit needs a URL")
	}
	if fs.NArg() > 1 {
		return usageError("submit takes exactly one URL (got %d)", fs.NArg())
	}
	target := fs.Arg(0)
	if err := validateURL(target); err != nil {
		return err
	}

	c, err := newClient(*server, *token)
	if err != nil {
		return err
	}
	payload, err := json.Marshal(map[string]string{"url": target, "as": *as})
	if err != nil {
		return fail("encoding request: %v", err)
	}
	data, err := c.do(http.MethodPost, "/submit", string(payload))
	if err != nil {
		return err
	}
	var reply struct {
		Item    Item `json:"item"`
		Pending int  `json:"pending"`
	}
	if err := json.Unmarshal(data, &reply); err != nil {
		return fail("server sent an unreadable reply: %v", err)
	}
	fmt.Printf("queued #%d %s\n", reply.Item.ID, reply.Item.URL)
	fmt.Printf("  server  %s\n", c.base)
	fmt.Printf("  as      %s\n", reply.Item.SubmittedBy)
	fmt.Printf("  pending %d item(s) in the shared queue\n", reply.Pending)
	return nil
}

func cmdStatus(args []string) error {
	fs := newFlagSet("status")
	server := fs.String("server", "", "team server base URL")
	token := fs.String("token", "", "shared secret")
	asJSON := fs.Bool("json", false, "raw JSON output")

	vf := map[string]bool{"server": true, "token": true}
	if err := fs.Parse(reorderFlags(args, vf)); err != nil {
		return usageError("status: %v", err)
	}
	if fs.NArg() > 0 {
		return usageError("status takes no positional arguments (got %q)", fs.Arg(0))
	}

	c, err := newClient(*server, *token)
	if err != nil {
		return err
	}
	data, err := c.do(http.MethodGet, "/status", "")
	if err != nil {
		return err
	}
	if *asJSON {
		os.Stdout.Write(data)
		if len(data) > 0 && data[len(data)-1] != '\n' {
			fmt.Println()
		}
		return nil
	}

	var st Status
	if err := json.Unmarshal(data, &st); err != nil {
		return fail("server sent an unreadable reply: %v", err)
	}
	printStatus(c.base, &st)
	return nil
}

func printStatus(base string, st *Status) {
	fmt.Printf("team queue at %s\n", base)
	fmt.Printf("  out %s   workers %d\n\n", st.OutDir, st.Workers)

	if len(st.Items) == 0 {
		fmt.Println("  (queue is empty - nobody has submitted anything yet)")
		return
	}

	widthBy := len("BY")
	widthFile := len("FILE")
	for _, it := range st.Items {
		if len(it.SubmittedBy) > widthBy {
			widthBy = len(it.SubmittedBy)
		}
		name := it.File
		if name == "" {
			name = "-"
		}
		if len(name) > widthFile {
			widthFile = len(name)
		}
	}

	fmt.Printf("  %-4s %-12s %-*s %-*s %10s  %s\n",
		"ID", "STATE", widthBy, "BY", widthFile, "FILE", "SIZE", "URL")
	for _, it := range st.Items {
		name := it.File
		if name == "" {
			name = "-"
		}
		size := "-"
		if it.State == stateDone {
			size = humanBytes(it.Size)
		}
		fmt.Printf("  %-4d %-12s %-*s %-*s %10s  %s\n",
			it.ID, it.State, widthBy, it.SubmittedBy, widthFile, name, size, it.URL)
		if it.Error != "" {
			fmt.Printf("       reason: %s\n", it.Error)
		}
	}

	fmt.Printf("\n  %d total: %d done, %d failed, %d downloading, %d queued\n",
		st.Total, st.Counts[stateDone], st.Counts[stateFailed],
		st.Counts[stateDownloading], st.Counts[stateQueued])
	if st.Pending == 0 {
		fmt.Println("  nothing pending - the shared queue is drained")
	}
}
