// DropDeck - batch manifest downloader with per-host token-bucket rate
// limiting and per-host concurrency caps.
package main

import (
	"bufio"
	"context"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"errors"
	"flag"
	"fmt"
	"io"
	"math"
	"net/http"
	"net/url"
	"os"
	"os/signal"
	"path"
	"path/filepath"
	"sort"
	"strconv"
	"strings"
	"sync"
	"syscall"
	"time"
)

const version = "1.0.0"

// chunkSize is the largest single read the rate limiter will grant at once.
// Small chunks keep the token bucket smooth inside the read loop.
const chunkSize = 32 * 1024

// ---------------------------------------------------------------------------
// shared Techlosoft CLI helpers
// ---------------------------------------------------------------------------

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

// ---------------------------------------------------------------------------
// usage
// ---------------------------------------------------------------------------

const usageText = `dropdeck ` + version + ` - Smart Download Manager (batch / manifest edition)

Downloads a whole manifest of URLs at once, with a real per-host token-bucket
rate limiter and a per-host concurrency cap, so a big media playlist does not
get you throttled or banned.

USAGE
  dropdeck fetch <manifest.txt> --out <dir> [options]
  dropdeck plan  <manifest.txt> [--json]
  dropdeck help | -h | --help
  dropdeck version

COMMANDS
  fetch    Download every URL in the manifest into --out.
           DRY RUN BY DEFAULT: prints the plan and writes nothing.
           Pass --apply to actually download.
  plan     Group the manifest URLs by host and show per-host counts.

FETCH OPTIONS
  --out <dir>                 Destination directory (required).
  --rate <bytes-per-sec>      Rate limit PER HOST, in bytes/sec. 0 = unlimited
                              (default). Accepts k/m/g suffixes (1024-based),
                              e.g. --rate 500000, --rate 512k, --rate 2m.
  --per-host-concurrency <n>  Max simultaneous downloads per host (default 2).
  --timeout <seconds>         Per-request response-header timeout (default 30).
  --apply                     Actually download. Without it, nothing is written.

PLAN OPTIONS
  --json                      Emit machine-readable JSON instead of a table.

MANIFEST FORMAT
  One URL per line. Blank lines are ignored. Lines whose first non-space
  character is '#' are comments. Trailing whitespace is trimmed.

BEHAVIOUR
  * Each file is written to "<name>.part" and renamed to "<name>" only after a
    complete, successful transfer. A failed transfer leaves no .part behind.
  * A file that already exists locally with the same size the server reports is
    SKIPPED, so re-running the same fetch is idempotent and cheap.
  * One failing URL (404, connection refused, ...) does not stop the others.
  * The rate limiter is a token bucket per host applied inside the read loop,
    so two hosts in one manifest each get their own full budget.

EXIT STATUS
  0  success (or explicit help, or a dry run)
  1  bad invocation, unreadable manifest, or at least one download failed

EXAMPLES
  dropdeck plan playlist.txt
  dropdeck plan playlist.txt --json
  dropdeck fetch playlist.txt --out ./media
  dropdeck fetch playlist.txt --out ./media --rate 500000 --apply
  dropdeck fetch playlist.txt --rate 512k --per-host-concurrency 4 --out ./media --apply
`

func usage() {
	fmt.Fprint(os.Stderr, usageText)
}

func helpToStdout() {
	fmt.Fprint(os.Stdout, usageText)
}

func fail(format string, args ...any) {
	fmt.Fprintf(os.Stderr, "dropdeck: "+format+"\n", args...)
	os.Exit(1)
}

// ---------------------------------------------------------------------------
// token bucket rate limiter (per host)
// ---------------------------------------------------------------------------

// bucket is a classic token bucket. One token == one byte. Tokens accrue at
// `rate` bytes/sec up to `burst`. take() blocks until at least one token is
// available, which is what makes this a real limiter rather than a sleep
// between files.
type bucket struct {
	mu     sync.Mutex
	rate   float64 // bytes per second
	burst  float64 // max tokens held
	tokens float64
	last   time.Time
}

func newBucket(rate float64) *bucket {
	burst := rate / 10 // 100ms worth of burst
	if burst < chunkSize {
		burst = chunkSize
	}
	return &bucket{
		rate:   rate,
		burst:  burst,
		tokens: 0, // start empty: the first byte costs full price
		last:   time.Now(),
	}
}

// refill must be called with mu held.
func (b *bucket) refill(now time.Time) {
	elapsed := now.Sub(b.last).Seconds()
	if elapsed > 0 {
		b.tokens += elapsed * b.rate
		if b.tokens > b.burst {
			b.tokens = b.burst
		}
		b.last = now
	}
}

// take blocks until at least one token is available and then consumes up to
// `want` of them, returning how many were granted. It returns 0 only if ctx is
// cancelled.
func (b *bucket) take(ctx context.Context, want int) int {
	if want <= 0 {
		return 0
	}
	for {
		b.mu.Lock()
		b.refill(time.Now())
		if b.tokens >= 1 {
			n := int(math.Min(b.tokens, float64(want)))
			b.tokens -= float64(n)
			b.mu.Unlock()
			return n
		}
		deficit := 1 - b.tokens
		wait := time.Duration(deficit / b.rate * float64(time.Second))
		b.mu.Unlock()
		if wait < time.Millisecond {
			wait = time.Millisecond
		}
		timer := time.NewTimer(wait)
		select {
		case <-ctx.Done():
			timer.Stop()
			return 0
		case <-timer.C:
		}
	}
}

// refund returns unused tokens (we charge before reading, and a short read
// means we over-charged).
func (b *bucket) refund(n int) {
	if n <= 0 {
		return
	}
	b.mu.Lock()
	b.tokens += float64(n)
	if b.tokens > b.burst {
		b.tokens = b.burst
	}
	b.mu.Unlock()
}

// limitedReader wraps an io.Reader (the HTTP response body) and spends tokens
// for every byte it reads. This is the read loop the limiter lives in.
type limitedReader struct {
	ctx context.Context
	r   io.Reader
	b   *bucket
}

func (lr *limitedReader) Read(p []byte) (int, error) {
	if err := lr.ctx.Err(); err != nil {
		return 0, err
	}
	if lr.b == nil {
		return lr.r.Read(p)
	}
	if len(p) > chunkSize {
		p = p[:chunkSize]
	}
	granted := lr.b.take(lr.ctx, len(p))
	if granted <= 0 {
		return 0, context.Canceled
	}
	n, err := lr.r.Read(p[:granted])
	if n < granted {
		lr.b.refund(granted - n)
	}
	return n, err
}

// hostLimiter holds the per-host budget: one token bucket plus one semaphore.
type hostLimiter struct {
	bucket *bucket
	sem    chan struct{}
}

type limiterSet struct {
	mu          sync.Mutex
	rate        float64
	concurrency int
	byHost      map[string]*hostLimiter
}

func newLimiterSet(rate float64, concurrency int) *limiterSet {
	return &limiterSet{rate: rate, concurrency: concurrency, byHost: map[string]*hostLimiter{}}
}

func (ls *limiterSet) for_(host string) *hostLimiter {
	ls.mu.Lock()
	defer ls.mu.Unlock()
	hl, ok := ls.byHost[host]
	if !ok {
		hl = &hostLimiter{sem: make(chan struct{}, ls.concurrency)}
		if ls.rate > 0 {
			hl.bucket = newBucket(ls.rate)
		}
		ls.byHost[host] = hl
	}
	return hl
}

// ---------------------------------------------------------------------------
// manifest parsing
// ---------------------------------------------------------------------------

type entry struct {
	line int
	raw  string
	u    *url.URL
	host string
	name string // destination file name
}

type badLine struct {
	Line int    `json:"line"`
	Text string `json:"text"`
	Err  string `json:"error"`
}

type manifest struct {
	path     string
	entries  []entry
	bad      []badLine
	blank    int
	comments int
	total    int
}

func parseManifest(p string) (*manifest, error) {
	f, err := os.Open(p)
	if err != nil {
		return nil, err
	}
	defer f.Close()

	m := &manifest{path: p}
	sc := bufio.NewScanner(f)
	sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
	lineNo := 0
	for sc.Scan() {
		lineNo++
		m.total++
		raw := strings.TrimSpace(sc.Text())
		if raw == "" {
			m.blank++
			continue
		}
		if strings.HasPrefix(raw, "#") {
			m.comments++
			continue
		}
		u, err := url.Parse(raw)
		if err != nil {
			m.bad = append(m.bad, badLine{lineNo, raw, "unparseable URL: " + err.Error()})
			continue
		}
		if u.Scheme != "http" && u.Scheme != "https" {
			m.bad = append(m.bad, badLine{lineNo, raw, "unsupported scheme (want http or https)"})
			continue
		}
		if u.Host == "" {
			m.bad = append(m.bad, badLine{lineNo, raw, "missing host"})
			continue
		}
		m.entries = append(m.entries, entry{line: lineNo, raw: raw, u: u, host: u.Host})
	}
	if err := sc.Err(); err != nil {
		return nil, err
	}
	assignNames(m.entries)
	return m, nil
}

// assignNames derives a destination file name for every entry and de-duplicates
// collisions deterministically (foo.bin, foo-2.bin, foo-3.bin...).
func assignNames(entries []entry) {
	seen := map[string]int{}
	for i := range entries {
		base := path.Base(entries[i].u.Path)
		if base == "" || base == "." || base == "/" {
			base = "index"
		}
		base = filepath.Base(base) // defensive: never escape --out
		base = strings.TrimSpace(base)
		if base == "" || base == "." || base == ".." {
			base = "index"
		}
		name := base
		if n, dup := seen[base]; dup {
			ext := path.Ext(base)
			stem := strings.TrimSuffix(base, ext)
			name = fmt.Sprintf("%s-%d%s", stem, n+1, ext)
		}
		seen[base] = seen[base] + 1
		entries[i].name = name
	}
}

type hostGroup struct {
	Host  string   `json:"host"`
	Count int      `json:"count"`
	URLs  []string `json:"urls"`
}

func groupByHost(entries []entry) []hostGroup {
	idx := map[string]int{}
	var groups []hostGroup
	for _, e := range entries {
		i, ok := idx[e.host]
		if !ok {
			groups = append(groups, hostGroup{Host: e.host})
			i = len(groups) - 1
			idx[e.host] = i
		}
		groups[i].Count++
		groups[i].URLs = append(groups[i].URLs, e.raw)
	}
	sort.SliceStable(groups, func(a, b int) bool {
		if groups[a].Count != groups[b].Count {
			return groups[a].Count > groups[b].Count
		}
		return groups[a].Host < groups[b].Host
	})
	return groups
}

// ---------------------------------------------------------------------------
// rate parsing
// ---------------------------------------------------------------------------

func parseRate(s string) (float64, error) {
	s = strings.TrimSpace(s)
	if s == "" {
		return 0, nil
	}
	mult := 1.0
	low := strings.ToLower(s)
	low = strings.TrimSuffix(low, "b")
	low = strings.TrimSuffix(low, "i")
	switch {
	case strings.HasSuffix(low, "k"):
		mult, low = 1024, strings.TrimSuffix(low, "k")
	case strings.HasSuffix(low, "m"):
		mult, low = 1024*1024, strings.TrimSuffix(low, "m")
	case strings.HasSuffix(low, "g"):
		mult, low = 1024*1024*1024, strings.TrimSuffix(low, "g")
	}
	v, err := strconv.ParseFloat(strings.TrimSpace(low), 64)
	if err != nil {
		return 0, fmt.Errorf("bad rate %q (want bytes/sec, e.g. 500000 or 512k)", s)
	}
	if v < 0 {
		return 0, fmt.Errorf("rate cannot be negative")
	}
	if v > 0 && v*mult < 1 {
		return 0, fmt.Errorf("rate too small (minimum 1 byte/sec)")
	}
	return v * mult, nil
}

// ---------------------------------------------------------------------------
// main
// ---------------------------------------------------------------------------

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.Exit(1)
	}
	switch args[0] {
	case "-h", "--help", "help":
		helpToStdout()
		os.Exit(0)
	case "version", "--version", "-V":
		fmt.Println("dropdeck " + version)
		os.Exit(0)
	case "fetch":
		cmdFetch(args[1:])
	case "plan":
		cmdPlan(args[1:])
	default:
		fmt.Fprintf(os.Stderr, "dropdeck: unknown command %q\n\n", args[0])
		usage()
		os.Exit(1)
	}
}

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

// ---------------------------------------------------------------------------
// plan
// ---------------------------------------------------------------------------

type planJSON struct {
	Manifest  string      `json:"manifest"`
	TotalURLs int         `json:"total_urls"`
	HostCount int         `json:"host_count"`
	Hosts     []hostGroup `json:"hosts"`
	Skipped   struct {
		Blank    int `json:"blank"`
		Comments int `json:"comments"`
	} `json:"skipped_lines"`
	Invalid []badLine `json:"invalid"`
}

func cmdPlan(argv []string) {
	if hasHelp(argv) {
		helpToStdout()
		os.Exit(0)
	}
	valueFlags := map[string]bool{}
	argv = reorderFlags(argv, valueFlags)

	fs := flag.NewFlagSet("plan", flag.ContinueOnError)
	fs.SetOutput(io.Discard)
	asJSON := fs.Bool("json", false, "emit JSON")
	if err := fs.Parse(argv); err != nil {
		fmt.Fprintf(os.Stderr, "dropdeck: %v\n\n", err)
		usage()
		os.Exit(1)
	}
	rest := fs.Args()
	if len(rest) != 1 {
		fmt.Fprintf(os.Stderr, "dropdeck: plan needs exactly one manifest file\n\n")
		usage()
		os.Exit(1)
	}

	m, err := parseManifest(rest[0])
	if err != nil {
		fail("cannot read manifest: %v", err)
	}
	groups := groupByHost(m.entries)

	if *asJSON {
		out := planJSON{
			Manifest:  m.path,
			TotalURLs: len(m.entries),
			HostCount: len(groups),
			Hosts:     groups,
			Invalid:   m.bad,
		}
		if out.Hosts == nil {
			out.Hosts = []hostGroup{}
		}
		if out.Invalid == nil {
			out.Invalid = []badLine{}
		}
		out.Skipped.Blank = m.blank
		out.Skipped.Comments = m.comments
		enc := json.NewEncoder(os.Stdout)
		enc.SetIndent("", "  ")
		if err := enc.Encode(out); err != nil {
			fail("cannot write JSON: %v", err)
		}
		return
	}

	fmt.Printf("manifest: %s\n", m.path)
	fmt.Printf("lines:    %d total, %d blank, %d comment\n", m.total, m.blank, m.comments)
	if len(m.entries) == 0 {
		fmt.Println("\nNo downloadable URLs in this manifest. Nothing to do.")
		printBad(m.bad)
		return
	}
	fmt.Printf("urls:     %d across %d host(s)\n\n", len(m.entries), len(groups))

	width := len("HOST")
	for _, g := range groups {
		if len(g.Host) > width {
			width = len(g.Host)
		}
	}
	fmt.Printf("  %-*s  %5s\n", width, "HOST", "URLS")
	fmt.Printf("  %-*s  %5s\n", width, strings.Repeat("-", width), "-----")
	for _, g := range groups {
		fmt.Printf("  %-*s  %5d\n", width, g.Host, g.Count)
	}
	fmt.Printf("  %-*s  %5d\n", width, "TOTAL", len(m.entries))
	printBad(m.bad)
}

func printBad(bad []badLine) {
	if len(bad) == 0 {
		return
	}
	fmt.Printf("\n%d unusable line(s):\n", len(bad))
	for _, b := range bad {
		fmt.Printf("  line %d: %s (%s)\n", b.Line, b.Text, b.Err)
	}
}

// ---------------------------------------------------------------------------
// fetch
// ---------------------------------------------------------------------------

type result struct {
	e      entry
	status string // "ok", "skipped", "failed"
	bytes  int64
	dur    time.Duration
	sum    string
	err    error
}

func cmdFetch(argv []string) {
	if hasHelp(argv) {
		helpToStdout()
		os.Exit(0)
	}
	valueFlags := map[string]bool{
		"out":                  true,
		"rate":                 true,
		"per-host-concurrency": true,
		"timeout":              true,
	}
	argv = reorderFlags(argv, valueFlags)

	fs := flag.NewFlagSet("fetch", flag.ContinueOnError)
	fs.SetOutput(io.Discard)
	out := fs.String("out", "", "destination directory")
	rateStr := fs.String("rate", "0", "per-host rate limit in bytes/sec (0 = unlimited)")
	conc := fs.Int("per-host-concurrency", 2, "max simultaneous downloads per host")
	timeout := fs.Int("timeout", 30, "response-header timeout in seconds")
	apply := fs.Bool("apply", false, "actually download")
	if err := fs.Parse(argv); err != nil {
		fmt.Fprintf(os.Stderr, "dropdeck: %v\n\n", err)
		usage()
		os.Exit(1)
	}
	rest := fs.Args()
	if len(rest) != 1 {
		fmt.Fprintf(os.Stderr, "dropdeck: fetch needs exactly one manifest file\n\n")
		usage()
		os.Exit(1)
	}
	if strings.TrimSpace(*out) == "" {
		fmt.Fprintf(os.Stderr, "dropdeck: --out <dir> is required\n\n")
		usage()
		os.Exit(1)
	}
	if *conc < 1 {
		fmt.Fprintf(os.Stderr, "dropdeck: --per-host-concurrency must be >= 1\n\n")
		usage()
		os.Exit(1)
	}
	if *timeout < 1 {
		fmt.Fprintf(os.Stderr, "dropdeck: --timeout must be >= 1\n\n")
		usage()
		os.Exit(1)
	}
	rate, err := parseRate(*rateStr)
	if err != nil {
		fmt.Fprintf(os.Stderr, "dropdeck: %v\n\n", err)
		usage()
		os.Exit(1)
	}

	m, err := parseManifest(rest[0])
	if err != nil {
		fail("cannot read manifest: %v", err)
	}
	groups := groupByHost(m.entries)

	rateDesc := "unlimited"
	if rate > 0 {
		rateDesc = fmt.Sprintf("%s/s per host", humanBytes(int64(rate)))
	}

	fmt.Printf("manifest:    %s\n", m.path)
	fmt.Printf("destination: %s\n", *out)
	fmt.Printf("rate limit:  %s\n", rateDesc)
	fmt.Printf("concurrency: %d per host\n", *conc)
	fmt.Printf("lines:       %d total, %d blank, %d comment\n\n", m.total, m.blank, m.comments)

	if len(m.entries) == 0 {
		fmt.Println("No downloadable URLs in this manifest. Nothing to do.")
		printBad(m.bad)
		os.Exit(0)
	}

	fmt.Printf("plan: %d file(s) across %d host(s)\n", len(m.entries), len(groups))
	byName := map[string]string{}
	for _, e := range m.entries {
		byName[e.raw] = e.name
	}
	for _, g := range groups {
		fmt.Printf("\n  %s  (%d file(s), max %d at a time)\n", g.Host, g.Count, *conc)
		for _, raw := range g.URLs {
			name := byName[raw]
			note := ""
			if st, err := os.Stat(filepath.Join(*out, name)); err == nil && !st.IsDir() {
				note = fmt.Sprintf("  [present locally, %s]", humanBytes(st.Size()))
			}
			fmt.Printf("    %s -> %s%s\n", raw, filepath.Join(*out, name), note)
		}
	}
	printBad(m.bad)

	if !*apply {
		fmt.Printf("\nDRY RUN: nothing was downloaded and %s was not touched.\n", *out)
		fmt.Println("Re-run with --apply to actually download.")
		os.Exit(0)
	}

	if err := os.MkdirAll(*out, 0o755); err != nil {
		fail("cannot create --out directory: %v", err)
	}

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

	client := &http.Client{
		Transport: &http.Transport{
			Proxy:                 http.ProxyFromEnvironment,
			MaxIdleConns:          64,
			MaxIdleConnsPerHost:   16,
			IdleConnTimeout:       30 * time.Second,
			ResponseHeaderTimeout: time.Duration(*timeout) * time.Second,
			TLSHandshakeTimeout:   time.Duration(*timeout) * time.Second,
			ExpectContinueTimeout: 1 * time.Second,
		},
	}

	limiters := newLimiterSet(rate, *conc)
	results := make([]result, len(m.entries))
	var printMu sync.Mutex
	var wg sync.WaitGroup

	fmt.Printf("\ndownloading %d file(s)...\n\n", len(m.entries))
	started := time.Now()

	for i, e := range m.entries {
		wg.Add(1)
		go func(i int, e entry) {
			defer wg.Done()
			hl := limiters.for_(e.host)
			select {
			case hl.sem <- struct{}{}:
			case <-ctx.Done():
				results[i] = result{e: e, status: "failed", err: ctx.Err()}
				return
			}
			defer func() { <-hl.sem }()

			r := download(ctx, client, e, *out, hl)
			results[i] = r

			printMu.Lock()
			switch r.status {
			case "ok":
				speed := ""
				if r.dur > 0 {
					speed = fmt.Sprintf(" @ %s/s", humanBytes(int64(float64(r.bytes)/r.dur.Seconds())))
				}
				fmt.Printf("  ok       %-28s %10s in %6.2fs%s  sha256:%s\n",
					e.name, humanBytes(r.bytes), r.dur.Seconds(), speed, r.sum)
			case "skipped":
				fmt.Printf("  skipped  %-28s %10s  already present, size matches\n",
					e.name, humanBytes(r.bytes))
			default:
				fmt.Printf("  FAILED   %-28s %s\n", e.name, r.err)
			}
			printMu.Unlock()
		}(i, e)
	}
	wg.Wait()
	elapsed := time.Since(started)

	var okN, skipN, failN int
	var totalBytes int64
	for _, r := range results {
		switch r.status {
		case "ok":
			okN++
			totalBytes += r.bytes
		case "skipped":
			skipN++
		default:
			failN++
		}
	}

	fmt.Printf("\nsummary: %d downloaded, %d skipped, %d failed  (%s in %.2fs)\n",
		okN, skipN, failN, humanBytes(totalBytes), elapsed.Seconds())
	if failN > 0 {
		fmt.Println("\nfailures:")
		for _, r := range results {
			if r.status == "failed" {
				fmt.Printf("  %s\n    %v\n", r.e.raw, r.err)
			}
		}
		os.Exit(1)
	}
	os.Exit(0)
}

// remoteSize asks the server how big the resource is. -1 means "unknown".
func remoteSize(ctx context.Context, client *http.Client, e entry) int64 {
	req, err := http.NewRequestWithContext(ctx, http.MethodHead, e.raw, nil)
	if err != nil {
		return -1
	}
	req.Header.Set("User-Agent", "dropdeck/"+version)
	resp, err := client.Do(req)
	if err != nil {
		return -1
	}
	defer resp.Body.Close()
	io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
	if resp.StatusCode != http.StatusOK {
		return -1
	}
	return resp.ContentLength
}

func download(ctx context.Context, client *http.Client, e entry, outDir string, hl *hostLimiter) result {
	final := filepath.Join(outDir, e.name)
	part := final + ".part"

	// Idempotency: if the file is already here at the size the server reports,
	// do not fetch it again.
	if st, err := os.Stat(final); err == nil && !st.IsDir() {
		if rs := remoteSize(ctx, client, e); rs >= 0 && rs == st.Size() {
			return result{e: e, status: "skipped", bytes: st.Size()}
		}
	}

	start := time.Now()
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, e.raw, nil)
	if err != nil {
		return result{e: e, status: "failed", err: err}
	}
	req.Header.Set("User-Agent", "dropdeck/"+version)

	resp, err := client.Do(req)
	if err != nil {
		return result{e: e, status: "failed", err: cleanErr(err)}
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		io.Copy(io.Discard, io.LimitReader(resp.Body, 8192))
		// No .part file was ever created, so nothing to clean up.
		return result{e: e, status: "failed",
			err: fmt.Errorf("HTTP %s", strings.TrimSpace(resp.Status))}
	}

	f, err := os.Create(part)
	if err != nil {
		return result{e: e, status: "failed", err: err}
	}

	h := sha256.New()
	lr := &limitedReader{ctx: ctx, r: resp.Body, b: hl.bucket}
	buf := make([]byte, chunkSize)
	n, copyErr := io.CopyBuffer(io.MultiWriter(f, h), lr, buf)

	closeErr := f.Close()
	if copyErr == nil && closeErr != nil {
		copyErr = closeErr
	}
	if copyErr == nil && resp.ContentLength >= 0 && n != resp.ContentLength {
		copyErr = fmt.Errorf("short read: got %d bytes, server said %d", n, resp.ContentLength)
	}
	if copyErr != nil {
		os.Remove(part) // never leave a partial file behind
		return result{e: e, status: "failed", err: cleanErr(copyErr)}
	}
	if err := os.Rename(part, final); err != nil {
		os.Remove(part)
		return result{e: e, status: "failed", err: err}
	}
	return result{
		e:      e,
		status: "ok",
		bytes:  n,
		dur:    time.Since(start),
		sum:    hex.EncodeToString(h.Sum(nil))[:12],
	}
}

// cleanErr unwraps url.Error noise so messages stay readable.
func cleanErr(err error) error {
	var ue *url.Error
	if errors.As(err, &ue) && ue.Err != nil {
		return ue.Err
	}
	return err
}
