package httpapi

import (
	"context"
	"errors"
	"fmt"
	"net/http"
	"net/http/httptest"
	"net/url"
	"strconv"
	"strings"
	"testing"
	"time"

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

// Валидные lowercase-ULID для форм (parseBulkBatch прогоняет через ident.Parse).
const (
	bid1 = "01arz3ndektsv4rrffq69g5fa1"
	bid2 = "01arz3ndektsv4rrffq69g5fa2"
	bid3 = "01arz3ndektsv4rrffq69g5fa3"
	bid4 = "01arz3ndektsv4rrffq69g5fa4"
)

// bulkReviewer — Reviewer, который считает вызовы удаления и умеет отказать по
// конкретному идентификатору. Указатель: тест проверяет «ни одного вызова», а
// значение-копия этого не покажет.
type bulkReviewer struct {
	stubReviewer
	calls  *[]string
	errs   map[string]error
	allErr error         // отказ по любому идентификатору (лежащий внешний сервис)
	sleep  time.Duration // медленный, но исправный сосед
}

func (b bulkReviewer) Delete(ctx context.Context, id string) error {
	*b.calls = append(*b.calls, id)
	// Контекст исполнения обязан пережить отмену запроса: необратимую операцию
	// нельзя обрывать на середине. Проверяем во всех тестах, а не только в том,
	// что об этом, — гарантия одна на все пути.
	if err := ctx.Err(); err != nil {
		return fmt.Errorf("bulk delete stub: %w", err)
	}
	time.Sleep(b.sleep)
	if b.allErr != nil {
		return b.allErr
	}
	return b.errs[id]
}

func newBulkReviewer(errs map[string]error) (bulkReviewer, *[]string) {
	calls := &[]string{}
	return bulkReviewer{calls: calls, errs: errs}, calls
}

func dl(id string, st store.State, title string) store.Download {
	return store.Download{ID: id, State: st, DisplayName: title}
}

// byID собирает карту для поштучного чтения на экранах подтверждения и отчёта.
func byID(ds ...store.Download) map[string]store.Download {
	m := make(map[string]store.Download, len(ds))
	for _, d := range ds {
		m[d.ID] = d
	}
	return m
}

// postCancelled отправляет POST с уже отменённым контекстом запроса — так
// выглядит закрытая вкладка или оборванная связь на середине пачки.
func postCancelled(t *testing.T, h http.Handler, path string, form url.Values) *httptest.ResponseRecorder {
	t.Helper()
	req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	ctx, cancel := context.WithCancel(req.Context())
	req = req.WithContext(ctx)
	cancel()
	rr := httptest.NewRecorder()
	h.ServeHTTP(rr, req)
	return rr
}

func idForm(ids ...string) url.Values {
	f := url.Values{}
	for _, id := range ids {
		f.Add("id", id)
	}
	return f
}

// П1: страница показывает строки только тех загрузок, для которых удаление
// разрешено поштучно. Читатель отдаёт задачи во всех состояниях — на странице
// оказываются ровно разрешённые.
func TestBulkDeletePageShowsOnlyDeletable(t *testing.T) {
	all := []store.State{
		store.StateCatched, store.StateDownloading, store.StateCompleted,
		store.StateRecognizing, store.StateReview, store.StateLinking,
		store.StateDone, store.StateDeferred, store.StateStuck,
		store.StateFailed, store.StateCancelled, store.StateReverted,
		store.StateTargetMissing, store.StateOrphaned, store.StateDeleted,
	}
	// Читатель отдаёт всё подряд — отбирает страница по домену, а не тест.
	var rows []store.Download
	for i, st := range all {
		if st.CanDelete() {
			rows = append(rows, dl(fmt.Sprintf("%026d", i), st, "задача "+string(st)))
		}
	}
	rv, _ := newBulkReviewer(nil)
	h := testRouterAction(t, stubReader{deletable: rows}, rv, stubCommander{}, stubLive{})

	body := get(t, h, "/delete").Body.String()
	for _, st := range all {
		marker := "задача " + string(st)
		if got := strings.Contains(body, marker); got != st.CanDelete() {
			t.Errorf("%s: строка на странице=%v, CanDelete=%v", st, got, st.CanDelete())
		}
	}
}

// Страница не опрашивает сервер: своп разметки стёр бы отметки, и человек
// подтвердил бы необратимое удаление по выбору, которого уже не видит.
func TestBulkDeletePageDoesNotSelfPoll(t *testing.T) {
	rv, _ := newBulkReviewer(nil)
	h := testRouterAction(t,
		stubReader{deletable: []store.Download{dl(bid1, store.StateDone, "Дюна")}},
		rv, stubCommander{}, stubLive{})

	body := get(t, h, "/delete").Body.String()
	if strings.Contains(body, `hx-trigger="every`) {
		t.Error("страница выбора не должна самообновляться")
	}
	// Предел пачки назван до отправки — иначе отказ по нему отнимает работу.
	if !strings.Contains(body, strconv.Itoa(maxBulkDelete)) {
		t.Errorf("предел пачки не назван на странице:\n%s", body)
	}
}

// Пустое состояние: разрешённых нет — удаление не предлагается.
func TestBulkDeletePageEmpty(t *testing.T) {
	rv, _ := newBulkReviewer(nil)
	h := testRouterAction(t, stubReader{}, rv, stubCommander{}, stubLive{})

	body := get(t, h, "/delete").Body.String()
	if strings.Contains(body, `action="/ui/delete/confirm"`) {
		t.Error("на пустой странице не должно быть формы удаления")
	}
	if !strings.Contains(body, "Удалять нечего") {
		t.Errorf("нет пустого состояния:\n%s", body)
	}
}

// П2 (первая половина): подтверждение называет каждую выбранную поимённо и не
// делает ни одного вызова удаления.
func TestBulkConfirmNamesRowsAndDeletesNothing(t *testing.T) {
	rv, calls := newBulkReviewer(nil)
	h := testRouterAction(t, stubReader{byID: byID(
		dl(bid1, store.StateDone, "Дюна"),
		dl(bid2, store.StateOrphaned, "Фарго"),
	)}, rv, stubCommander{}, stubLive{})

	body := post(t, h, "/ui/delete/confirm", idForm(bid1, bid2), false).Body.String()
	for _, want := range []string{"Дюна", "Фарго", bid1, bid2} {
		if !strings.Contains(body, want) {
			t.Errorf("подтверждение не называет %q:\n%s", want, body)
		}
	}
	if len(*calls) != 0 {
		t.Errorf("подтверждение не должно удалять, вызовы: %v", *calls)
	}
}

// Строка orphaned на подтверждении предупреждает о последней копии данных: гард
// последней копии в удалении выключен сознательно, и осведомлённость человека —
// единственный оставшийся предохранитель.
func TestBulkConfirmWarnsAboutLastCopy(t *testing.T) {
	rv, _ := newBulkReviewer(nil)
	h := testRouterAction(t, stubReader{byID: byID(
		dl(bid1, store.StateOrphaned, "Фарго"),
	)}, rv, stubCommander{}, stubLive{})

	body := post(t, h, "/ui/delete/confirm", idForm(bid1), false).Body.String()
	if !strings.Contains(body, "последняя копия данных") {
		t.Errorf("нет предупреждения о последней копии:\n%s", body)
	}
}

// П2 (вторая половина): без признака подтверждения не удаляется ничего.
func TestBulkDeleteWithoutConfirmDeletesNothing(t *testing.T) {
	rv, calls := newBulkReviewer(nil)
	h := testRouterAction(t, stubReader{byID: byID(
		dl(bid1, store.StateDone, "Дюна"),
	)}, rv, stubCommander{}, stubLive{})

	body := post(t, h, "/ui/delete", idForm(bid1), false).Body.String()
	if len(*calls) != 0 {
		t.Errorf("без подтверждения не должно быть вызовов удаления: %v", *calls)
	}
	if !strings.Contains(body, "страницы подтверждения") {
		t.Errorf("отказ не объяснён:\n%s", body)
	}
}

// Гарды разбора одинаковы на обеих границах: исполняющий запрос получает
// идентификаторы формой заново и на проверки подтверждения опираться не вправе.
func TestBulkBatchGuardsOnBothBoundaries(t *testing.T) {
	over := make([]string, 0, maxBulkDelete+1)
	for i := range maxBulkDelete + 1 {
		over = append(over, fmt.Sprintf("%026d", i))
	}

	cases := []struct {
		name string
		form url.Values
		want string
	}{
		{"неразобранный идентификатор", idForm(bid1, "не-ulid"), "некорректный идентификатор"},
		{"пачка сверх предела", idForm(over...), "не больше"},
		{"пустой набор", url.Values{}, "ни одна загрузка не выбрана"},
	}
	for _, c := range cases {
		for _, path := range []string{"/ui/delete/confirm", "/ui/delete"} {
			t.Run(c.name+" "+path, func(t *testing.T) {
				rv, calls := newBulkReviewer(nil)
				h := testRouterAction(t, stubReader{
					deletable: []store.Download{dl(bid1, store.StateDone, "Дюна")},
					byID:      byID(dl(bid1, store.StateDone, "Дюна")),
				}, rv, stubCommander{}, stubLive{})

				form := url.Values{}
				for k, v := range c.form {
					form[k] = v
				}
				if path == "/ui/delete" {
					form.Set("confirm", "1")
				}
				body := post(t, h, path, form, false).Body.String()

				if len(*calls) != 0 {
					t.Errorf("отказ разбора не должен удалять: %v", *calls)
				}
				if !strings.Contains(body, c.want) {
					t.Errorf("нет объяснения %q:\n%s", c.want, body)
				}
			})
		}
	}
}

// Отказ по пределу возвращает страницу выбора с сохранёнными отметками: иначе
// проверка отнимает всю проделанную человеком работу.
func TestBulkOverLimitKeepsSelection(t *testing.T) {
	rows := []store.Download{dl(bid1, store.StateDone, "Дюна")}
	over := []string{bid1}
	for i := range maxBulkDelete {
		over = append(over, fmt.Sprintf("%026d", i))
	}
	rv, _ := newBulkReviewer(nil)
	h := testRouterAction(t, stubReader{deletable: rows}, rv, stubCommander{}, stubLive{})

	body := post(t, h, "/ui/delete/confirm", idForm(over...), false).Body.String()
	if !strings.Contains(body, `value="`+bid1+`" checked`) {
		t.Errorf("отметка выбора не сохранена:\n%s", body)
	}
}

// Дубликаты в пачке схлопываются: повторный вызов по той же задаче дал бы
// ложный конфликт во второй строке отчёта.
func TestBulkDeleteCollapsesDuplicates(t *testing.T) {
	rv, calls := newBulkReviewer(nil)
	h := testRouterAction(t, stubReader{byID: byID(
		dl(bid1, store.StateDone, "Дюна"),
	)}, rv, stubCommander{}, stubLive{})

	form := idForm(bid1, bid1, bid1)
	form.Set("confirm", "1")
	post(t, h, "/ui/delete", form, false)

	if len(*calls) != 1 {
		t.Errorf("дубликаты не схлопнуты: %v", *calls)
	}
}

// П3: отказ на одной загрузке не отменяет остальных, отчёт называет обе
// половины поимённо.
func TestBulkDeletePartialFailure(t *testing.T) {
	rv, calls := newBulkReviewer(map[string]error{
		bid2: errors.New("qbittorrent: connection refused"),
	})
	h := testRouterAction(t, stubReader{byID: byID(
		dl(bid1, store.StateDone, "Дюна"),
		dl(bid2, store.StateDone, "Фарго"),
		dl(bid3, store.StateDone, "Оппенгеймер"),
	)}, rv, stubCommander{}, stubLive{})

	form := idForm(bid1, bid2, bid3)
	form.Set("confirm", "1")
	body := post(t, h, "/ui/delete", form, false).Body.String()

	if len(*calls) != 3 {
		t.Fatalf("удаление должно уйти по всем трём: %v", *calls)
	}
	for _, want := range []string{"Дюна", "Фарго", "Оппенгеймер", "Удалено — 2", "Отказ — 1"} {
		if !strings.Contains(body, want) {
			t.Errorf("отчёт не называет %q:\n%s", want, body)
		}
	}
	// Сырой текст ошибки внешнего сервиса наружу не идёт — только публичный канал.
	if strings.Contains(body, "connection refused") {
		t.Errorf("сырая ошибка просочилась в разметку:\n%s", body)
	}
}

// П4: групповой путь прав поштучного не расширяет — недопустимое состояние
// отклоняется тем же конфликтом, остальные выбранные удаляются.
func TestBulkDeleteConflictDoesNotWidenRights(t *testing.T) {
	rv, calls := newBulkReviewer(map[string]error{
		bid2: fmt.Errorf("delete: download in state downloading: %w", worker.ErrConflict),
	})
	h := testRouterAction(t, stubReader{byID: byID(
		dl(bid1, store.StateDone, "Дюна"),
		dl(bid2, store.StateDownloading, "Фарго"),
		dl(bid3, store.StateDone, "Оппенгеймер"),
	)}, rv, stubCommander{}, stubLive{})

	form := idForm(bid1, bid2, bid3)
	form.Set("confirm", "1")
	body := post(t, h, "/ui/delete", form, false).Body.String()

	if len(*calls) != 3 {
		t.Fatalf("допуск проверяет ядро — звать надо все три: %v", *calls)
	}
	if !strings.Contains(body, "Удалено — 2") || !strings.Contains(body, "Отказ — 1") {
		t.Errorf("отчёт не разделил исходы:\n%s", body)
	}
}

// Все удалены — отчёт называет обе загрузки и отказов не содержит.
func TestBulkDeleteAllSucceed(t *testing.T) {
	rv, _ := newBulkReviewer(nil)
	h := testRouterAction(t, stubReader{byID: byID(
		dl(bid1, store.StateDone, "Дюна"),
		dl(bid2, store.StateOrphaned, "Фарго"),
	)}, rv, stubCommander{}, stubLive{})

	form := idForm(bid1, bid2)
	form.Set("confirm", "1")
	body := post(t, h, "/ui/delete", form, false).Body.String()

	if !strings.Contains(body, "Удалено — 2") || strings.Contains(body, "Отказ — ") {
		t.Errorf("ожидались две удалённые без отказов:\n%s", body)
	}
}

// Идентификатор без записи в хранилище назван строкой и на подтверждении, и в
// отчёте: молча выброшенный, он развёл бы подтверждённое с исполненным.
func TestBulkMissingDownloadIsNamed(t *testing.T) {
	rv, _ := newBulkReviewer(map[string]error{
		bid2: fmt.Errorf("delete: %w", store.ErrNotFound),
	})
	h := testRouterAction(t, stubReader{byID: byID(
		dl(bid1, store.StateDone, "Дюна"),
		dl(bid3, store.StateDone, "Оппенгеймер"),
	)}, rv, stubCommander{}, stubLive{})

	confirm := post(t, h, "/ui/delete/confirm", idForm(bid1, bid2, bid3), false).Body.String()
	if !strings.Contains(confirm, bid2) || !strings.Contains(confirm, "не найдена") {
		t.Errorf("подтверждение не назвало ненайденную загрузку:\n%s", confirm)
	}

	form := idForm(bid1, bid2, bid3)
	form.Set("confirm", "1")
	res := post(t, h, "/ui/delete", form, false).Body.String()
	if !strings.Contains(res, bid2) {
		t.Errorf("отчёт не назвал ненайденную загрузку:\n%s", res)
	}
	if !strings.Contains(res, "Удалено — 2") {
		t.Errorf("остальные должны быть удалены:\n%s", res)
	}
}

// Системный отказ останавливает пачку: удаление снимает библиотечные ссылки
// раньше, чем сносит раздачу, поэтому при лежащем qBittorrent проход без
// остановки оставил бы без раскладки все выбранные тайтлы разом.
func TestBulkDeleteStopsOnConsecutiveSystemFailures(t *testing.T) {
	calls := &[]string{}
	rv := bulkReviewer{calls: calls, allErr: errors.New("qbittorrent: unreachable")}
	ids := []string{bid1, bid2, bid3, bid4}
	h := testRouterAction(t, stubReader{byID: byID(
		dl(bid1, store.StateDone, "Дюна"),
		dl(bid2, store.StateDone, "Фарго"),
		dl(bid3, store.StateDone, "Оппенгеймер"),
		dl(bid4, store.StateDone, "Интерстеллар"),
	)}, rv, stubCommander{}, stubLive{})

	form := idForm(ids...)
	form.Set("confirm", "1")
	body := post(t, h, "/ui/delete", form, false).Body.String()

	if len(*calls) != bulkFailThreshold {
		t.Fatalf("проход должен остановиться после %d отказов, вызовов: %v",
			bulkFailThreshold, *calls)
	}
	if !strings.Contains(body, "Не выполнено — 1") {
		t.Errorf("остаток пачки не назван невыполненным:\n%s", body)
	}
	if !strings.Contains(body, "проход остановлен") {
		t.Errorf("причина остановки не названа:\n%s", body)
	}
}

// Одиночный отказ пачку не рвёт: счётчик подряд идущих отказов сбрасывается на
// каждом успехе, иначе случайная сетевая ошибка обрывала бы всю уборку.
func TestBulkDeleteSingleFailureDoesNotStop(t *testing.T) {
	rv, calls := newBulkReviewer(map[string]error{
		bid2: errors.New("qbittorrent: temporary"),
	})
	h := testRouterAction(t, stubReader{byID: byID(
		dl(bid1, store.StateDone, "Дюна"),
		dl(bid2, store.StateDone, "Фарго"),
		dl(bid3, store.StateDone, "Оппенгеймер"),
		dl(bid4, store.StateDone, "Интерстеллар"),
	)}, rv, stubCommander{}, stubLive{})

	form := idForm(bid1, bid2, bid3, bid4)
	form.Set("confirm", "1")
	body := post(t, h, "/ui/delete", form, false).Body.String()

	if len(*calls) != 4 {
		t.Fatalf("одиночный отказ не должен останавливать проход: %v", *calls)
	}
	if strings.Contains(body, "Не выполнено") {
		t.Errorf("остановки быть не должно:\n%s", body)
	}
}

// Отмена запроса не прекращает необратимую операцию: закрытая вкладка не вправе
// оборвать пачку на середине, в том числе внутри одной загрузки.
func TestBulkDeleteSurvivesRequestCancel(t *testing.T) {
	calls := &[]string{}
	// Reviewer, который проверяет, что контекст исполнения жив, хотя контекст
	// запроса уже отменён.
	rv := bulkReviewer{calls: calls, errs: map[string]error{}}
	h := testRouterAction(t, stubReader{byID: byID(
		dl(bid1, store.StateDone, "Дюна"),
		dl(bid2, store.StateDone, "Фарго"),
	)}, rv, stubCommander{}, stubLive{})

	form := idForm(bid1, bid2)
	form.Set("confirm", "1")
	rr := postCancelled(t, h, "/ui/delete", form)

	if len(*calls) != 2 {
		t.Fatalf("пачка должна дойти до конца при обрыве: %v", *calls)
	}
	if !strings.Contains(rr.Body.String(), "Удалено — 2") {
		t.Errorf("исход не собран:\n%s", rr.Body.String())
	}
}

// Формы страниц удаления работают без JavaScript: обычный POST с рабочим action
// и никаких hx-атрибутов на пути к необратимому действию.
func TestBulkDeleteWorksWithoutJS(t *testing.T) {
	rv, _ := newBulkReviewer(nil)
	h := testRouterAction(t, stubReader{
		deletable: []store.Download{dl(bid1, store.StateDone, "Дюна")},
		byID:      byID(dl(bid1, store.StateDone, "Дюна")),
	}, rv, stubCommander{}, stubLive{})

	sel := get(t, h, "/delete").Body.String()
	if !strings.Contains(sel, `<form method="post" action="/ui/delete/confirm">`) {
		t.Errorf("страница выбора должна нести обычную POST-форму:\n%s", sel)
	}
	conf := post(t, h, "/ui/delete/confirm", idForm(bid1), false).Body.String()
	if !strings.Contains(conf, `action="/ui/delete"`) || !strings.Contains(conf, `name="confirm" value="1"`) {
		t.Errorf("подтверждение должно нести форму исполнения:\n%s", conf)
	}
	for _, page := range []string{sel, conf} {
		if strings.Contains(page, "hx-post") || strings.Contains(page, "hx-get") {
			t.Error("страницы группового удаления не должны зависеть от htmx")
		}
	}
}

// Р1: исполняется ровно подтверждённое множество — список идентификаторов, а не
// предикат «всё, что сейчас разрешено». Задача, ставшая разрешённой уже после
// показа подтверждения, в пачку не попадает.
func TestBulkDeleteRunsOnlyConfirmedSet(t *testing.T) {
	rv, calls := newBulkReviewer(nil)
	// Читатель отдаёт две разрешённые, но подтверждена одна.
	h := testRouterAction(t, stubReader{
		deletable: []store.Download{
			dl(bid1, store.StateDone, "Дюна"),
			dl(bid2, store.StateDone, "Фарго"),
		},
		byID: byID(
			dl(bid1, store.StateDone, "Дюна"),
			dl(bid2, store.StateDone, "Фарго"),
		),
	}, rv, stubCommander{}, stubLive{})

	form := idForm(bid1)
	form.Set("confirm", "1")
	post(t, h, "/ui/delete", form, false)

	if len(*calls) != 1 || (*calls)[0] != bid1 {
		t.Errorf("удалено не подтверждённое множество: %v", *calls)
	}
}

// Ссылка на страницу группового удаления есть в шапке любой страницы веб-UI.
// Без этой проверки удаление пункта из навигации закрыло бы единственный вход
// на страницу молча: сама страница жива, тесты зелёные, попасть некуда.
func TestHeaderLinksToBulkDelete(t *testing.T) {
	dl := store.Download{ID: bid1, State: store.StateReview, DisplayName: "Дюна"}
	rv, _ := newBulkReviewer(nil)
	h := testRouterAction(t, stubReader{list: []store.Download{dl}, one: &dl},
		rv, stubCommander{}, stubLive{})

	for _, path := range []string{"/", "/delete"} {
		body := get(t, h, path).Body.String()
		if !strings.Contains(body, `href="/delete"`) {
			t.Errorf("%s: в шапке нет ссылки на групповое удаление:\n%s", path, body)
		}
	}
}

// Отказ хранилища не выдаётся за «записи нет»: строка говорит, что состояние
// прочитать не удалось, и отметка о последней копии не теряется молча. Иначе
// человек подтверждает пачку, где строка обещала «удалять нечего», а загрузка
// сносится с файлами по-настоящему.
func TestBulkConfirmDistinguishesReadFailure(t *testing.T) {
	rv, _ := newBulkReviewer(nil)
	h := testRouterAction(t, stubReader{
		getErr: errors.New("database is locked"),
	}, rv, stubCommander{}, stubLive{})

	body := post(t, h, "/ui/delete/confirm", idForm(bid1), false).Body.String()
	if strings.Contains(body, "записи нет") || strings.Contains(body, "удалять нечего") {
		t.Errorf("отказ чтения выдан за отсутствие записи:\n%s", body)
	}
	if !strings.Contains(body, "состояние прочитать не удалось") {
		t.Errorf("отказ чтения не назван строкой:\n%s", body)
	}
}

// Потолок времени останавливает проход: пока идёт пачка, общий замок ядра
// удерживается на каждом обращении к qBittorrent, и медленный (но рабочий)
// сосед иначе остановил бы фон целиком, не дав ни одного отказа.
func TestBulkDeleteStopsOnTimeBudget(t *testing.T) {
	calls := &[]string{}
	// Каждое удаление «идёт» дольше всего бюджета — второй единице стартовать
	// уже нельзя.
	// Бюджет укорочен на время теста — иначе проверка стоила бы двух минут.
	orig := bulkBudget
	bulkBudget = time.Millisecond
	t.Cleanup(func() { bulkBudget = orig })
	slow := bulkReviewer{calls: calls, sleep: 3 * time.Millisecond}
	h := testRouterAction(t, stubReader{byID: byID(
		dl(bid1, store.StateDone, "Дюна"),
		dl(bid2, store.StateDone, "Фарго"),
		dl(bid3, store.StateDone, "Оппенгеймер"),
	)}, slow, stubCommander{}, stubLive{})

	form := idForm(bid1, bid2, bid3)
	form.Set("confirm", "1")
	body := post(t, h, "/ui/delete", form, false).Body.String()

	if len(*calls) != 1 {
		t.Fatalf("после исчерпания бюджета новых удалений быть не должно: %v", *calls)
	}
	if !strings.Contains(body, "Не выполнено — 2") {
		t.Errorf("остаток не назван невыполненным:\n%s", body)
	}
	if !strings.Contains(body, "дольше отведённого времени") {
		t.Errorf("причина остановки не названа:\n%s", body)
	}
}

// Форматирующие символы юникода в имени раздачи не доезжают до экрана: с ними
// заголовок читается не так, как хранится, а поимённое чтение заголовков — и
// есть предохранитель необратимого группового удаления.
func TestBulkTitleStripsFormattingRunes(t *testing.T) {
	const rtl = "\u202e" // RIGHT-TO-LEFT OVERRIDE
	d := dl(bid1, store.StateDone, "Дюна"+rtl+"vkm.iso \U0001F468\u200D\U0001F469\u200D\U0001F467")
	rv, _ := newBulkReviewer(nil)
	h := testRouterAction(t, stubReader{
		deletable: []store.Download{d},
		byID:      byID(d),
	}, rv, stubCommander{}, stubLive{})

	pages := map[string]string{
		"/delete": get(t, h, "/delete").Body.String(),
		"/ui/delete/confirm": post(t, h, "/ui/delete/confirm",
			idForm(bid1), false).Body.String(),
	}
	for path, body := range pages {
		if strings.Contains(body, rtl) {
			t.Errorf("%s: bidi-символ уехал в разметку дословно", path)
		}
		if !strings.Contains(body, "Дюна") {
			t.Errorf("%s: заголовок потерялся целиком:\n%s", path, body)
		}
		// Составные эмодзи не рассыпаются: снимается класс bidi, а не весь Cf.
		if !strings.Contains(body, "\U0001F468\u200D\U0001F469\u200D\U0001F467") {
			t.Errorf("%s: соединитель составного эмодзи снят вместе с bidi:\n%s", path, body)
		}
	}
}
