package httpapi_test

import (
	"context"
	"database/sql"
	"encoding/json"
	"fmt"
	"io"
	"log/slog"
	"net/http"
	"net/http/httptest"
	"net/url"
	"strings"
	"testing"

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

// Валидные lowercase-ULID для маршрутов (pathID валидирует формат).
const (
	tid  = "01arz3ndektsv4rrffq69g5fav"
	tid2 = "01arz3ndektsv4rrffq69g5fb0"
	cid  = "01arz3ndektsv4rrffq69g5fc0"
	cid2 = "01arz3ndektsv4rrffq69g5fd0"
)

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

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

type fakeCommander struct {
	cancelled []string
	retried   []string
	err       error
}

func (f *fakeCommander) Cancel(_ context.Context, id string) error {
	if f.err != nil {
		return f.err
	}
	f.cancelled = append(f.cancelled, id)
	return nil
}

func (f *fakeCommander) Retry(_ context.Context, id string) error {
	if f.err != nil {
		return f.err
	}
	f.retried = append(f.retried, id)
	return nil
}

type fakeReader struct {
	list       []store.Download
	get        *store.Download
	pageErr    error
	total      int              // общее число под фильтром (для пагинации); 0 → len(list)
	lastFilter store.ListFilter // последний переданный фильтр
}

func (f *fakeReader) ListDownloads(_ context.Context) ([]store.Download, error) { return f.list, nil }

func (f *fakeReader) ListDownloadsPage(_ context.Context, flt store.ListFilter) ([]store.Download, int, error) {
	f.lastFilter = flt
	if f.pageErr != nil {
		return nil, 0, f.pageErr
	}
	total := f.total
	if total == 0 {
		total = len(f.list)
	}
	return f.list, total, nil
}

func (f *fakeReader) GetDownload(_ context.Context, id string) (*store.Download, error) {
	if f.get != nil {
		return f.get, nil
	}
	return &store.Download{ID: id, State: store.StateCancelled}, nil
}
func (f *fakeReader) LayoutSizeByDownload(_ context.Context, _ []string) (map[string]int64, error) {
	return nil, nil
}
func (f *fakeReader) ListDeletableDownloads(_ context.Context) ([]store.Download, error) {
	return nil, nil
}

func newServer(t *testing.T, d httpapi.Deps) *httptest.Server {
	t.Helper()
	if d.Logger == nil {
		d.Logger = slog.New(slog.DiscardHandler)
	}
	h, err := httpapi.NewRouter(d)
	if err != nil {
		t.Fatalf("NewRouter: %v", err)
	}
	srv := httptest.NewServer(h)
	t.Cleanup(srv.Close)
	return srv
}

func TestAPIAdd(t *testing.T) {
	ing := &fakeIngestor{res: ingest.Result{DownloadID: tid, Infohashes: []string{"abc"}, State: store.StateDownloading}}
	srv := newServer(t, httpapi.Deps{Ingestor: ing, Commander: &fakeCommander{}, Reader: &fakeReader{}})

	resp, err := http.Post(srv.URL+"/api/downloads", "application/json",
		strings.NewReader(`{"source":"magnet:?xt=urn:btih:abc","context":"Дюна"}`))
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusCreated {
		t.Fatalf("status = %d, want 201", resp.StatusCode)
	}
	var got map[string]any
	_ = json.NewDecoder(resp.Body).Decode(&got)
	if got["id"] != tid || got["state"] != "downloading" {
		t.Errorf("body = %v", got)
	}
	if ing.lastReq.Context != "Дюна" {
		t.Errorf("контекст не проброшен: %q", ing.lastReq.Context)
	}
}

func TestAPIAddBadInput(t *testing.T) {
	// Источник не magnet → ingest оборачивает magnet.ErrNotMagnet; транспорт
	// классифицирует это как 400 (некорректный источник).
	ing := &fakeIngestor{err: fmt.Errorf("ingest: parse source: %w", magnet.ErrNotMagnet)}
	srv := newServer(t, httpapi.Deps{Ingestor: ing, Commander: &fakeCommander{}, Reader: &fakeReader{}})

	resp, err := http.Post(srv.URL+"/api/downloads", "application/json", strings.NewReader(`{"source":"x"}`))
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusBadRequest {
		t.Fatalf("status = %d, want 400", resp.StatusCode)
	}
}

// F7: oversized `.torrent` — ingest.ErrTorrentTooLarge классифицируется как 400
// (промах ввода), а не 500.
func TestAPIAddTorrentTooLarge(t *testing.T) {
	ing := &fakeIngestor{err: fmt.Errorf("ingest: torrent too large: %w", ingest.ErrTorrentTooLarge)}
	srv := newServer(t, httpapi.Deps{Ingestor: ing, Commander: &fakeCommander{}, Reader: &fakeReader{}})

	resp, err := http.Post(srv.URL+"/api/downloads", "application/json", strings.NewReader(`{"source":"x"}`))
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusBadRequest {
		t.Fatalf("status = %d, want 400", resp.StatusCode)
	}
}

func TestAPIList(t *testing.T) {
	reader := &fakeReader{list: []store.Download{
		{ID: tid2, SourceType: store.SourceMagnet, State: store.StateCompleted,
			Infohashes: []store.Infohash{{DownloadID: tid2, Infohash: "abc", Kind: store.HashV1}}},
		{ID: tid, SourceType: store.SourceMagnet, State: store.StateDownloading},
	}}
	srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, Reader: reader})

	resp, err := http.Get(srv.URL + "/api/downloads")
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	var got []map[string]any
	_ = json.NewDecoder(resp.Body).Decode(&got)
	if len(got) != 2 {
		t.Fatalf("len = %d, want 2", len(got))
	}
	hashes, _ := got[0]["infohashes"].([]any)
	if got[0]["state"] != "completed" || len(hashes) != 1 || hashes[0] != "abc" {
		t.Errorf("first = %v", got[0])
	}
}

func TestAPICancel(t *testing.T) {
	cmd := &fakeCommander{}
	srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: cmd, Reader: &fakeReader{}})

	resp, err := http.Post(srv.URL+"/api/downloads/"+tid+"/cancel", "", nil)
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("status = %d, want 200", resp.StatusCode)
	}
	if len(cmd.cancelled) != 1 || cmd.cancelled[0] != tid {
		t.Errorf("cancel вызван неверно: %v", cmd.cancelled)
	}
}

func TestUIRetry(t *testing.T) {
	cmd := &fakeCommander{}
	srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: cmd, Reader: &fakeReader{}})

	resp, err := http.Post(srv.URL+"/ui/downloads/"+tid+"/retry", "application/x-www-form-urlencoded", nil)
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK { // 303 → редирект на / → 200
		t.Fatalf("status = %d, want 200", resp.StatusCode)
	}
	if len(cmd.retried) != 1 || cmd.retried[0] != tid {
		t.Errorf("retry вызван неверно: %v", cmd.retried)
	}
}

func TestAPICommandConflict(t *testing.T) {
	// Конфликт состояния (worker.ErrConflict) → 409, не 500.
	cmd := &fakeCommander{err: fmt.Errorf("cancel: download 5 in wrong state: %w", worker.ErrConflict)}
	srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: cmd, Reader: &fakeReader{}})

	resp, err := http.Post(srv.URL+"/api/downloads/"+tid+"/cancel", "", nil)
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusConflict {
		t.Fatalf("status = %d, want 409", resp.StatusCode)
	}
	var got map[string]any
	_ = json.NewDecoder(resp.Body).Decode(&got)
	if got["download_id"] != tid {
		t.Errorf("download_id корреляции нет: %v", got)
	}
}

func TestAPICommandNotReady(t *testing.T) {
	// Недокачанный источник (worker.ErrNotReady) → 409 с конкретным сообщением
	// «торрент ещё качается», а не генерик «действие недоступно».
	cmd := &fakeCommander{err: fmt.Errorf("apply: торрент ещё качается: %w", worker.ErrNotReady)}
	srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: cmd, Reader: &fakeReader{}})

	resp, err := http.Post(srv.URL+"/api/downloads/"+tid+"/cancel", "", nil)
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusConflict {
		t.Fatalf("status = %d, want 409", resp.StatusCode)
	}
	var got map[string]any
	_ = json.NewDecoder(resp.Body).Decode(&got)
	if msg, _ := got["error"].(string); !strings.Contains(msg, "качается") {
		t.Errorf("error = %q, want содержащее «качается»", msg)
	}
}

func TestAPICommandInvalidInput(t *testing.T) {
	// Промах пользователя (worker.ErrInvalidInput) → 400, не 500.
	cmd := &fakeCommander{err: fmt.Errorf("choose candidate: invalid id %q: %w", "foo", worker.ErrInvalidInput)}
	srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: cmd, Reader: &fakeReader{}})

	resp, err := http.Post(srv.URL+"/api/downloads/"+tid+"/cancel", "", nil)
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusBadRequest {
		t.Fatalf("status = %d, want 400", resp.StatusCode)
	}
}

func TestAPICommandCollision(t *testing.T) {
	// Коллизия цели (layout.ErrCollision) → 409 (штатно ушло в review), не 500.
	cmd := &fakeCommander{err: fmt.Errorf("apply: %w", layout.ErrCollision)}
	srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: cmd, Reader: &fakeReader{}})

	resp, err := http.Post(srv.URL+"/api/downloads/"+tid+"/cancel", "", nil)
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusConflict {
		t.Fatalf("status = %d, want 409", resp.StatusCode)
	}
	var got map[string]any
	_ = json.NewDecoder(resp.Body).Decode(&got)
	if msg, _ := got["error"].(string); !strings.Contains(msg, "уже существует") {
		t.Errorf("error = %q, want содержащее «уже существует»", msg)
	}
}

func TestIndexRenders(t *testing.T) {
	reader := &fakeReader{list: []store.Download{
		{ID: tid, SourceType: store.SourceMagnet, SourceRef: "magnet:?xt=urn:btih:abc", State: store.StateDownloading},
	}}
	srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, Reader: reader})

	resp, err := http.Get(srv.URL + "/")
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	body, _ := io.ReadAll(resp.Body)
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("status = %d", resp.StatusCode)
	}
	if !strings.Contains(string(body), "jellybit") || !strings.Contains(string(body), "downloading") {
		t.Error("страница не содержит ожидаемого контента")
	}
}

// Карточка catched несёт самозавершающийся htmx-поллинг (обновится без
// перезагрузки при переходе в downloading) и человекочитаемый бейдж.
func TestCatchedCardSelfPolls(t *testing.T) {
	reader := &fakeReader{get: &store.Download{
		ID: tid, SourceType: store.SourceMagnet, SourceRef: "magnet:?xt=urn:btih:abc",
		State: store.StateCatched,
	}}
	srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, Reader: reader})

	resp, err := http.Get(srv.URL + "/fragments/downloads/" + tid + "/card")
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	body, _ := io.ReadAll(resp.Body)
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("status = %d", resp.StatusCode)
	}
	s := string(body)
	if !strings.Contains(s, `hx-get="/fragments/downloads/`+tid+`/card"`) {
		t.Errorf("карточка catched без самополлинга: %s", s)
	}
	if !strings.Contains(s, "принято, добавляется") {
		t.Errorf("нет подписи бейджа catched: %s", s)
	}
}

type ingestErr string

func (e ingestErr) Error() string { return string(e) }

func TestIndexTitleFromDisplayName(t *testing.T) {
	// Заголовок карточки — имя раздачи (display_name), а не сырой magnet.
	reader := &fakeReader{list: []store.Download{{
		ID: tid, SourceType: store.SourceMagnet, SourceRef: "magnet:?xt=urn:btih:abc",
		DisplayName: "Дюна: Часть вторая (2024)", State: store.StateDownloading,
	}}}
	srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, Reader: reader})

	resp, err := http.Get(srv.URL + "/")
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	body, _ := io.ReadAll(resp.Body)
	if !strings.Contains(string(body), "Дюна: Часть вторая (2024)") {
		t.Error("заголовок карточки не из display_name")
	}
	if strings.Contains(string(body), "magnet:?xt=urn:btih:abc") {
		t.Error("сырой magnet просочился в список вместо имени раздачи")
	}
}

func TestIndexListFilterParams(t *testing.T) {
	// GET-параметры списка транслируются в store.ListFilter (серверная выборка).
	reader := &fakeReader{total: 100}
	srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, Reader: reader})

	resp, err := http.Get(srv.URL + "/?f=review&q=дюна&page=2&all=1")
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()

	f := reader.lastFilter
	if f.Group != store.GroupReview {
		t.Errorf("Group = %q, want review", f.Group)
	}
	if f.Query != "дюна" {
		t.Errorf("Query = %q", f.Query)
	}
	if !f.IncludeDeleted {
		t.Errorf("IncludeDeleted = false, want true (all=1)")
	}
	if f.Limit != 25 || f.Offset != 25 { // page 2, размер 25
		t.Errorf("Limit/Offset = %d/%d, want 25/25", f.Limit, f.Offset)
	}
}

func TestIndexPageClamp(t *testing.T) {
	reader := &fakeReader{}
	srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, Reader: reader})
	resp, err := http.Get(srv.URL + "/?page=0")
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	if reader.lastFilter.Offset != 0 {
		t.Errorf("page=0 → Offset %d, want 0 (клэмп на 1)", reader.lastFilter.Offset)
	}
}

// detailReviewData — ReviewData для страницы /download/{id} с матчем метабазы.
func detailReviewData(provider, providerID, chosenID, chosenURL string) *worker.ReviewData {
	rd := &worker.ReviewData{
		Download: store.Download{
			ID: tid, State: store.StateReview, SourceType: store.SourceMagnet,
			SourceRef: "magnet:?xt=urn:btih:deadbeef", DisplayName: "Дюна",
			Infohashes: []store.Infohash{{DownloadID: tid, Infohash: "deadbeef", Kind: store.HashV1}},
		},
		Recognition: &store.Recognition{ID: "1", DownloadID: tid, IsCurrent: true},
		Plan: recognize.Plan{
			Type: recognize.MediaMovie, Title: "Дюна", Year: 2024,
			Files: []recognize.PlanFile{{Src: "dune.mkv", Role: recognize.RoleMain}},
		},
		Preview:    []layout.Link{{Src: "dune.mkv", Dst: "/srv/media/movies/Дюна (2024)/Дюна (2024).mkv"}},
		Provider:   provider,
		ProviderID: providerID,
	}
	if chosenID != "" {
		rd.Candidates = []store.MetadataCandidate{{
			ID: cid, Provider: "tmdb", ProviderID: chosenID, Chosen: true,
			URL: store.NullString(chosenURL),
		}}
	}
	return rd
}

func TestDownloadMatchLinkFromCandidate(t *testing.T) {
	// Выбранный кандидат совпадает с эффективным provider+id → ссылка кандидата.
	rv := &fakeReviewer{data: detailReviewData("tmdb", "693134", "693134", "https://custom.example/x")}
	srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
		Reader: &fakeReader{}, Reviewer: rv})

	resp, err := http.Get(srv.URL + "/download/" + tid)
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	body, _ := io.ReadAll(resp.Body)
	if !strings.Contains(string(body), "https://custom.example/x") {
		t.Error("ссылка на запись метабазы (URL кандидата) не показана")
	}
}

func TestDownloadMatchLinkMismatchUsesBuilt(t *testing.T) {
	// Эффективный id (999, ручной override) отличается от выбранного кандидата
	// (693134) → ссылка строится по эффективному id, а не берётся у кандидата.
	rv := &fakeReviewer{data: detailReviewData("tmdb", "999", "693134", "https://custom.example/x")}
	srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
		Reader: &fakeReader{}, Reviewer: rv})

	resp, err := http.Get(srv.URL + "/download/" + tid)
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	body, _ := io.ReadAll(resp.Body)
	if !strings.Contains(string(body), "https://www.themoviedb.org/movie/999") {
		t.Error("ссылка не построена по эффективному provider_id (movie/999)")
	}
	if strings.Contains(string(body), "https://custom.example/x") {
		t.Error("взят URL кандидата, не совпадающего с эффективным id")
	}
}

func TestDownloadTorrentInfoBlock(t *testing.T) {
	rv := &fakeReviewer{data: detailReviewData("tmdb", "693134", "693134", "https://x/y")}
	srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
		Reader: &fakeReader{}, Reviewer: rv})

	resp, err := http.Get(srv.URL + "/download/" + tid)
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	body, _ := io.ReadAll(resp.Body)
	for _, want := range []string{"Информация о торренте", "magnet:?xt=urn:btih:deadbeef", "Дюна"} {
		if !strings.Contains(string(body), want) {
			t.Errorf("страница загрузки не содержит %q", want)
		}
	}
}

// --- Ревью ---

type fakeReviewer struct {
	data          *worker.ReviewData
	applyErr      error
	refined       map[string]string
	ignored       map[string]string
	chosen        map[string]string
	providerSet   map[string]string
	sourceAdded   map[string]string
	applied       []string
	deferred      []string
	undone        []string
	deleted       []string
	dismissed     []string
	relinked      []string
	rerecognized  []string
	cleared       []string
	nameRefreshed []string
}

func (f *fakeReviewer) ReviewData(_ context.Context, _ string) (*worker.ReviewData, error) {
	return f.data, nil
}
func (f *fakeReviewer) Apply(_ context.Context, id string) error {
	if f.applyErr != nil {
		return f.applyErr
	}
	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) IgnoreFile(_ context.Context, id string, src string) error {
	if f.ignored == nil {
		f.ignored = map[string]string{}
	}
	f.ignored[id] = src
	return nil
}
func (f *fakeReviewer) Defer(_ context.Context, id string) error {
	f.deferred = append(f.deferred, id)
	return nil
}
func (f *fakeReviewer) Undo(_ context.Context, id string) error {
	f.undone = append(f.undone, 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
}
func (f *fakeReviewer) Relink(_ context.Context, id string) error {
	f.relinked = append(f.relinked, id)
	return nil
}
func (f *fakeReviewer) Rerecognize(_ context.Context, id string) error {
	f.rerecognized = append(f.rerecognized, id)
	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) SetProviderID(_ context.Context, id string, provider, providerID string) error {
	if f.providerSet == nil {
		f.providerSet = map[string]string{}
	}
	f.providerSet[id] = provider + ":" + providerID
	return nil
}
func (f *fakeReviewer) AddManualSource(_ context.Context, id, provider, providerID string) error {
	if f.sourceAdded == nil {
		f.sourceAdded = map[string]string{}
	}
	f.sourceAdded[id] = provider + ":" + providerID
	return nil
}
func (f *fakeReviewer) ClearProvider(_ context.Context, id string) error {
	f.cleared = append(f.cleared, id)
	return nil
}
func (f *fakeReviewer) RefreshDisplayName(_ context.Context, id string) error {
	f.nameRefreshed = append(f.nameRefreshed, id)
	return nil
}

func seriesReviewData() *worker.ReviewData {
	s, e := 2, 1
	plan := recognize.Plan{
		Type: recognize.MediaSeries, Title: "Фарго", Year: 2015,
		Files: []recognize.PlanFile{
			{Src: "Fargo/e1.mkv", Role: recognize.RoleEpisode, Season: &s, Episode: &e},
		},
	}
	preview := []layout.Link{
		{Src: "Fargo/e1.mkv", Dst: "/srv/media/series/Фарго (2015)/Season 02/Фарго (2015) S02E01.mkv"},
	}
	return &worker.ReviewData{
		Download: store.Download{ID: tid, State: store.StateReview, SourceRef: "magnet:?xt=urn:btih:abc"},
		Recognition: &store.Recognition{
			ID: "1", DownloadID: tid, IsCurrent: true, Reasons: `["нет матча в базе"]`,
		},
		Plan:    plan,
		Preview: preview,
		Candidates: []store.MetadataCandidate{
			{ID: cid, Provider: "tvdb", ProviderID: "269613", Title: store.NullString("Fargo"),
				Year: sql.NullInt64{Int64: 2014, Valid: true}},
			{ID: cid2, Provider: "tmdb", ProviderID: "60622", Title: store.NullString("Fargo")},
		},
		// Единый список источников: нейронка активна (матча нет) + кандидаты.
		Sources: []worker.SourceOption{
			{Kind: worker.SourceNeural, Provider: "none", Title: "Фарго", Year: 2015,
				Type: "series", Active: true, Plan: plan, Preview: preview},
			{Kind: worker.SourceCandidate, CandidateID: cid, Provider: "tvdb", ProviderID: "269613",
				URL: "https://www.thetvdb.com/dereferrer/series/269613", Title: "Fargo", Year: 2014,
				Type: "series", Plan: plan, Preview: preview},
			{Kind: worker.SourceCandidate, CandidateID: cid2, Provider: "tmdb", ProviderID: "60622",
				Title: "Fargo", Type: "series", Plan: plan, Preview: preview},
		},
		Hints: []string{"второй сезон"},
	}
}

// noRedirectClient — не следует за 3xx, чтобы проверять Location.
func noRedirectClient() *http.Client {
	return &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
		return http.ErrUseLastResponse
	}}
}

// Веб-приём при дедупе (в т.ч. на «спящую» desync-запись) ведёт на страницу
// существующей записи, а не на список — пользователь видит, что нового не завели.
func TestUIAddDeduplicatedRedirectsToRecord(t *testing.T) {
	ing := &fakeIngestor{res: ingest.Result{DownloadID: tid, State: store.StateTargetMissing, Deduplicated: true}}
	srv := newServer(t, httpapi.Deps{Ingestor: ing, Commander: &fakeCommander{}, Reader: &fakeReader{}})

	req, _ := http.NewRequest(http.MethodPost, srv.URL+"/ui/downloads",
		strings.NewReader("source=magnet:?xt=urn:btih:abc"))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	resp, err := noRedirectClient().Do(req)
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusSeeOther {
		t.Fatalf("status = %d, want 303", resp.StatusCode)
	}
	if loc := resp.Header.Get("Location"); loc != "/download/"+tid {
		t.Errorf("Location = %q, want /download/%s", loc, tid)
	}
}

func TestReviewRenders(t *testing.T) {
	rv := &fakeReviewer{data: seriesReviewData()}
	srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
		Reader: &fakeReader{}, Reviewer: rv})

	resp, err := http.Get(srv.URL + "/review/" + tid)
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	body, _ := io.ReadAll(resp.Body)
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("status = %d", resp.StatusCode)
	}
	for _, want := range []string{"Фарго", "нет матча в базе", "Fargo/e1.mkv",
		"Season 02", "Применить", "Уточнить",
		"Источник и раскладка", "269613", "распознано нейронкой", "Добавить",
		"Сезон 2"} {
		if !strings.Contains(string(body), want) {
			t.Errorf("страница ревью не содержит %q", want)
		}
	}
	// Кнопки «выбрать» больше нет — выбор одним кликом по радио.
	if strings.Contains(string(body), ">выбрать<") {
		t.Error("страница ревью всё ещё содержит кнопку «выбрать»")
	}
}

func TestReviewShowsMatchLink(t *testing.T) {
	// Подтверждённый матч в списке источников — ссылкой на запись.
	rd := seriesReviewData()
	rd.Provider = "tvdb"
	rd.ProviderID = "269613"
	// Активен кандидат tvdb (индекс 1: нейронка, tvdb, tmdb).
	rd.Sources[0].Active = false
	rd.Sources[1].Active = true
	rv := &fakeReviewer{data: rd}
	srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
		Reader: &fakeReader{}, Reviewer: rv})

	resp, err := http.Get(srv.URL + "/review/" + tid)
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	body, _ := io.ReadAll(resp.Body)
	if !strings.Contains(string(body), "https://www.thetvdb.com/dereferrer/series/269613") {
		t.Error("строка «Выбрано» не содержит ссылку на запись метабазы")
	}
}

func TestChooseCandidate(t *testing.T) {
	rv := &fakeReviewer{data: seriesReviewData()}
	srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
		Reader: &fakeReader{}, Reviewer: rv})

	resp, err := noRedirectClient().PostForm(srv.URL+"/ui/downloads/"+tid+"/candidate",
		map[string][]string{"candidate_id": {cid}})
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	if rv.chosen[tid] != cid {
		t.Errorf("ChooseCandidate получил %q", rv.chosen[tid])
	}
	if loc := resp.Header.Get("Location"); !strings.HasPrefix(loc, "/review/"+tid) {
		t.Errorf("Location = %q", loc)
	}
}

func TestSetProviderAndNoBase(t *testing.T) {
	rv := &fakeReviewer{data: seriesReviewData()}
	srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
		Reader: &fakeReader{}, Reviewer: rv})
	cl := noRedirectClient()

	if _, err := cl.PostForm(srv.URL+"/ui/downloads/"+tid+"/provider",
		map[string][]string{"provider": {"tvdb"}, "provider_id": {"269613"}}); err != nil {
		t.Fatal(err)
	}
	if rv.providerSet[tid] != "tvdb:269613" {
		t.Errorf("SetProviderID получил %q", rv.providerSet[tid])
	}

	if _, err := cl.Post(srv.URL+"/ui/downloads/"+tid+"/nobase", "", nil); err != nil {
		t.Fatal(err)
	}
	if len(rv.cleared) != 1 || rv.cleared[0] != tid {
		t.Errorf("ClearProvider = %v", rv.cleared)
	}
}

func TestAddManualSource(t *testing.T) {
	rv := &fakeReviewer{data: seriesReviewData()}
	srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
		Reader: &fakeReader{}, Reviewer: rv})
	cl := noRedirectClient()

	// URL записи → провайдер+id разбираются на границе, воркер получает их.
	if _, err := cl.PostForm(srv.URL+"/ui/downloads/"+tid+"/source",
		map[string][]string{"provider": {"tmdb"}, "provider_id": {"https://www.themoviedb.org/tv/60622-fargo"}}); err != nil {
		t.Fatal(err)
	}
	if rv.sourceAdded[tid] != "tmdb:60622" {
		t.Errorf("AddManualSource получил %q, want tmdb:60622", rv.sourceAdded[tid])
	}
}

func TestRefreshNameNonHTMXRedirectsToDownload(t *testing.T) {
	rv := &fakeReviewer{data: seriesReviewData()}
	srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
		Reader: &fakeReader{}, Reviewer: rv})

	resp, err := noRedirectClient().PostForm(srv.URL+"/ui/downloads/"+tid+"/refresh-name", nil)
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	if len(rv.nameRefreshed) != 1 || rv.nameRefreshed[0] != tid {
		t.Errorf("RefreshDisplayName вызван = %v", rv.nameRefreshed)
	}
	// Без htmx — PRG на страницу загрузки (увидеть новое имя здесь же), не на список.
	if loc := resp.Header.Get("Location"); loc != "/download/"+tid {
		t.Errorf("Location = %q, want /download/%s", loc, tid)
	}
}

func TestRefreshNameHTMXSwapsMain(t *testing.T) {
	rv := &fakeReviewer{data: seriesReviewData()}
	srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
		Reader: &fakeReader{}, Reviewer: rv})

	req, _ := http.NewRequest(http.MethodPost, srv.URL+"/ui/downloads/"+tid+"/refresh-name", nil)
	req.Header.Set("HX-Request", "true")
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("status = %d, want 200", resp.StatusCode)
	}
	body, _ := io.ReadAll(resp.Body)
	if !strings.Contains(string(body), `id="download-main"`) {
		t.Error("htmx-ответ не содержит фрагмент download-main")
	}
	if len(rv.nameRefreshed) != 1 {
		t.Errorf("RefreshDisplayName вызван = %v", rv.nameRefreshed)
	}
}

// Стоп-кран «Закрыть» рендерится в danger-зоне для терминального состояния
// (target_missing) и постит на /dismiss.
func TestDownloadPageShowsDismissButtonOnTerminal(t *testing.T) {
	rd := seriesReviewData()
	rd.Download.State = store.StateTargetMissing
	rv := &fakeReviewer{data: rd}
	srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
		Reader: &fakeReader{}, Reviewer: rv})

	resp, err := http.Get(srv.URL + "/download/" + tid)
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	body, _ := io.ReadAll(resp.Body)
	if !strings.Contains(string(body), "/dismiss") || !strings.Contains(string(body), "Закрыть загрузку") {
		t.Error("кнопка «Закрыть» не показана в danger-зоне для target_missing")
	}
}

// POST /ui/downloads/{id}/dismiss вызывает Reviewer.Dismiss.
func TestUIDismiss(t *testing.T) {
	rv := &fakeReviewer{data: seriesReviewData()}
	srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
		Reader: &fakeReader{}, Reviewer: rv})

	resp, err := http.Post(srv.URL+"/ui/downloads/"+tid+"/dismiss", "application/x-www-form-urlencoded", nil)
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK { // 303 → редирект на / → 200
		t.Fatalf("status = %d, want 200", resp.StatusCode)
	}
	if len(rv.dismissed) != 1 || rv.dismissed[0] != tid {
		t.Errorf("Dismiss вызван неверно: %v", rv.dismissed)
	}
}

func TestDownloadPageShowsRefreshNameButtonOnDone(t *testing.T) {
	// Кнопка гейтится наличием распознавания, не состоянием ревью: на терминальном
	// done (есть план) она всё равно доступна.
	rd := seriesReviewData()
	rd.Download.State = store.StateDone
	rv := &fakeReviewer{data: rd}
	srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
		Reader: &fakeReader{}, Reviewer: rv})

	resp, err := http.Get(srv.URL + "/download/" + tid)
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	body, _ := io.ReadAll(resp.Body)
	if !strings.Contains(string(body), "/refresh-name") {
		t.Error("кнопка «Обновить имя» не показана на done при наличии распознавания")
	}
}

func TestDownloadPageHidesRefreshNameWithoutRecognition(t *testing.T) {
	rv := &fakeReviewer{data: &worker.ReviewData{
		Download: store.Download{ID: tid, State: store.StateDownloading, SourceRef: "magnet:?xt=urn:btih:abc"},
	}}
	srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
		Reader: &fakeReader{}, Reviewer: rv})

	resp, err := http.Get(srv.URL + "/download/" + tid)
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	body, _ := io.ReadAll(resp.Body)
	if strings.Contains(string(body), "/refresh-name") {
		t.Error("кнопка «Обновить имя» показана без распознавания")
	}
}

func TestAddManualSource_RejectsBadURL(t *testing.T) {
	rv := &fakeReviewer{data: seriesReviewData()}
	srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
		Reader: &fakeReader{}, Reviewer: rv})

	// URL TVDB со slug (без числового id) не разбирается → отклонён, источник
	// не добавлен, пользователю уходит сообщение (не «внутренняя ошибка»).
	resp, err := noRedirectClient().PostForm(srv.URL+"/ui/downloads/"+tid+"/source",
		map[string][]string{"provider": {"tvdb"}, "provider_id": {"https://www.thetvdb.com/series/fargo"}})
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	if _, called := rv.sourceAdded[tid]; called {
		t.Errorf("невалидный ввод не должен вызывать AddManualSource: %v", rv.sourceAdded)
	}
	loc := resp.Header.Get("Location")
	if !strings.HasPrefix(loc, "/review/"+tid) || !strings.Contains(loc, "err=") {
		t.Errorf("Location = %q, want редирект на ревью с ?err=", loc)
	}
	if strings.Contains(loc, "%D0%B2%D0%BD%D1%83%D1%82%D1%80%D0%B5%D0%BD") { // «внутрен…»
		t.Errorf("ошибка ввода подана как внутренняя: %q", loc)
	}
}

// TestAddManualSource_HTMXError: невалидный ручной ввод на htmx-пути возвращает
// партиал блока с ошибкой (не редирект, не «внутренняя ошибка»), источник не
// добавлен.
func TestAddManualSource_HTMXError(t *testing.T) {
	rv := &fakeReviewer{data: seriesReviewData()}
	srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
		Reader: &fakeReader{}, Reviewer: rv})

	form := url.Values{"provider": {"tvdb"}, "provider_id": {"https://www.thetvdb.com/series/fargo"}}
	req, err := http.NewRequest(http.MethodPost, srv.URL+"/ui/downloads/"+tid+"/source",
		strings.NewReader(form.Encode()))
	if err != nil {
		t.Fatal(err)
	}
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	req.Header.Set("HX-Request", "true")
	resp, err := noRedirectClient().Do(req)
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	body, _ := io.ReadAll(resp.Body)
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("status = %d, want 200 (партиал с ошибкой)", resp.StatusCode)
	}
	if _, called := rv.sourceAdded[tid]; called {
		t.Errorf("невалидный ввод не должен вызывать AddManualSource: %v", rv.sourceAdded)
	}
	if !strings.Contains(string(body), `id="source-block"`) {
		t.Error("htmx-ответ не содержит блок источника")
	}
	if !strings.Contains(string(body), "block-error") {
		t.Error("htmx-ответ не содержит баннер ошибки блока")
	}
	if strings.Contains(string(body), "внутренняя ошибка") {
		t.Error("ошибка ввода подана как внутренняя")
	}
}

func TestApplyRedirectsToIndex(t *testing.T) {
	rv := &fakeReviewer{data: seriesReviewData()}
	srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
		Reader: &fakeReader{}, Reviewer: rv})

	resp, err := noRedirectClient().Post(srv.URL+"/ui/downloads/"+tid+"/apply", "", nil)
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusSeeOther {
		t.Fatalf("status = %d, want 303", resp.StatusCode)
	}
	if loc := resp.Header.Get("Location"); loc != "/" {
		t.Errorf("Location = %q, want /", loc)
	}
	if len(rv.applied) != 1 {
		t.Errorf("Apply не вызван: %v", rv.applied)
	}
}

func TestApplyCollisionRedirectsToReview(t *testing.T) {
	rv := &fakeReviewer{data: seriesReviewData(), applyErr: ingestErr("collision")}
	srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
		Reader: &fakeReader{}, Reviewer: rv})

	resp, err := noRedirectClient().Post(srv.URL+"/ui/downloads/"+tid+"/apply", "", nil)
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	if loc := resp.Header.Get("Location"); !strings.HasPrefix(loc, "/review/"+tid) {
		t.Errorf("Location = %q, want /review/{id}?err=...", loc)
	}
}

func TestRefinePostsHint(t *testing.T) {
	rv := &fakeReviewer{data: seriesReviewData()}
	srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
		Reader: &fakeReader{}, Reviewer: rv})

	resp, err := noRedirectClient().PostForm(srv.URL+"/ui/downloads/"+tid+"/refine",
		map[string][]string{"hint": {"это второй сезон"}})
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	if rv.refined[tid] != "это второй сезон" {
		t.Errorf("Refine получил %q", rv.refined[tid])
	}
	if loc := resp.Header.Get("Location"); !strings.HasPrefix(loc, "/review/"+tid) {
		t.Errorf("Location = %q", loc)
	}
}

func TestIgnoreFile(t *testing.T) {
	rv := &fakeReviewer{data: seriesReviewData()}
	srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
		Reader: &fakeReader{}, Reviewer: rv})
	cl := noRedirectClient()

	if _, err := cl.PostForm(srv.URL+"/ui/downloads/"+tid+"/ignore",
		map[string][]string{"src": {"Fargo/sample.mkv"}}); err != nil {
		t.Fatal(err)
	}
	if rv.ignored[tid] != "Fargo/sample.mkv" {
		t.Errorf("IgnoreFile получил %q", rv.ignored[tid])
	}
}

// TestChooseCandidateHTMX: на htmx-запрос выбор возвращает партиал блока
// источника (а не полную страницу и не редирект), обновлённый под выбор.
func TestChooseCandidateHTMX(t *testing.T) {
	rv := &fakeReviewer{data: seriesReviewData()}
	srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
		Reader: &fakeReader{}, Reviewer: rv})

	req, err := http.NewRequest(http.MethodPost, srv.URL+"/ui/downloads/"+tid+"/candidate",
		strings.NewReader("candidate_id="+cid))
	if err != nil {
		t.Fatal(err)
	}
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	req.Header.Set("HX-Request", "true")
	resp, err := noRedirectClient().Do(req)
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	body, _ := io.ReadAll(resp.Body)
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("status = %d, want 200 (партиал, не редирект)", resp.StatusCode)
	}
	if rv.chosen[tid] != cid {
		t.Errorf("ChooseCandidate получил %q", rv.chosen[tid])
	}
	// Ответ — партиал блока, а не полная страница.
	if strings.Contains(string(body), "<!doctype html>") {
		t.Error("htmx-ответ должен быть партиалом, а не полной страницей")
	}
	if !strings.Contains(string(body), `id="source-block"`) {
		t.Error("htmx-ответ не содержит корневой контейнер блока #source-block")
	}
}

func TestUndoAndDefer(t *testing.T) {
	rv := &fakeReviewer{data: seriesReviewData()}
	srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
		Reader: &fakeReader{}, Reviewer: rv})
	cl := noRedirectClient()

	if _, err := cl.Post(srv.URL+"/ui/downloads/"+tid+"/undo", "", nil); err != nil {
		t.Fatal(err)
	}
	if _, err := cl.Post(srv.URL+"/ui/downloads/"+tid+"/defer", "", nil); err != nil {
		t.Fatal(err)
	}
	if len(rv.undone) != 1 || len(rv.deferred) != 1 {
		t.Errorf("undo=%v defer=%v", rv.undone, rv.deferred)
	}
}

func TestRelink(t *testing.T) {
	rv := &fakeReviewer{data: seriesReviewData()}
	srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
		Reader: &fakeReader{}, Reviewer: rv})
	cl := noRedirectClient()

	if _, err := cl.Post(srv.URL+"/ui/downloads/"+tid+"/relink", "", nil); err != nil {
		t.Fatal(err)
	}
	if len(rv.relinked) != 1 || rv.relinked[0] != tid {
		t.Errorf("relinked = %v, want [%s]", rv.relinked, tid)
	}
}

func TestRerecognize(t *testing.T) {
	rv := &fakeReviewer{data: seriesReviewData()}
	srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
		Reader: &fakeReader{}, Reviewer: rv})
	cl := noRedirectClient()

	if _, err := cl.Post(srv.URL+"/ui/downloads/"+tid+"/rerecognize", "", nil); err != nil {
		t.Fatal(err)
	}
	if len(rv.rerecognized) != 1 || rv.rerecognized[0] != tid {
		t.Errorf("rerecognized = %v, want [%s]", rv.rerecognized, tid)
	}
}

func TestAPICommandNameTooLong(t *testing.T) {
	// Непомещающееся целевое имя (layout.ErrNameTooLong) → 409 (штатно ушло в
	// review), не 500: это конфликт, требующий разбора, а не сбой сервера.
	// Наружу — нейтральное сообщение, сырой текст ошибки остаётся в логах.
	cmd := &fakeCommander{err: fmt.Errorf("apply: %w", layout.ErrNameTooLong)}
	srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: cmd, Reader: &fakeReader{}})

	resp, err := http.Post(srv.URL+"/api/downloads/"+tid+"/cancel", "", nil)
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusConflict {
		t.Fatalf("status = %d, want 409", resp.StatusCode)
	}
	var got map[string]any
	_ = json.NewDecoder(resp.Body).Decode(&got)
	if msg, _ := got["error"].(string); !strings.Contains(msg, "слишком длинное") {
		t.Errorf("error = %q, want содержащее «слишком длинное»", msg)
	}
}
