// download-all — 下载 Firefox Translations (bergamot-translator) 模型文件，
// 部署到 https://download.freedictionary.cn/bergamot-models/。
//
// 仿照 ../wiktionary-raw-data/download-all.sh，用 Go 实现，仅用标准库。
// 模型注册表（与仓库自带 download-models.py 相同的来源）：
//
//	https://storage.googleapis.com/moz-fx-translations-data--303e-prod-translations-data/db/models.json
//
// 行为：
//   - 对注册表里的每个语言对，默认下载其全部可用架构（base / base-memory /
//     tiny…）；-arch 可逗号分隔多选过滤。同一架构有多个候选时，优先选择
//     releaseStatus 含 "Release" 的（否则取第一个，与 download-models.py
//     的选择逻辑一致）。
//   - 默认输出到当前目录（相对路径，-out 可覆盖）。不同架构的模型
//     文件同名但内容不同，因此落盘布局为 <pair>/<arch>/<file>；每次运行
//     还会把注册表快照存为 models.json。
//   - 下载模型权重、词表（共享 vocab 或 srcVocab/trgVocab）与 lexical
//     shortlist，自动解压 .gz，并对带 uncompressedHash 的文件做 sha256 校验。
//   - 与 wiktionary 脚本一致：先写 <name>.tmp，校验通过后再重命名；
//     每个文件生成 <name>.sha256（sha256sum 格式）。
//   - 额外下载 nonbreaking_prefix.<lang>（ssplit 句子切分用，注册表不提供，
//     取自 mosesdecoder），与仓库测试期望的 models/<pair>/nonbreaking_prefix.*
//     布局对应；上游没有该语言的文件时静默跳过。
//   - 已存在且校验通过的文件自动跳过，便于定期增量更新；-force 强制重下。
//   - 代理：通过 http.ProxyFromEnvironment 支持 HTTP_PROXY / HTTPS_PROXY /
//     NO_PROXY 环境变量。
//
// 用法：
//
//	go build -o download-all .
//	go run download-all.go                    # 下载到当前目录
//	./download-all                            # 下载全部语言对的全部架构到当前目录
//	./download-all -list                      # 仅列出语言对 × 架构，不下载
//	./download-all -arch base-memory,tiny     # 只要指定架构（多选）
//	./download-all -pairs en-de,en-zh         # 只下载指定语言对
//	HTTPS_PROXY=http://127.0.0.1:7890 ./download-all
package main

import (
	"compress/gzip"
	"context"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"errors"
	"flag"
	"fmt"
	"io"
	"log"
	"net"
	"net/http"
	"net/url"
	"os"
	"os/signal"
	"path/filepath"
	"sort"
	"strings"
	"sync"
	"sync/atomic"
	"syscall"
	"time"
)

const (
	defaultRegistryURL = "https://storage.googleapis.com/moz-fx-translations-data--303e-prod-translations-data/db/models.json"
	fallbackBaseURL    = "https://storage.googleapis.com/moz-fx-translations-data--303e-prod-translations-data"

	// nonbreaking_prefix.<lang> 的来源（ssplit 句子切分的 moses 前缀表）
	defaultPrefixBaseURL = "https://raw.githubusercontent.com/moses-smt/mosesdecoder/master/scripts/share/nonbreaking_prefixes"

	// 默认输出到当前目录（相对路径）：无论是编译好的二进制放在
	// /bergamot-models/ 里运行，还是 `go run download-all.go`，
	// 文件都落在运行时所在目录；-out 可覆盖。
	defaultOutDir = "."
)

// 文件下载顺序（与 download-models.py 的 FILE_ORDER 一致）
var fileOrder = []string{"model", "vocab", "srcVocab", "trgVocab", "lexicalShortlist"}

type modelFile struct {
	Path             string `json:"path"`
	UncompressedHash string `json:"uncompressedHash"`
}

type candidate struct {
	Architecture   string                `json:"architecture"`
	ReleaseStatus  string                `json:"releaseStatus"`
	SourceLanguage string                `json:"sourceLanguage"`
	TargetLanguage string                `json:"targetLanguage"`
	Files          map[string]*modelFile `json:"files"`
}

type registryFile struct {
	BaseURL string                  `json:"baseUrl"`
	Models  map[string][]*candidate `json:"models"`
}

// job 是一个待落盘的文件，目标路径为 <out>/<pair>/<arch>/<name>
type job struct {
	pair     string // 语言对
	arch     string // 架构（base / base-memory / tiny…）
	name     string // 目标文件名（已去掉 .gz 后缀）
	remote   string // 下载 URL
	fallback string // 404 时尝试的备用 URL（prefix 语言的回退，如 zh_hant -> zh）
	gzipped  bool   // 是否需要解压
	wantHash string // 解压后内容的 sha256（注册表 uncompressedHash，可为空）
}

func main() {
	log.SetFlags(log.Ltime)

	outDir := flag.String("out", defaultOutDir, "输出目录（默认：当前目录）")
	pairsFlag := flag.String("pairs", "", "逗号分隔的语言对列表（如 en-de,en-zh），缺省为全部")
	archFlag := flag.String("arch", "", "逗号分隔的架构列表（base / base-memory / tiny…），可多选，缺省为全部架构")
	workers := flag.Int("j", 4, "并发下载数")
	listOnly := flag.Bool("list", false, "仅列出语言对及所选模型，不下载")
	force := flag.Bool("force", false, "忽略已有文件，强制重新下载")
	registryURL := flag.String("registry", defaultRegistryURL, "模型注册表 models.json 的 URL")
	baseURLFlag := flag.String("base-url", "", "覆盖注册表 baseUrl（默认用注册表自带值）")
	noPrefixes := flag.Bool("no-prefixes", false, "不下载 nonbreaking_prefix.* 文件")
	prefixBase := flag.String("prefix-base", defaultPrefixBaseURL, "nonbreaking_prefix.* 文件的基 URL")
	flag.Parse()

	// 与 wiktionary 脚本的 `cd "$(dirname "$0")"` 对应：默认写到脚本所在目录
	absOut, err := filepath.Abs(*outDir)
	if err != nil {
		log.Fatalf("解析输出目录失败: %v", err)
	}

	if p := proxyDescription(); p != "" {
		log.Printf("Proxy: 使用 %s", p)
	} else {
		log.Printf("Proxy: 未设置，直连")
	}
	log.Printf("Output: %s", absOut)

	client := newHTTPClient()

	// ── 拉取注册表 ────────────────────────────────────────────────
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	log.Printf("Fetching registry: %s", *registryURL)
	regBytes, err := fetchAll(ctx, client, *registryURL)
	if err != nil {
		log.Fatalf("拉取注册表失败: %v", err)
	}

	var reg registryFile
	if err := json.Unmarshal(regBytes, &reg); err != nil {
		log.Fatalf("解析注册表失败: %v", err)
	}
	baseURL := reg.BaseURL
	if baseURL == "" {
		baseURL = fallbackBaseURL
	}
	if *baseURLFlag != "" {
		baseURL = *baseURLFlag
	}

	// ── 架构过滤：多选，缺省为全部 ────────────────────────────────
	archFilter := parseList(*archFlag)
	if unknown := unknownArchs(reg.Models, archFilter); len(unknown) > 0 {
		fmt.Fprintf(os.Stderr, "错误: 注册表中不存在架构 %s\n可用架构: %s\n",
			strings.Join(unknown, ", "), strings.Join(allArchs(reg.Models), " "))
		os.Exit(2)
	}

	pairs, unknown := selectPairs(reg.Models, *pairsFlag)
	if len(unknown) > 0 {
		fmt.Fprintf(os.Stderr, "错误: 注册表中不存在语言对 %s\n可用语言对 (%d 个):\n  %s\n",
			strings.Join(unknown, ", "), len(reg.Models), strings.Join(sortedPairs(reg.Models), " "))
		os.Exit(2)
	}
	if len(pairs) == 0 {
		log.Fatalf("注册表中没有语言对")
	}

	// -list 不写任何盘；正式下载前建好输出目录并留下注册表快照
	if !*listOnly {
		if err := os.MkdirAll(absOut, 0o755); err != nil {
			log.Fatalf("创建输出目录 %s 失败: %v（本地测试请用 -out 指定其他目录）", absOut, err)
		}
		// 留一份注册表快照作为镜像的出处记录
		if err := os.WriteFile(filepath.Join(absOut, "models.json"), regBytes, 0o644); err != nil {
			log.Fatalf("写入注册表快照失败: %v", err)
		}
	}

	// ── 为每个语言对 × 架构选择模型并构造任务 ─────────────────────
	var jobs []job
	skippedPairs := 0
	explicit := *pairsFlag != ""
	for _, pair := range pairs {
		cands := reg.Models[pair]
		selected := selectArches(cands, archFilter) // 过滤器为空时返回全部架构
		if len(selected) == 0 {
			if explicit {
				fmt.Fprintf(os.Stderr, "错误: 语言对 %s 没有架构 %q 的模型。可选架构: %s\n",
					pair, strings.Join(archFilter, ", "), strings.Join(archesOf(cands), ", "))
				os.Exit(2)
			}
			log.Printf("[%s] 跳过: 没有架构 %q 的模型", pair, strings.Join(archFilter, ", "))
			skippedPairs++
			continue
		}
		for _, arch := range selected {
			chosen, err := chooseCandidate(cands, arch)
			if err != nil {
				fmt.Fprintf(os.Stderr, "错误: %v\n", err)
				os.Exit(2)
			}
			if *listOnly {
				names := make([]string, 0, len(chosen.Files))
				for _, kind := range fileOrder {
					if f := chosen.Files[kind]; f != nil && f.Path != "" {
						names = append(names, destName(f.Path))
					}
				}
				log.Printf("%-12s %-13s %-16s %2d files  %s",
					pair, arch, chosen.ReleaseStatus, len(names), strings.Join(names, " "))
				continue
			}
			for _, kind := range fileOrder {
				f := chosen.Files[kind]
				if f == nil || f.Path == "" {
					continue
				}
				jobs = append(jobs, job{
					pair:     pair,
					arch:     arch,
					name:     destName(f.Path),
					remote:   baseURL + "/" + f.Path,
					gzipped:  strings.HasSuffix(f.Path, ".gz"),
					wantHash: strings.ToLower(f.UncompressedHash),
				})
			}
			if !*noPrefixes {
				jobs = append(jobs, prefixJobs(*prefixBase, pair, arch, chosen)...)
			}
		}
	}
	if *listOnly {
		log.Printf("共 %d 个语言对 (arch=%s)", len(pairs), archDisplay(archFilter))
		return
	}

	// ── 并发下载 ─────────────────────────────────────────────────
	var (
		downloaded, skipped, absent, failed atomic.Int64
		bytesTotal                          atomic.Int64
		mu                                  sync.Mutex
		failures                            []string
	)
	start := time.Now()
	queue := make(chan job)
	var wg sync.WaitGroup
	for i := 0; i < *workers; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for j := range queue {
				action, n, err := processJob(ctx, client, absOut, j, *force)
				bytesTotal.Add(n)
				switch {
				case err != nil:
					failed.Add(1)
					mu.Lock()
					failures = append(failures, fmt.Sprintf("%s/%s/%s: %v", j.pair, j.arch, j.name, err))
					mu.Unlock()
					log.Printf("[%s/%s] %s: 失败: %v", j.pair, j.arch, j.name, err)
				case action == "skip":
					skipped.Add(1)
					log.Printf("[%s/%s] %s: 跳过 (sha256 校验通过)", j.pair, j.arch, j.name)
				case action == "absent":
					absent.Add(1)
					log.Printf("[%s/%s] %s: 上游不存在，跳过", j.pair, j.arch, j.name)
				default:
					downloaded.Add(1)
					log.Printf("[%s/%s] %s: 已下载 %s", j.pair, j.arch, j.name, humanBytes(n))
				}
			}
		}()
	}
	for _, j := range jobs {
		select {
		case queue <- j:
		case <-ctx.Done():
		}
		if ctx.Err() != nil {
			break
		}
	}
	close(queue)
	wg.Wait()

	if skippedPairs > 0 {
		log.Printf("跳过 %d 个没有匹配架构的语言对", skippedPairs)
	}
	verb := "完成"
	if ctx.Err() != nil {
		verb = "中断"
	}
	log.Printf("%s: %d 个文件已下载 (%s)，%d 个跳过，%d 个上游不存在，%d 个失败，用时 %s。",
		verb, downloaded.Load(), humanBytes(bytesTotal.Load()),
		skipped.Load(), absent.Load(), failed.Load(), time.Since(start).Round(time.Second))

	if len(failures) > 0 {
		fmt.Fprintf(os.Stderr, "\n失败列表:\n")
		for _, f := range failures {
			fmt.Fprintf(os.Stderr, "  %s\n", f)
		}
		os.Exit(1)
	}
}

// ── HTTP ─────────────────────────────────────────────────────────────

func newHTTPClient() *http.Client {
	return &http.Client{
		Transport: &http.Transport{
			Proxy: http.ProxyFromEnvironment, // HTTP_PROXY / HTTPS_PROXY / NO_PROXY
			DialContext: (&net.Dialer{
				Timeout:   30 * time.Second,
				KeepAlive: 30 * time.Second,
			}).DialContext,
			ForceAttemptHTTP2:     true,
			TLSHandshakeTimeout:   15 * time.Second,
			ResponseHeaderTimeout: 60 * time.Second,
			IdleConnTimeout:       90 * time.Second,
		},
	}
}

// httpError 记录非 2xx 响应，便于调用方区分 404（prefix 文件上游可能没有）
type httpError struct {
	code    int
	snippet string
}

func (e *httpError) Error() string {
	return fmt.Sprintf("HTTP %d: %s", e.code, e.snippet)
}

func httpGet(ctx context.Context, client *http.Client, rawURL string) (*http.Response, error) {
	var lastErr error
	for attempt := 0; attempt < 3; attempt++ {
		if attempt > 0 {
			time.Sleep(time.Duration(attempt) * 3 * time.Second) // 3s, 6s
		}
		req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
		if err != nil {
			return nil, err
		}
		resp, err := client.Do(req)
		if err != nil {
			lastErr = err
			continue
		}
		if resp.StatusCode == http.StatusOK {
			return resp, nil
		}
		body, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
		resp.Body.Close()
		lastErr = &httpError{code: resp.StatusCode, snippet: strings.TrimSpace(string(body))}
		if resp.StatusCode >= 500 || resp.StatusCode == http.StatusTooManyRequests {
			continue
		}
		return nil, lastErr
	}
	return nil, lastErr
}

// fetchAll 下载整个响应体（注册表用）
func fetchAll(ctx context.Context, client *http.Client, rawURL string) ([]byte, error) {
	resp, err := httpGet(ctx, client, rawURL)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	return io.ReadAll(resp.Body)
}

// ── 任务处理 ─────────────────────────────────────────────────────────

// processJob 把一个 job 落盘：先写 <dest>.tmp，校验通过后重命名。
// 返回 action: "download" / "skip" / "absent"。
func processJob(ctx context.Context, client *http.Client, outDir string, j job, force bool) (string, int64, error) {
	dest := filepath.Join(outDir, j.pair, j.arch, j.name)

	// 已存在且校验通过（注册表哈希或既有 .sha256）则跳过
	if !force {
		if sum, err := sha256File(dest); err == nil {
			want := j.wantHash
			if want == "" {
				want = readSidecarSum(dest)
			}
			if want != "" && strings.EqualFold(sum, want) {
				writeSidecar(dest, sum)
				return "skip", 0, nil
			}
		}
	}

	if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
		return "", 0, err
	}

	tmp := dest + ".tmp"
	sum, n, err := downloadToFile(ctx, client, j.remote, tmp, j.gzipped)

	// prefix 文件上游可能没有该语言版本；有备用语言（zh_hant -> zh）时再试一次
	if isNotFound(err) && j.fallback != "" {
		sum, n, err = downloadToFile(ctx, client, j.fallback, tmp, j.gzipped)
	}
	if isNotFound(err) {
		return "absent", 0, nil
	}
	if err != nil {
		os.Remove(tmp)
		return "", 0, err
	}

	if j.wantHash != "" && !strings.EqualFold(sum, j.wantHash) {
		os.Remove(tmp)
		return "", n, fmt.Errorf("sha256 不匹配: 期望 %s, 实际 %s", j.wantHash, sum)
	}
	if err := os.Chmod(tmp, 0o644); err != nil {
		os.Remove(tmp)
		return "", n, err
	}
	if err := os.Rename(tmp, dest); err != nil {
		os.Remove(tmp)
		return "", n, err
	}
	writeSidecar(dest, sum)
	return "download", n, nil
}

// downloadToFile 流式下载到 tmp，边写边算解压后内容的 sha256
func downloadToFile(ctx context.Context, client *http.Client, rawURL, tmp string, gunzip bool) (string, int64, error) {
	resp, err := httpGet(ctx, client, rawURL)
	if err != nil {
		return "", 0, err
	}
	defer resp.Body.Close()

	var r io.Reader = resp.Body
	if gunzip {
		zr, err := gzip.NewReader(resp.Body)
		if err != nil {
			return "", 0, fmt.Errorf("gzip: %w", err)
		}
		defer zr.Close()
		r = zr
	}

	out, err := os.Create(tmp)
	if err != nil {
		return "", 0, err
	}
	h := sha256.New()
	copied, copyErr := io.Copy(io.MultiWriter(out, h), r)
	closeErr := out.Close()
	if copyErr != nil {
		return "", copied, copyErr
	}
	if closeErr != nil {
		return "", copied, closeErr
	}
	return hex.EncodeToString(h.Sum(nil)), copied, nil
}

// prefixJobs 为语言对的源/目标语言构造 nonbreaking_prefix.<lang> 任务
// （按 <pair>/<arch>/ 布局，每个架构目录各自带一份）
func prefixJobs(prefixBase, pair, arch string, c *candidate) []job {
	seen := map[string]bool{}
	var jobs []job
	for _, lang := range []string{c.SourceLanguage, c.TargetLanguage} {
		if lang == "" || seen[lang] {
			continue
		}
		seen[lang] = true
		j := job{
			pair:     pair,
			arch:     arch,
			name:     "nonbreaking_prefix." + lang,
			remote:   prefixBase + "/nonbreaking_prefix." + lang,
			fallback: "",
		}
		// zh_hant 之类没有专用的前缀表，回退到基础语言 (zh)
		if base, _, ok := strings.Cut(lang, "_"); ok {
			j.fallback = prefixBase + "/nonbreaking_prefix." + base
		}
		jobs = append(jobs, j)
	}
	return jobs
}

// ── 选择逻辑（同一架构内有多个候选时与 download-models.py 一致）──────

// chooseCandidate 在指定架构的候选中，优先选择 releaseStatus 含 "Release"
// 的，否则取第一个。
func chooseCandidate(cands []*candidate, arch string) (*candidate, error) {
	var pool []*candidate
	for _, c := range cands {
		if c.Architecture == arch {
			pool = append(pool, c)
		}
	}
	if len(pool) == 0 {
		arches := archesOf(cands)
		return nil, fmt.Errorf("没有架构 %q 的模型 (可选: %s)", arch, strings.Join(arches, ", "))
	}
	sort.SliceStable(pool, func(i, j int) bool {
		return releaseRank(pool[i]) < releaseRank(pool[j])
	})
	return pool[0], nil
}

func releaseRank(c *candidate) int {
	if strings.Contains(c.ReleaseStatus, "Release") {
		return 0
	}
	return 1
}

// archesOf 返回语言对拥有的架构（去重、升序）
func archesOf(cands []*candidate) []string {
	seen := map[string]bool{}
	var arches []string
	for _, c := range cands {
		if !seen[c.Architecture] {
			seen[c.Architecture] = true
			arches = append(arches, c.Architecture)
		}
	}
	sort.Strings(arches)
	return arches
}

// allArchs 返回整个注册表出现过的架构（升序），用于校验 -arch 参数
func allArchs(models map[string][]*candidate) []string {
	set := map[string]bool{}
	for _, cands := range models {
		for _, c := range cands {
			set[c.Architecture] = true
		}
	}
	return sortedKeys(set)
}

// unknownArchs 返回过滤器里注册表没有的架构
func unknownArchs(models map[string][]*candidate, filter []string) []string {
	valid := map[string]bool{}
	for _, a := range allArchs(models) {
		valid[a] = true
	}
	var unknown []string
	for _, a := range filter {
		if !valid[a] {
			unknown = append(unknown, a)
		}
	}
	return unknown
}

// selectArches 按过滤器（多选）返回要下载的架构：
// 过滤器为空时返回该语言对全部架构；否则按过滤器顺序返回该语言对拥有的架构。
func selectArches(cands []*candidate, filter []string) []string {
	if len(filter) == 0 {
		return archesOf(cands)
	}
	have := map[string]bool{}
	for _, a := range archesOf(cands) {
		have[a] = true
	}
	var selected []string
	for _, a := range filter {
		if have[a] {
			selected = append(selected, a)
		}
	}
	return selected
}

// selectPairs 按逗号分隔的过滤器返回排序后的语言对；过滤器为空时返回全部。
// 返回值 unknown 是过滤器里注册表没有的条目。
func selectPairs(models map[string][]*candidate, filter string) (pairs, unknown []string) {
	if strings.TrimSpace(filter) == "" {
		return sortedPairs(models), nil
	}
	seen := map[string]bool{}
	for _, p := range parseList(filter) {
		if _, ok := models[p]; ok {
			if !seen[p] {
				seen[p] = true
				pairs = append(pairs, p)
			}
		} else {
			unknown = append(unknown, p)
		}
	}
	sort.Strings(pairs)
	return pairs, unknown
}

func sortedPairs(models map[string][]*candidate) []string {
	pairs := make([]string, 0, len(models))
	for p := range models {
		pairs = append(pairs, p)
	}
	sort.Strings(pairs)
	return pairs
}

// parseList 解析逗号分隔的列表：去空白、去空项、去重（保持顺序）
func parseList(s string) []string {
	seen := map[string]bool{}
	var items []string
	for _, item := range strings.Split(s, ",") {
		item = strings.TrimSpace(item)
		if item == "" || seen[item] {
			continue
		}
		seen[item] = true
		items = append(items, item)
	}
	return items
}

func archDisplay(filter []string) string {
	if len(filter) == 0 {
		return `"" (全部架构)`
	}
	return `"` + strings.Join(filter, ",") + `"`
}

func sortedKeys(set map[string]bool) []string {
	keys := make([]string, 0, len(set))
	for k := range set {
		keys = append(keys, k)
	}
	sort.Strings(keys)
	return keys
}

// ── 文件与哈希 ───────────────────────────────────────────────────────

// destName 由注册表 path 得到落盘文件名：取基名并去掉 .gz 后缀
// （与 download-models.py 一致，引擎直接加载未压缩文件）
func destName(remotePath string) string {
	return strings.TrimSuffix(filepath.Base(remotePath), ".gz")
}

func sha256File(path string) (string, error) {
	f, err := os.Open(path)
	if err != nil {
		return "", err
	}
	defer f.Close()
	h := sha256.New()
	if _, err := io.Copy(h, f); err != nil {
		return "", err
	}
	return hex.EncodeToString(h.Sum(nil)), nil
}

// readSidecarSum 读取 .sha256 sidecar 中记录的哈希
// （sidecar 为 sha256sum 格式: "<hex>  <name>\n"，可用 sha256sum -c 校验）
func readSidecarSum(dest string) string {
	b, err := os.ReadFile(dest + ".sha256")
	if err != nil {
		return ""
	}
	fields := strings.Fields(string(b))
	if len(fields) == 0 {
		return ""
	}
	return strings.ToLower(fields[0])
}

func writeSidecar(dest, sum string) {
	name := filepath.Base(dest)
	content := sum + "  " + name + "\n"
	if existing, err := os.ReadFile(dest + ".sha256"); err == nil && string(existing) == content {
		return
	}
	_ = os.WriteFile(dest+".sha256", []byte(content), 0o644)
}

// ── 杂项 ─────────────────────────────────────────────────────────────

// proxyDescription 返回当前生效的代理环境变量（隐藏用户名/密码），无则返回空
func proxyDescription() string {
	for _, key := range []string{"HTTPS_PROXY", "https_proxy", "ALL_PROXY", "all_proxy"} {
		if v := os.Getenv(key); v != "" {
			if u, err := url.Parse(v); err == nil && u.Host != "" {
				u.User = nil
				v = u.String()
			}
			return key + "=" + v
		}
	}
	return ""
}

func isNotFound(err error) bool {
	var he *httpError
	return errors.As(err, &he) && he.code == http.StatusNotFound
}

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