package tgbot

import (
	"context"
	"database/sql"
	"errors"
	"fmt"
	"log/slog"
	"strings"
	"testing"

	tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"

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

// fakeAPI записывает исходящие Chattable; обновления не нужны (хендлеры зовём
// напрямую).
type fakeAPI struct {
	sent    []sentMsg
	edits   []sentMsg
	answers []string
	fileURL string // GetFileDirectURL возвращает это (для .torrent-документов)
	fileErr error
}

type sentMsg struct {
	chatID    int64
	text      string
	hasKB     bool
	parseMode string
}

func (f *fakeAPI) Send(c tgbotapi.Chattable) (tgbotapi.Message, error) {
	switch m := c.(type) {
	case tgbotapi.MessageConfig:
		f.sent = append(f.sent, sentMsg{m.ChatID, m.Text, m.ReplyMarkup != nil, m.ParseMode})
	case tgbotapi.EditMessageTextConfig:
		f.edits = append(f.edits, sentMsg{m.ChatID, m.Text, m.ReplyMarkup != nil, m.ParseMode})
	}
	return tgbotapi.Message{MessageID: 1}, nil
}
func (f *fakeAPI) Request(c tgbotapi.Chattable) (*tgbotapi.APIResponse, error) {
	if cb, ok := c.(tgbotapi.CallbackConfig); ok {
		f.answers = append(f.answers, cb.Text)
	}
	return &tgbotapi.APIResponse{Ok: true}, nil
}
func (f *fakeAPI) GetUpdatesChan(tgbotapi.UpdateConfig) tgbotapi.UpdatesChannel { return nil }
func (f *fakeAPI) StopReceivingUpdates()                                        {}
func (f *fakeAPI) GetFileDirectURL(string) (string, error)                      { return f.fileURL, f.fileErr }

type fakeIngestor struct {
	lastReq ingest.Request
	res     ingest.Result
	err     error
}

func (f *fakeIngestor) Ingest(_ context.Context, req ingest.Request) (ingest.Result, error) {
	f.lastReq = req
	if f.err != nil {
		// Контракт приёма: на ошибке результат нулевой (ingest.Ingest).
		return ingest.Result{}, f.err
	}
	return f.res, nil
}

type fakeReviewer struct {
	data      *worker.ReviewData
	applied   []string
	refined   map[string]string
	deferred  []string
	canceled  []string
	retried   []string
	deleted   []string
	dismissed []string
	chosen    map[string]string // downloadID → выбранный candidateID
	applyErr  error             // чем отвечает Apply (nil — успехом)
}

func (f *fakeReviewer) ReviewData(context.Context, string) (*worker.ReviewData, error) {
	return f.data, nil
}
func (f *fakeReviewer) Apply(_ context.Context, id string) error {
	f.applied = append(f.applied, id)
	return f.applyErr
}
func (f *fakeReviewer) Refine(_ context.Context, id string, hint string) error {
	if f.refined == nil {
		f.refined = map[string]string{}
	}
	f.refined[id] = hint
	return nil
}
func (f *fakeReviewer) ChooseCandidate(_ context.Context, id, candidateID string) error {
	if f.chosen == nil {
		f.chosen = map[string]string{}
	}
	f.chosen[id] = candidateID
	return nil
}
func (f *fakeReviewer) Defer(_ context.Context, id string) error {
	f.deferred = append(f.deferred, id)
	return nil
}
func (f *fakeReviewer) Cancel(_ context.Context, id string) error {
	f.canceled = append(f.canceled, id)
	return nil
}
func (f *fakeReviewer) Retry(_ context.Context, id string) error {
	f.retried = append(f.retried, id)
	return nil
}
func (f *fakeReviewer) Delete(_ context.Context, id string) error {
	f.deleted = append(f.deleted, id)
	return nil
}
func (f *fakeReviewer) Dismiss(_ context.Context, id string) error {
	f.dismissed = append(f.dismissed, id)
	return nil
}

// tid — валидный lowercase-ULID (callback-data валидируется как ULID).
const tid = "01arz3ndektsv4rrffq69g5fav"

func reviewData(state store.State) *worker.ReviewData {
	s, e := 2, 1
	return &worker.ReviewData{
		Download: store.Download{ID: tid, State: state, DisplayName: "Фарго (2015). Сезон 2", Context: "Фарго, второй сезон", SourceRef: "magnet:?x"},
		Recognition: &store.Recognition{
			Provider: store.NullString("tvdb"), ProviderID: store.NullString("269613"),
			Reasons: `["неполный пак"]`,
		},
		// Эффективные provider/id (как заполняет effectiveProvider) — их и
		// показывают уведомления/карточка, консистентно с веб-страницей.
		Provider: "tvdb", ProviderID: "269613",
		Plan: recognize.Plan{
			Type: recognize.MediaSeries, Title: "Фарго", Year: 2015,
			Files: []recognize.PlanFile{{Src: "e1.mkv", Role: recognize.RoleEpisode, Season: &s, Episode: &e}},
		},
		Preview: []layout.Link{
			{Src: "e1.mkv", Dst: "/srv/media/series/Фарго (2015)/Season 02/Фарго (2015) S02E01.mkv"},
		},
	}
}

func newTestBot(t *testing.T, allowed []int64) (*Bot, *fakeAPI, *fakeIngestor, *fakeReviewer) {
	t.Helper()
	api := &fakeAPI{}
	ing := &fakeIngestor{res: ingest.Result{DownloadID: tid, State: store.StateDownloading}}
	rev := &fakeReviewer{data: reviewData(store.StateReview)}
	b := New(api, ing, rev, Config{AllowedUserIDs: allowed, WebBaseURL: "http://host:8080"},
		slog.New(slog.DiscardHandler))
	return b, api, ing, rev
}

func msgFrom(userID int64, text string) *tgbotapi.Message {
	return &tgbotapi.Message{
		MessageID: 1, From: &tgbotapi.User{ID: userID}, Chat: &tgbotapi.Chat{ID: userID}, Text: text,
	}
}

func TestBot_IngestFromMagnet(t *testing.T) {
	b, api, ing, _ := newTestBot(t, []int64{7})
	b.handleMessage(context.Background(), msgFrom(7, "крутой сериал\nmagnet:?xt=urn:btih:ABC"))

	if !strings.HasPrefix(ing.lastReq.Source, "magnet:?xt=urn:btih:ABC") {
		t.Errorf("source = %q", ing.lastReq.Source)
	}
	if ing.lastReq.Context != "крутой сериал" {
		t.Errorf("context = %q", ing.lastReq.Context)
	}
	if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "Принято #"+idCode(tid)) {
		t.Errorf("sent = %+v", api.sent)
	}
}

// Дедуп на приёме (дубль на уже активную задачу) → явный ответ «дубль …
// добавление отменено», а не «Принято».
func TestBot_IngestDeduplicated(t *testing.T) {
	b, api, ing, _ := newTestBot(t, []int64{7})
	ing.res = ingest.Result{DownloadID: tid, State: store.StateDownloading, Deduplicated: true}

	b.handleMessage(context.Background(), msgFrom(7, "magnet:?xt=urn:btih:ABC"))

	if len(api.sent) != 1 {
		t.Fatalf("sent = %+v", api.sent)
	}
	txt := api.sent[0].text
	if !strings.Contains(txt, "Дубль") || !strings.Contains(txt, tid) || strings.Contains(txt, "Принято") {
		t.Errorf("ожидалось сообщение о дубле с #%s, got %q", tid, txt)
	}
}

// Дедуп на «спящую» desync-запись (target_missing) → сообщение зовёт привязать
// заново/закрыть, а не «дубль активной».
func TestBot_IngestDeduplicatedDesync(t *testing.T) {
	b, api, ing, _ := newTestBot(t, []int64{7})
	ing.res = ingest.Result{DownloadID: tid, State: store.StateTargetMissing, Deduplicated: true}

	b.handleMessage(context.Background(), msgFrom(7, "magnet:?xt=urn:btih:ABC"))

	if len(api.sent) != 1 {
		t.Fatalf("sent = %+v", api.sent)
	}
	txt := api.sent[0].text
	if !strings.Contains(txt, "без цели") || !strings.Contains(txt, tid) {
		t.Errorf("ожидалось сообщение о записи без цели с #%s, got %q", tid, txt)
	}
}

func TestBot_DeniesUnknownUser(t *testing.T) {
	b, api, ing, _ := newTestBot(t, []int64{7})
	b.handleMessage(context.Background(), msgFrom(999, "magnet:?xt=urn:btih:ABC"))

	if len(ing.lastReq.Source) != 0 {
		t.Error("ingest не должен вызываться для чужого пользователя")
	}
	if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "Доступ запрещён") {
		t.Errorf("sent = %+v", api.sent)
	}
}

func TestBot_NoMagnet(t *testing.T) {
	b, api, _, _ := newTestBot(t, []int64{7})
	b.handleMessage(context.Background(), msgFrom(7, "привет"))
	if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "Не вижу magnet") {
		t.Errorf("sent = %+v", api.sent)
	}
}

func TestBot_RefineViaReply(t *testing.T) {
	b, _, _, rev := newTestBot(t, []int64{7})
	// Кнопка «Уточнить» поставила ожидание подсказки для чата 7.
	b.setPending(7, tid)
	b.handleMessage(context.Background(), msgFrom(7, "это второй сезон"))

	if rev.refined[tid] != "это второй сезон" {
		t.Errorf("refine = %v", rev.refined)
	}
}

func cbFrom(userID int64, data string) *tgbotapi.CallbackQuery {
	return &tgbotapi.CallbackQuery{
		ID: "cb", From: &tgbotapi.User{ID: userID}, Data: data,
		Message: &tgbotapi.Message{MessageID: 99, Chat: &tgbotapi.Chat{ID: userID}},
	}
}

func TestBot_CallbackApply(t *testing.T) {
	b, api, _, rev := newTestBot(t, []int64{7})
	b.handleCallback(context.Background(), cbFrom(7, "apply:"+tid))

	if len(rev.applied) != 1 || rev.applied[0] != tid {
		t.Errorf("applied = %v", rev.applied)
	}
	if len(api.answers) != 1 {
		t.Errorf("answers = %v", api.answers)
	}
	if len(api.edits) != 1 { // карточка обновлена на месте
		t.Errorf("edits = %v", api.edits)
	}
	if api.edits[0].parseMode != tgbotapi.ModeHTML {
		t.Errorf("edit parse mode = %q, want HTML", api.edits[0].parseMode)
	}
}

func TestBot_CallbackRefineSetsPending(t *testing.T) {
	b, api, _, _ := newTestBot(t, []int64{7})
	b.handleCallback(context.Background(), cbFrom(7, "refine:"+tid))

	if id, ok := b.takePending(7); !ok || id != tid {
		t.Errorf("pending = %s,%v", id, ok)
	}
	if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "подсказкой") {
		t.Errorf("sent = %+v", api.sent)
	}
}

func TestBot_CallbackDeniesUnknown(t *testing.T) {
	b, _, _, rev := newTestBot(t, []int64{7})
	b.handleCallback(context.Background(), cbFrom(999, "apply:"+tid))
	if len(rev.applied) != 0 {
		t.Error("чужой колбэк не должен исполняться")
	}
}

func TestBot_NotifyReview(t *testing.T) {
	b, api, _, _ := newTestBot(t, []int64{7, 8})
	b.Notify(context.Background(), tid, worker.EventReview)

	if len(api.sent) != 2 { // обоим доверенным
		t.Fatalf("sent to %d chats, want 2", len(api.sent))
	}
	if !strings.Contains(api.sent[0].text, "Нужно подтверждение #"+idCode(tid)) {
		t.Errorf("card text = %q", api.sent[0].text)
	}
	if !api.sent[0].hasKB {
		t.Error("карточка ревью без клавиатуры")
	}
}

func TestBot_NotifyDone(t *testing.T) {
	b, api, _, rev := newTestBot(t, []int64{7})
	rev.data = reviewData(store.StateDone)
	b.Notify(context.Background(), tid, worker.EventDone)

	// Заголовок берётся из display_name (консистентно с веб-UI), а не из Plan.Title.
	if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "Готово") ||
		!strings.Contains(api.sent[0].text, "Фарго (2015). Сезон 2") {
		t.Errorf("sent = %+v", api.sent)
	}
}

func TestBot_NotifyFailed(t *testing.T) {
	b, api, _, rev := newTestBot(t, []int64{7})
	rev.data = reviewData(store.StateFailed)
	b.Notify(context.Background(), tid, worker.EventFailed)

	// В ошибке — и заголовок (display_name), и #id для поиска по логам.
	if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "не удалась") ||
		!strings.Contains(api.sent[0].text, "Фарго (2015). Сезон 2") ||
		!strings.Contains(api.sent[0].text, "#"+idCode(tid)) {
		t.Errorf("sent = %+v", api.sent)
	}
	if !api.sent[0].hasKB { // кнопка повтора
		t.Error("уведомление о падении без клавиатуры повтора")
	}
}

// HTML parse mode включён у всех исходящих: внешний текст (название, источник,
// причины) должен экранироваться, а download id — уходить моноширинным <code>.
// Иначе спецсимволы (`<`/`>`/`&`) в названии/пути сломали бы разметку.
func TestBot_NotifyEscapesExternalText(t *testing.T) {
	b, api, _, rev := newTestBot(t, []int64{7})
	rd := reviewData(store.StateReview)
	rd.Download.DisplayName = "" // чтобы карточка показала Plan.Title через guessLine
	rd.Plan.Title = "Tom & Jerry <b>x</b>"
	// Источник длиннее лимита shorten(80) со спецсимволом ровно на границе:
	// проверяем, что esc идёт ПОСЛЕ усечения (иначе сущность разрубится).
	rd.Download.Context = strings.Repeat("a", 79) + "&" + strings.Repeat("b", 5)
	rev.data = rd

	b.Notify(context.Background(), tid, worker.EventReview)

	if len(api.sent) != 1 {
		t.Fatalf("sent = %+v", api.sent)
	}
	msg := api.sent[0]
	if msg.parseMode != tgbotapi.ModeHTML {
		t.Errorf("parse mode = %q, want HTML", msg.parseMode)
	}
	// download id — моноширинным (tap-to-copy).
	if !strings.Contains(msg.text, idCode(tid)) {
		t.Errorf("id не в <code>: %q", msg.text)
	}
	// Спецсимволы названия экранированы, сырая разметка не просочилась.
	if !strings.Contains(msg.text, "Tom &amp; Jerry &lt;b&gt;x&lt;/b&gt;") {
		t.Errorf("название не экранировано: %q", msg.text)
	}
	if strings.Contains(msg.text, "<b>") {
		t.Errorf("сырая разметка просочилась: %q", msg.text)
	}
	// Усечённый источник: сущность на границе цела (esc после shorten даёт
	// «&amp;…», а не разрубленное «&am…»).
	if !strings.Contains(msg.text, "&amp;…") {
		t.Errorf("источник обрезан посреди сущности: %q", msg.text)
	}
}

// Failed-путь (renderFailed): спецсимволы в названии и тексте ошибки
// экранируются, #id уходит моноширинным. Закрывает opErr/failed-ветку формата.
func TestBot_NotifyFailedEscapesExternalText(t *testing.T) {
	b, api, _, rev := newTestBot(t, []int64{7})
	rd := reviewData(store.StateFailed)
	rd.Download.DisplayName = "A & B <x>"
	rd.Download.ErrorMsg = store.NullString("path <bad> & stuff")
	rev.data = rd

	b.Notify(context.Background(), tid, worker.EventFailed)

	if len(api.sent) != 1 {
		t.Fatalf("sent = %+v", api.sent)
	}
	msg := api.sent[0]
	if msg.parseMode != tgbotapi.ModeHTML {
		t.Errorf("parse mode = %q, want HTML", msg.parseMode)
	}
	if !strings.Contains(msg.text, "«A &amp; B &lt;x&gt;»") {
		t.Errorf("название failed не экранировано: %q", msg.text)
	}
	if !strings.Contains(msg.text, "path &lt;bad&gt; &amp; stuff") {
		t.Errorf("текст ошибки не экранирован: %q", msg.text)
	}
	if !strings.Contains(msg.text, "#"+idCode(tid)) {
		t.Errorf("id не в <code>: %q", msg.text)
	}
	if strings.Contains(msg.text, "<x>") {
		t.Errorf("сырая разметка просочилась: %q", msg.text)
	}
}

// Карточка ревью: спецсимволы во внешнем пути раскладки и в причинах
// распознавания экранируются (esc после tailPath/join), сырая разметка не
// просачивается. Закрывает сценарий «Внешний путь и причины экранируются».
func TestReviewCard_EscapesPathAndReasons(t *testing.T) {
	b, _, _, _ := newTestBot(t, []int64{7})
	rd := reviewData(store.StateReview)
	rd.Recognition.Reasons = `["Tom & Jerry <b>x</b>"]`
	rd.Preview[0].Dst = "/srv/media/series/A & B <x>/file <y>.mkv"
	text, _ := b.renderCard(rd)

	if !strings.Contains(text, "Tom &amp; Jerry &lt;b&gt;x&lt;/b&gt;") {
		t.Errorf("причины не экранированы:\n%s", text)
	}
	if !strings.Contains(text, "A &amp; B &lt;x&gt;") || !strings.Contains(text, "file &lt;y&gt;.mkv") {
		t.Errorf("путь раскладки не экранирован:\n%s", text)
	}
	if strings.Contains(text, "<b>") || strings.Contains(text, "<x>") {
		t.Errorf("сырая разметка просочилась:\n%s", text)
	}
}

// opErr: при наличии download id он уходит моноширинным (tap-to-copy) под
// ключом download_id=; без id — дружелюбный текст без ключа. Закрывает сценарий
// «Id в сообщении об ошибке копируется».
func TestOpErr_IDMonospace(t *testing.T) {
	if got := opErr("Не удалось выполнить действие", tid); !strings.Contains(got, "download_id="+idCode(tid)) {
		t.Errorf("id не моноширинным под ключом download_id: %q", got)
	}
	if got := opErr("Готово", ""); strings.Contains(got, "download_id") {
		t.Errorf("без id ключа download_id быть не должно: %q", got)
	}
}

func TestBot_CallbackRetry(t *testing.T) {
	b, _, _, rev := newTestBot(t, []int64{7})
	rev.data = reviewData(store.StateFailed)
	b.handleCallback(context.Background(), cbFrom(7, "retry:"+tid))

	if len(rev.retried) != 1 || rev.retried[0] != tid {
		t.Errorf("retried = %v", rev.retried)
	}
}

func TestParseCallback(t *testing.T) {
	a, id, v := parseCallback("pick:" + tid + ":" + tid)
	if a != "pick" || id != tid || v != tid {
		t.Errorf("got %q %q %q", a, id, v)
	}
	a, id, v = parseCallback("apply:" + tid)
	if a != "apply" || id != tid || v != "" {
		t.Errorf("got %q %q %q", a, id, v)
	}
	// Устаревшая числовая кнопка (до перехода на ULID) → id пуст.
	if _, id, _ := parseCallback("apply:5"); id != "" {
		t.Errorf("legacy numeric id must be rejected, got %q", id)
	}
}

// candID1/candID2 — валидные lowercase-ULID id кандидатов (callback-data
// валидируется как ULID).
const (
	candID1 = "01arz3ndektsv4rrffq69g5fb1"
	candID2 = "01arz3ndektsv4rrffq69g5fb2"
)

// findButton ищет inline-кнопку по callback data во всей клавиатуре.
func findButton(kb *tgbotapi.InlineKeyboardMarkup, data string) (tgbotapi.InlineKeyboardButton, bool) {
	for _, row := range kb.InlineKeyboard {
		for _, btn := range row {
			if btn.CallbackData != nil && *btn.CallbackData == data {
				return btn, true
			}
		}
	}
	return tgbotapi.InlineKeyboardButton{}, false
}

// withCandidates возвращает ReviewData с двумя кандидатами (второй — выбран).
func withCandidates() *worker.ReviewData {
	rd := reviewData(store.StateReview)
	rd.Candidates = []store.MetadataCandidate{
		{ID: candID1, Provider: "tvdb", ProviderID: "269613", Title: sql.NullString{String: "Fargo", Valid: true}, Year: sql.NullInt64{Int64: 2014, Valid: true}},
		{ID: candID2, Provider: "tmdb", ProviderID: "60622", Title: sql.NullString{String: "Фарго", Valid: true}, Year: sql.NullInt64{Int64: 2015, Valid: true}, Chosen: true},
	}
	return rd
}

// Карточка ревью с кандидатами показывает кнопку выбора базы; без кандидатов — нет.
func TestBot_ReviewKeyboardBaseButton(t *testing.T) {
	b, _, _, _ := newTestBot(t, []int64{7})

	kb := b.reviewKeyboard(withCandidates())
	if _, ok := findButton(kb, "sources:"+tid); !ok {
		t.Error("карточка с кандидатами должна иметь кнопку выбора базы")
	}

	if kbNone := b.reviewKeyboard(reviewData(store.StateReview)); func() bool {
		_, ok := findButton(kbNone, "sources:"+tid)
		return ok
	}() {
		t.Error("без кандидатов кнопки выбора базы быть не должно")
	}
}

// Список кандидатов: по кнопке на кандидата (pick:<id>:<candID>) + «Назад»;
// выбранный помечен галочкой, дубли по provider:id схлопнуты.
func TestBot_CandidatesKeyboard(t *testing.T) {
	b, _, _, _ := newTestBot(t, []int64{7})
	rd := withCandidates()
	// Дубль второго кандидата (тот же provider:id) — должен схлопнуться.
	rd.Candidates = append(rd.Candidates, store.MetadataCandidate{ID: "01arz3ndektsv4rrffq69g5fb3", Provider: "tmdb", ProviderID: "60622"})

	kb := b.candidatesKeyboard(rd)
	if _, ok := findButton(kb, "pick:"+tid+":"+candID1); !ok {
		t.Error("нет кнопки первого кандидата")
	}
	chosen, ok := findButton(kb, "pick:"+tid+":"+candID2)
	if !ok {
		t.Fatal("нет кнопки выбранного кандидата")
	}
	if !strings.HasPrefix(chosen.Text, "✓ ") {
		t.Errorf("выбранный кандидат должен быть помечен ✓, got %q", chosen.Text)
	}
	if _, ok := findButton(kb, "srcback:"+tid); !ok {
		t.Error("нет кнопки возврата")
	}
	// Дубль не породил третью кнопку выбора.
	if _, ok := findButton(kb, "pick:"+tid+":01arz3ndektsv4rrffq69g5fb3"); ok {
		t.Error("дубль provider:id должен быть схлопнут")
	}
}

// Выбор кандидата: pick с валидным id зовёт ChooseCandidate и обновляет карточку.
func TestBot_CallbackPick(t *testing.T) {
	b, api, _, rev := newTestBot(t, []int64{7})
	rev.data = withCandidates()
	b.handleCallback(context.Background(), cbFrom(7, "pick:"+tid+":"+candID1))

	if rev.chosen[tid] != candID1 {
		t.Errorf("ChooseCandidate получил %q, want %q", rev.chosen[tid], candID1)
	}
	if len(api.edits) != 1 { // карточка обновлена на месте (refreshCard)
		t.Errorf("edits = %v, want 1", api.edits)
	}
}

// Невалидный id кандидата из callback отклоняется на границе — домен не зовём.
func TestBot_CallbackPickInvalidID(t *testing.T) {
	b, api, _, rev := newTestBot(t, []int64{7})
	b.handleCallback(context.Background(), cbFrom(7, "pick:"+tid+":not-a-ulid"))

	if len(rev.chosen) != 0 {
		t.Errorf("невалидный id не должен доходить до домена, chosen = %v", rev.chosen)
	}
	if len(api.answers) != 1 || !strings.Contains(api.answers[0], "устарела") {
		t.Errorf("answers = %v, want понятный ответ", api.answers)
	}
}

// «Назад» из списка кандидатов возвращает карточку, домен не трогает.
func TestBot_CallbackSourcesBack(t *testing.T) {
	b, api, _, rev := newTestBot(t, []int64{7})
	rev.data = withCandidates()

	b.handleCallback(context.Background(), cbFrom(7, "sources:"+tid))
	if len(rev.chosen) != 0 {
		t.Error("разворачивание списка не должно трогать домен")
	}

	b.handleCallback(context.Background(), cbFrom(7, "srcback:"+tid))
	if len(rev.chosen) != 0 {
		t.Error("возврат не должен трогать домен")
	}
	if len(api.edits) != 1 { // srcback обновляет карточку на месте
		t.Errorf("edits = %v, want 1 (srcback refreshCard)", api.edits)
	}
}

// Нажатие устаревшей кнопки со старым числовым id получает понятный ответ.
func TestBot_CallbackStaleButton(t *testing.T) {
	b, api, _, rev := newTestBot(t, []int64{7})
	b.handleCallback(context.Background(), cbFrom(7, "apply:5"))
	if len(rev.applied) != 0 {
		t.Error("устаревшая кнопка не должна исполняться")
	}
	if len(api.answers) != 1 || !strings.Contains(api.answers[0], "устарела") {
		t.Errorf("answers = %v, want понятный ответ", api.answers)
	}
}

// Отказ приёма в Telegram: идентификатора загрузки нет (контракт ingest.Ingest —
// нулевой результат на любом пути ошибки), и обещать его пользователю нельзя.
// Корреляционного ключа у этого транспорта тоже нет — записанный вопрос
// изменения `ingest-nits`; тест фиксирует сегодняшнее состояние, чтобы
// «download_id=» не вернулся в текст молча.
func TestBot_IngestErrorPromisesNoDownloadID(t *testing.T) {
	b, api, ing, _ := newTestBot(t, []int64{7})
	ing.err = errors.New("boom")

	b.handleMessage(context.Background(), msgFrom(7, "magnet:?xt=urn:btih:ABC"))

	if len(api.sent) != 1 {
		t.Fatalf("отправлено сообщений = %d, want 1", len(api.sent))
	}
	got := api.sent[0].text
	if !strings.Contains(got, "Не удалось принять загрузку") {
		t.Errorf("текст отказа = %q", got)
	}
	if strings.Contains(got, "download_id") || strings.Contains(got, idCode(tid)) {
		t.Errorf("в отказе обещан идентификатор загрузки: %q", got)
	}
}

// Непомещающееся целевое имя — штатный отказ, чинимый на ревью: бот отвечает
// конкретно и обновляет карточку (в ней теперь причина), как для коллизии, а не
// падает в общее «Не удалось выполнить действие».
func TestBot_CallbackApplyNameTooLong(t *testing.T) {
	b, api, _, rev := newTestBot(t, []int64{7})
	rev.applyErr = fmt.Errorf("apply: %w", layout.ErrNameTooLong)

	b.handleCallback(context.Background(), cbFrom(7, "apply:"+tid))

	if len(api.answers) != 1 || !strings.Contains(api.answers[0], "не помещается") {
		t.Errorf("answers = %+v, ожидался конкретный ответ", api.answers)
	}
	if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "слишком длинное") {
		t.Errorf("sent = %+v, ожидалось конкретное сообщение", api.sent)
	}
	if len(api.edits) != 1 {
		t.Errorf("edits = %v, карточка должна обновиться причиной", api.edits)
	}
}
