package worker

import (
	"context"
	"errors"
	"fmt"
	"log/slog"
	"testing"
	"time"

	"git.vakhrushev.me/av/jellybit/internal/qbt"
	"git.vakhrushev.me/av/jellybit/internal/store"
)

// фиксированные метки времени для детерминированных таймаут-тестов.
const (
	timeNow    = "2026-06-14T10:00:00Z"
	timeOld    = "2026-06-14T08:00:00Z" // 2 часа назад
	timeRecent = "2026-06-14T09:59:00Z" // 1 минута назад
)

type fakeStore struct {
	downloads   map[string]*store.Download
	transitions []transition
	torrents    map[string][]byte // download_id → байты .torrent
	promoteErr  error             // если задан — PromoteCatched возвращает его, НЕ меняя state (симуляция транзиентного сбоя БД)
	setStateErr error             // если задан — SetDownloadState отказывает (ветка отката Retry)
	retriedErr  error             // если задан — SetRetriedAt отказывает (best-effort сброс базиса)
	missCntErr  error             // если задан — SetSourceMissCount отказывает (best-effort сброс счётчика)
}

type transition struct {
	id    string
	state store.State
}

func (f *fakeStore) ListDownloadsByState(_ context.Context, states ...store.State) ([]store.Download, error) {
	var out []store.Download
	for _, d := range f.downloads {
		for _, s := range states {
			if d.State == s {
				out = append(out, *d)
				break
			}
		}
	}
	return out, nil
}

func (f *fakeStore) ListRecoverable(_ context.Context, codes ...string) ([]store.Download, error) {
	var out []store.Download
	for _, d := range f.downloads {
		if d.State != store.StateFailed && d.State != store.StateStuck {
			continue
		}
		for _, c := range codes {
			if d.ErrorCode.Valid && d.ErrorCode.String == c {
				out = append(out, *d)
				break
			}
		}
	}
	return out, nil
}

func (f *fakeStore) GetDownload(_ context.Context, id string) (*store.Download, error) {
	d, ok := f.downloads[id]
	if !ok {
		return nil, fmt.Errorf("download %s not found", id)
	}
	cp := *d
	return &cp, nil
}

// hasAnyHash сообщает, владеет ли загрузка любым из hashes.
func hasAnyHash(d *store.Download, hashes []string) bool {
	for _, own := range d.Infohashes {
		for _, h := range hashes {
			if own.Infohash == store.NormalizeHash(h) {
				return true
			}
		}
	}
	return false
}

func (f *fakeStore) ExistsByInfohash(_ context.Context, hashes ...string) (bool, error) {
	for _, d := range f.downloads {
		if hasAnyHash(d, hashes) {
			return true, nil
		}
	}
	return false, nil
}

func (f *fakeStore) FindActiveByInfohash(_ context.Context, hashes ...string) (*store.Download, error) {
	for _, d := range f.downloads {
		if hasAnyHash(d, hashes) && !d.State.IsTerminal() {
			cp := *d
			return &cp, nil
		}
	}
	return nil, nil
}

func (f *fakeStore) CreateDownloadIfNoActive(ctx context.Context, d *store.Download, hashes []string, torrentBlob []byte) (*store.Download, error) {
	if existing, _ := f.FindActiveByInfohash(ctx, hashes...); existing != nil {
		return existing, nil
	}
	id := fmt.Sprintf("%d", len(f.downloads)+1)
	cp := *d
	cp.ID = id
	for _, h := range hashes {
		h = store.NormalizeHash(h)
		cp.Infohashes = append(cp.Infohashes, store.Infohash{DownloadID: id, Infohash: h, Kind: store.HashKind(h)})
	}
	f.downloads[id] = &cp
	if len(torrentBlob) > 0 {
		if f.torrents == nil {
			f.torrents = map[string][]byte{}
		}
		f.torrents[id] = torrentBlob
	}
	d.ID = id
	d.Infohashes = cp.Infohashes
	return nil, nil
}

func (f *fakeStore) GetTorrentData(_ context.Context, downloadID string) ([]byte, error) {
	if data, ok := f.torrents[downloadID]; ok {
		return data, nil
	}
	return nil, store.ErrNotFound
}

func (f *fakeStore) ActivateIfNoOtherActive(ctx context.Context, id string, st store.State, code, msg string) error {
	d, ok := f.downloads[id]
	if !ok {
		return fmt.Errorf("download %s not found", id)
	}
	for _, other := range f.downloads {
		if other.ID != id && !other.State.IsTerminal() && hasAnyHash(other, hashList(d)) {
			return fmt.Errorf("activate %s: %w", id, store.ErrInfohashTaken)
		}
	}
	return f.SetDownloadState(ctx, id, st, code, msg)
}

func hashList(d *store.Download) []string {
	out := make([]string, len(d.Infohashes))
	for i, h := range d.Infohashes {
		out[i] = h.Infohash
	}
	return out
}

func (f *fakeStore) AddInfohashes(_ context.Context, id string, hashes []string) error {
	d, ok := f.downloads[id]
	if !ok {
		return fmt.Errorf("download %s not found", id)
	}
	for _, h := range hashes {
		h = store.NormalizeHash(h)
		if !hasAnyHash(d, []string{h}) {
			d.Infohashes = append(d.Infohashes, store.Infohash{DownloadID: id, Infohash: h, Kind: store.HashKind(h)})
		}
	}
	return nil
}

func (f *fakeStore) SetDownloadState(_ context.Context, id string, st store.State, code, msg string) error {
	if f.setStateErr != nil {
		return f.setStateErr
	}
	d, ok := f.downloads[id]
	if !ok {
		return fmt.Errorf("download %s not found", id)
	}
	d.State = st
	d.ErrorCode = store.NullString(code)
	d.ErrorMsg = store.NullString(msg)
	f.transitions = append(f.transitions, transition{id, st})
	return nil
}

func (f *fakeStore) PromoteCatched(_ context.Context, id, displayName string) error {
	d, ok := f.downloads[id]
	if !ok {
		return fmt.Errorf("download %s not found", id)
	}
	if f.promoteErr != nil {
		return f.promoteErr // транзиентный сбой БД: state НЕ меняем (остаётся catched)
	}
	if d.State != store.StateCatched {
		return fmt.Errorf("promote catched %s: not in catched (%s)", id, d.State)
	}
	d.State = store.StateDownloading
	d.DisplayName = displayName
	f.transitions = append(f.transitions, transition{id, store.StateDownloading})
	return nil
}

func (f *fakeStore) SetDisplayName(_ context.Context, id, name string) error {
	d, ok := f.downloads[id]
	if !ok {
		return fmt.Errorf("download %s not found", id)
	}
	d.DisplayName = name
	return nil
}

func (f *fakeStore) SetParsedContext(_ context.Context, id, jsonStr string) error {
	d, ok := f.downloads[id]
	if !ok {
		return fmt.Errorf("download %s not found", id)
	}
	d.ParsedContext = jsonStr
	return nil
}

func (f *fakeStore) SetSourceMissCount(_ context.Context, id string, n int) error {
	if f.missCntErr != nil {
		return f.missCntErr
	}
	d, ok := f.downloads[id]
	if !ok {
		return fmt.Errorf("download %s not found", id)
	}
	d.SourceMissCount = n
	return nil
}

func (f *fakeStore) SetSourceAddedAt(_ context.Context, id string, t time.Time) error {
	d, ok := f.downloads[id]
	if !ok {
		return fmt.Errorf("download %s not found", id)
	}
	if !d.SourceAddedAt.Valid { // гард как в store: пишем однократно
		d.SourceAddedAt = store.NullString(store.FormatTime(t))
	}
	return nil
}

func (f *fakeStore) SetRetriedAt(_ context.Context, id string, t time.Time) error {
	if f.retriedErr != nil {
		return f.retriedErr
	}
	d, ok := f.downloads[id]
	if !ok {
		return fmt.Errorf("download %s not found", id)
	}
	d.RetriedAt = store.NullString(store.FormatTime(t))
	return nil
}

// --- Ф3-методы Store (заглушки; переопределяются в review_test.go) ---

func (f *fakeStore) CreateRecognition(_ context.Context, _ *store.Recognition, _ []string) (string, error) {
	return "", nil
}
func (f *fakeStore) GetCurrentRecognition(_ context.Context, _ string) (*store.Recognition, error) {
	return nil, nil
}
func (f *fakeStore) AddHint(_ context.Context, _ string, _ string) error        { return nil }
func (f *fakeStore) ListHints(_ context.Context, _ string) ([]string, error)    { return nil, nil }
func (f *fakeStore) SetOverride(_ context.Context, _ string, _, _ string) error { return nil }
func (f *fakeStore) ListOverrides(_ context.Context, _ string) (map[string]string, error) {
	return nil, nil
}
func (f *fakeStore) CreateFileLinks(_ context.Context, _ []store.FileLink) error { return nil }
func (f *fakeStore) LiveTitleFolders(_ context.Context, _, _, _ string) ([]string, error) {
	return nil, nil
}
func (f *fakeStore) SupersedeForeignLinks(_ context.Context, _ string, _ []string) error {
	return nil
}
func (f *fakeStore) LatestBatchID(_ context.Context, _ string) (string, error) { return "", nil }
func (f *fakeStore) ListFileLinksByBatch(_ context.Context, _ string) ([]store.FileLink, error) {
	return nil, nil
}
func (f *fakeStore) DeleteFileLinksByBatch(_ context.Context, _ string) error { return nil }
func (f *fakeStore) CreateCandidates(_ context.Context, _ []store.MetadataCandidate) error {
	return nil
}
func (f *fakeStore) ListCandidatesByRecognition(_ context.Context, _ string) ([]store.MetadataCandidate, error) {
	return nil, nil
}
func (f *fakeStore) GetCandidate(_ context.Context, _ string) (*store.MetadataCandidate, error) {
	return nil, nil
}
func (f *fakeStore) SetCandidateChosen(_ context.Context, _, _ string) error { return nil }

type fakeQbt struct {
	torrents    []qbt.Torrent
	torrentsErr error
	onTorrents  func() // вклинивается в момент листинга (симуляция гонки между снимком и re-read)
	added       []qbt.AddRequest
	addCtx      []context.Context // ctx каждого вызова Add (проверка scoped-логгера)
	addErr      error
	onAdd       func() // вклинивается в момент Add (симуляция отмены в окне после add)
	files       []qbt.File
	deleted     [][]string // хеши каждого вызова Delete
	deletedData []bool     // deleteFiles каждого вызова Delete (параллельно deleted)
	deleteErr   error
	renamed     []renameCall // каждый вызов RenameTorrent (hash, name)
	renameErr   error
}

type renameCall struct {
	hash string
	name string
}

// Torrents имитирует /torrents/info: пустая категория — все торренты, иначе
// только торренты этой категории (как реальный qBittorrent). Это важно для
// регрессии: раздача, усыновлённая по тегу, имеет чужую категорию и не должна
// теряться при поиске по infohash.
func (f *fakeQbt) Torrents(_ context.Context, category string) ([]qbt.Torrent, error) {
	if f.onTorrents != nil {
		f.onTorrents()
	}
	if f.torrentsErr != nil {
		return nil, f.torrentsErr
	}
	if category == "" {
		return f.torrents, nil
	}
	var out []qbt.Torrent
	for _, t := range f.torrents {
		if t.Category == category {
			out = append(out, t)
		}
	}
	return out, nil
}

func (f *fakeQbt) Add(ctx context.Context, ar qbt.AddRequest) error {
	f.addCtx = append(f.addCtx, ctx)
	if f.onAdd != nil {
		f.onAdd()
	}
	if f.addErr != nil {
		return f.addErr
	}
	f.added = append(f.added, ar)
	return nil
}

func (f *fakeQbt) Files(_ context.Context, _ string) ([]qbt.File, error) {
	return f.files, nil
}

func (f *fakeQbt) Delete(_ context.Context, hashes []string, deleteFiles bool) error {
	if f.deleteErr != nil {
		return f.deleteErr
	}
	f.deleted = append(f.deleted, hashes)
	f.deletedData = append(f.deletedData, deleteFiles)
	return nil
}

func (f *fakeQbt) RenameTorrent(_ context.Context, hash, name string) error {
	if f.renameErr != nil {
		return f.renameErr
	}
	f.renamed = append(f.renamed, renameCall{hash, name})
	return nil
}

func newTestWorker(st *fakeStore, qb *fakeQbt) *Worker {
	w := New(st, qb, nil, nil, Config{
		Category:      "jellybit",
		SavePath:      "/srv/media/downloads",
		MagnetTimeout: 30 * time.Minute,
		StuckAfter:    time.Hour,
	}, slog.New(slog.DiscardHandler))
	w.now = func() time.Time { return time.Date(2026, 6, 14, 10, 0, 0, 0, time.UTC) }
	return w
}

func oneDownloading(infohash, createdAt string) *fakeStore {
	return &fakeStore{downloads: map[string]*store.Download{
		"1": {
			ID:         "1",
			State:      store.StateDownloading,
			SourceType: store.SourceMagnet,
			SourceRef:  "magnet:?xt=urn:btih:" + infohash,
			Infohashes: hashesOf("1", infohash),
			CreatedAt:  createdAt,
		},
	}}
}

// hashesOf — срез хешей загрузки для литералов фикстур.
func hashesOf(id string, hashes ...string) []store.Infohash {
	out := make([]store.Infohash, 0, len(hashes))
	for _, h := range hashes {
		h = store.NormalizeHash(h)
		out = append(out, store.Infohash{DownloadID: id, Infohash: h, Kind: store.HashKind(h)})
	}
	return out
}

func TestPollTransitions(t *testing.T) {
	const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
	tests := []struct {
		name      string
		qbitState string
		createdAt string
		want      store.State
	}{
		{"готов → completed", "uploading", timeRecent, store.StateCompleted},
		{"stalledUP → completed", "stalledUP", timeRecent, store.StateCompleted},
		{"ошибка → failed", "error", timeRecent, store.StateFailed},
		{"missingFiles → failed", "missingFiles", timeRecent, store.StateFailed},
		{"metaDL долго → failed", "metaDL", timeOld, store.StateFailed},
		{"stalledDL долго → stuck", "stalledDL", timeOld, store.StateStuck},
		{"свежий downloading → остаётся", "downloading", timeRecent, store.StateDownloading},
		{"moving → остаётся", "moving", timeRecent, store.StateDownloading},
		{"свежий metaDL → остаётся", "metaDL", timeRecent, store.StateDownloading},
	}
	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			st := oneDownloading(ih, tc.createdAt)
			qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ih, State: tc.qbitState}}}
			w := newTestWorker(st, qb)
			if err := w.Poll(context.Background()); err != nil {
				t.Fatalf("Poll: %v", err)
			}
			if got := st.downloads["1"].State; got != tc.want {
				t.Errorf("state = %q, want %q", got, tc.want)
			}
		})
	}
}

func TestPollMatchesByInfohashV2(t *testing.T) {
	const v2 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
	st := oneDownloading(v2, timeRecent)
	qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: "deadbeef", InfohashV2: v2, State: "uploading"}}}
	w := newTestWorker(st, qb)
	if err := w.Poll(context.Background()); err != nil {
		t.Fatal(err)
	}
	if st.downloads["1"].State != store.StateCompleted {
		t.Errorf("сопоставление по infohash_v2 не сработало: %q", st.downloads["1"].State)
	}
}

// Единичный транзиентный промах (торрента нет в этом тике) при пороге дебаунса
// > 1 не двигает состояние — устойчивую пропажу и переход в source_gone проверяют
// TestSourceGone* в recovery_test.
func TestPollToleratesTransientMissingTorrent(t *testing.T) {
	st := oneDownloading("541adcff3b6dd5dba7088ea83317d9d6fac331d6", timeRecent)
	qb := &fakeQbt{torrents: nil} // торрента в qBittorrent нет
	w := newTestWorker(st, qb)
	w.cfg.SourceMissingThreshold = 3 // порог не достигнут за один тик
	if err := w.Poll(context.Background()); err != nil {
		t.Fatal(err)
	}
	if st.downloads["1"].State != store.StateDownloading {
		t.Errorf("единичный промах не должен менять состояние, got %q", st.downloads["1"].State)
	}
}

func TestCancel(t *testing.T) {
	st := oneDownloading("541adcff3b6dd5dba7088ea83317d9d6fac331d6", timeRecent)
	w := newTestWorker(st, &fakeQbt{})
	if err := w.Cancel(context.Background(), "1"); err != nil {
		t.Fatalf("Cancel: %v", err)
	}
	if st.downloads["1"].State != store.StateCancelled {
		t.Errorf("state = %q, want cancelled", st.downloads["1"].State)
	}
	// Повторная отмена терминальной задачи — ошибка.
	if err := w.Cancel(context.Background(), "1"); err == nil {
		t.Error("ожидалась ошибка при отмене терминальной задачи")
	}
}

// Dismiss — стоп-кран: переводит в cancelled только сменой статуса, не трогая
// раздачу и файлы, из любого состояния, кроме deleted; из cancelled — no-op без
// перезаписи error_code.
func TestDismiss(t *testing.T) {
	t.Run("from-done-keeps-source-and-files", func(t *testing.T) {
		st := oneDownloading("541adcff3b6dd5dba7088ea83317d9d6fac331d6", timeRecent)
		st.downloads["1"].State = store.StateDone
		qb := &fakeQbt{}
		w := newTestWorker(st, qb)
		if err := w.Dismiss(context.Background(), "1"); err != nil {
			t.Fatalf("Dismiss: %v", err)
		}
		if st.downloads["1"].State != store.StateCancelled {
			t.Errorf("state = %q, want cancelled", st.downloads["1"].State)
		}
		if got := st.downloads["1"].ErrorCode.String; got != "user_dismiss" {
			t.Errorf("error_code = %q, want user_dismiss", got)
		}
		if len(qb.deleted) != 0 {
			t.Errorf("раздачу трогать не должны, Delete вызван %d раз", len(qb.deleted))
		}
	})

	t.Run("from-deleted-rejected", func(t *testing.T) {
		st := oneDownloading("541adcff3b6dd5dba7088ea83317d9d6fac331d6", timeRecent)
		st.downloads["1"].State = store.StateDeleted
		w := newTestWorker(st, &fakeQbt{})
		if err := w.Dismiss(context.Background(), "1"); err == nil {
			t.Error("ожидалась ошибка Dismiss из deleted")
		}
		if st.downloads["1"].State != store.StateDeleted {
			t.Errorf("state = %q, want deleted (не изменилось)", st.downloads["1"].State)
		}
	})

	t.Run("from-cancelled-noop-keeps-code", func(t *testing.T) {
		st := oneDownloading("541adcff3b6dd5dba7088ea83317d9d6fac331d6", timeRecent)
		st.downloads["1"].State = store.StateCancelled
		st.downloads["1"].ErrorCode = store.NullString("prior_reason")
		w := newTestWorker(st, &fakeQbt{})
		if err := w.Dismiss(context.Background(), "1"); err != nil {
			t.Fatalf("Dismiss no-op: %v", err)
		}
		if got := st.downloads["1"].ErrorCode.String; got != "prior_reason" {
			t.Errorf("error_code = %q, no-op не должен его переписывать", got)
		}
		if len(st.transitions) != 0 {
			t.Errorf("no-op не должен писать переход, got %d", len(st.transitions))
		}
	})
}

func TestRetry(t *testing.T) {
	st := oneDownloading("541adcff3b6dd5dba7088ea83317d9d6fac331d6", timeRecent)
	st.downloads["1"].State = store.StateStuck
	qb := &fakeQbt{}
	w := newTestWorker(st, qb)
	if err := w.Retry(context.Background(), "1"); err != nil {
		t.Fatalf("Retry: %v", err)
	}
	if st.downloads["1"].State != store.StateDownloading {
		t.Errorf("state = %q, want downloading", st.downloads["1"].State)
	}
	if len(qb.added) != 1 {
		t.Errorf("ожидалось повторное добавление в qBittorrent, got %d", len(qb.added))
	}
}

// Retry при занятом хеше отклоняется ДО побочного эффекта: торрент не
// добавляется в qBittorrent повторно.
func TestRetryConflictNoAdd(t *testing.T) {
	const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
	st := oneDownloading(ih, timeRecent)
	st.downloads["1"].State = store.StateFailed
	st.downloads["1"].ErrorCode = store.NullString("magnet_timeout")
	// Хешем владеет другая активная задача.
	st.downloads["2"] = &store.Download{
		ID: "2", State: store.StateDownloading, SourceType: store.SourceMagnet,
		Infohashes: hashesOf("2", ih), CreatedAt: timeRecent,
	}
	qb := &fakeQbt{} // торрента в qBittorrent нет — без гарда был бы Add
	w := newTestWorker(st, qb)

	if err := w.Retry(context.Background(), "1"); !errors.Is(err, ErrConflict) {
		t.Fatalf("ожидался ErrConflict, получили %v", err)
	}
	if len(qb.added) != 0 {
		t.Errorf("торрент добавлен побочным эффектом отклонённого retry: %d Add", len(qb.added))
	}
	if st.downloads["1"].State != store.StateFailed {
		t.Errorf("state = %s, want failed", st.downloads["1"].State)
	}
}

// Если после активации повторный Add в qBittorrent упал — задача
// откатывается в прежнее состояние, а не остаётся «качающейся» без раздачи.
func TestRetryRollsBackOnAddFailure(t *testing.T) {
	const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
	st := oneDownloading(ih, timeRecent)
	st.downloads["1"].State = store.StateFailed
	st.downloads["1"].ErrorCode = store.NullString("magnet_timeout")
	qb := &fakeQbt{addErr: fmt.Errorf("connection refused")}
	w := newTestWorker(st, qb)

	if err := w.Retry(context.Background(), "1"); err == nil {
		t.Fatal("ожидалась ошибка Add")
	}
	if st.downloads["1"].State != store.StateFailed {
		t.Errorf("state = %s, want failed (откат)", st.downloads["1"].State)
	}
	if st.downloads["1"].ErrorCode.String != "magnet_timeout" {
		t.Errorf("error_code = %q, want magnet_timeout (восстановлен)", st.downloads["1"].ErrorCode.String)
	}
}

func TestRetryRejectsActive(t *testing.T) {
	st := oneDownloading("541adcff3b6dd5dba7088ea83317d9d6fac331d6", timeRecent)
	w := newTestWorker(st, &fakeQbt{})
	if err := w.Retry(context.Background(), "1"); err == nil {
		t.Error("retry активной (downloading) задачи должен отклоняться")
	}
}

// TestRetryRejectsLiveErroredTorrent — NIT-12: живой, но сломанный торрент
// (error/missingFiles) при retry не перецепляем и не возвращаем в downloading —
// иначе сверка тут же вернула бы задачу в failed (+дебаунс уведомления), retry
// выглядел бы сломанным. Отклоняем с ErrConflict, состояние не трогаем, повторный
// Add не делаем — пользователю подсказано починить раздачу (recheck) в qBittorrent.
func TestRetryRejectsLiveErroredTorrent(t *testing.T) {
	const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
	for _, state := range []string{"missingFiles", "error"} {
		t.Run(state, func(t *testing.T) {
			st := oneDownloading(ih, timeRecent)
			st.downloads["1"].State = store.StateFailed
			st.downloads["1"].ErrorCode = store.NullString("qbit_error")
			qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ih, State: state}}}
			w := newTestWorker(st, qb)

			if err := w.Retry(context.Background(), "1"); !errors.Is(err, ErrConflict) {
				t.Fatalf("err = %v, want ErrConflict", err)
			}
			if len(qb.added) != 0 {
				t.Errorf("сломанный живой торрент не переотдаём: %d Add", len(qb.added))
			}
			if got := st.downloads["1"].State; got != store.StateFailed {
				t.Errorf("state = %s, want failed (не тронуто)", got)
			}
		})
	}
}

// TestRetryReattachesLiveHealthyTorrent — контроль к NIT-12: живой ЗДОРОВЫЙ торрент
// перецепляем без повторного Add (регрессия — errored-ветка не должна задеть
// здоровый путь).
func TestRetryReattachesLiveHealthyTorrent(t *testing.T) {
	const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
	st := oneDownloading(ih, timeRecent)
	st.downloads["1"].State = store.StateStuck
	qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ih, State: "stalledUP"}}}
	w := newTestWorker(st, qb)

	if err := w.Retry(context.Background(), "1"); err != nil {
		t.Fatalf("Retry: %v", err)
	}
	if len(qb.added) != 0 {
		t.Errorf("живой здоровый торрент не переотдаём: %d Add", len(qb.added))
	}
	if st.downloads["1"].State != store.StateDownloading {
		t.Errorf("state = %q, want downloading", st.downloads["1"].State)
	}
}

func TestClassify(t *testing.T) {
	cases := map[string]class{
		"uploading":    classReady,
		"stalledUP":    classReady,
		"stoppedUP":    classReady,
		"error":        classErrored,
		"missingFiles": classErrored,
		"moving":       classBusy,
		"checkingUP":   classBusy,
		"downloading":  classDownloading,
		"metaDL":       classDownloading,
		"stalledDL":    classDownloading,
	}
	for state, want := range cases {
		if got := classify(state); got != want {
			t.Errorf("classify(%q) = %d, want %d", state, got, want)
		}
	}
}
