package tgbot

import (
	"context"
	"io"
	"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
}

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})
	case tgbotapi.EditMessageTextConfig:
		f.edits = append(f.edits, sentMsg{m.ChatID, m.Text, m.ReplyMarkup != nil})
	}
	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
}

func (f *fakeIngestor) Ingest(_ context.Context, req ingest.Request) (ingest.Result, error) {
	f.lastReq = req
	return f.res, nil
}

type fakeReviewer struct {
	data      *worker.ReviewData
	applied   []string
	refined   map[string]string
	typed     map[string]string
	deferred  []string
	canceled  []string
	retried   []string
	deleted   []string
	dismissed []string
}

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 nil
}
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) SetType(_ context.Context, id string, t string) error {
	if f.typed == nil {
		f.typed = map[string]string{}
	}
	f.typed[id] = t
	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: `["неполный пак"]`,
		},
		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.NewTextHandler(io.Discard, nil)))
	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, "Принято #"+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)
	}
}

func TestBot_CallbackType(t *testing.T) {
	b, _, _, rev := newTestBot(t, []int64{7})
	b.handleCallback(context.Background(), cbFrom(7, "type:"+tid+":movie"))
	if rev.typed[tid] != "movie" {
		t.Errorf("typed = %v", rev.typed)
	}
}

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, "Нужно подтверждение #"+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, "#"+tid) {
		t.Errorf("sent = %+v", api.sent)
	}
	if !api.sent[0].hasKB { // кнопка повтора
		t.Error("уведомление о падении без клавиатуры повтора")
	}
}

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("type:" + tid + ":series")
	if a != "type" || id != tid || v != "series" {
		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)
	}
}

// Нажатие устаревшей кнопки со старым числовым 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)
	}
}
