package httpapi

import (
	"context"
	"errors"
	"log/slog"
	"net/http"
	"net/http/httptest"
	"net/url"
	"strings"
	"testing"

	"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"
)

// stubCommander — Commander-заглушка с инъекцией ошибок cancel/retry.
type stubCommander struct{ cancelErr, retryErr error }

func (s stubCommander) Cancel(context.Context, string) error { return s.cancelErr }
func (s stubCommander) Retry(context.Context, string) error  { return s.retryErr }

// actionReviewer — Reviewer-заглушка с инъекцией ошибок петлевых/своп-действий
// и захватом подсказки refine.
type actionReviewer struct {
	stubReviewer
	undoErr        error
	deleteErr      error
	dismissErr     error
	relinkErr      error
	rerecognizeErr error
	refineErr      error
	gotHint        *string // если не nil — сюда пишется hint из Refine
}

func (a actionReviewer) Undo(context.Context, string) error        { return a.undoErr }
func (a actionReviewer) Delete(context.Context, string) error      { return a.deleteErr }
func (a actionReviewer) Dismiss(context.Context, string) error     { return a.dismissErr }
func (a actionReviewer) Relink(context.Context, string) error      { return a.relinkErr }
func (a actionReviewer) Rerecognize(context.Context, string) error { return a.rerecognizeErr }
func (a actionReviewer) Refine(_ context.Context, _ string, hint string) error {
	if a.gotHint != nil {
		*a.gotHint = hint
	}
	return a.refineErr
}

func testRouterAction(t *testing.T, r stubReader, rv Reviewer, cmd Commander, lv stubLive) http.Handler {
	t.Helper()
	h, err := NewRouter(Deps{
		Logger:    slog.New(slog.DiscardHandler),
		Reader:    r,
		Reviewer:  rv,
		Commander: cmd,
		Live:      lv,
	})
	if err != nil {
		t.Fatalf("NewRouter: %v", err)
	}
	return h
}

// post отправляет POST-форму; htmx=true добавляет заголовок HX-Request.
func post(t *testing.T, h http.Handler, path string, form url.Values, htmx bool) *httptest.ResponseRecorder {
	t.Helper()
	req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	if htmx {
		req.Header.Set("HX-Request", "true")
	}
	rr := httptest.NewRecorder()
	h.ServeHTTP(rr, req)
	return rr
}

func dlState(s store.State) store.Download {
	return store.Download{
		ID:         testULID,
		SourceRef:  "Fargo.S02",
		Infohashes: []store.Infohash{{DownloadID: testULID, Infohash: "ihswap", Kind: store.HashV1}},
		State:      s,
	}
}

// TestUndoListHTMXSwapsCard: откат из списка (surface=list) при htmx → 200 и
// фрагмент карточки с новым состоянием, не редирект.
func TestUndoListHTMXSwapsCard(t *testing.T) {
	dl := dlState(store.StateReverted) // GetDownload после действия отдаёт новое состояние
	h := testRouterAction(t, stubReader{one: &dl}, actionReviewer{}, stubCommander{}, stubLive{})

	rr := post(t, h, "/ui/downloads/"+testULID+"/undo", url.Values{"surface": {"list"}}, true)
	if rr.Code != http.StatusOK {
		t.Fatalf("undo (htmx) = %d, want 200", rr.Code)
	}
	body := rr.Body.String()
	if !strings.Contains(body, `id="card-`+testULID+`"`) {
		t.Errorf("ответ не содержит фрагмент карточки: %s", body)
	}
	if !strings.Contains(body, "st-reverted") {
		t.Errorf("карточка без нового состояния (st-reverted): %s", body)
	}
}

// TestUndoNoHTMXRedirects: без htmx откат деградирует до PRG-редиректа на список.
func TestUndoNoHTMXRedirects(t *testing.T) {
	dl := dlState(store.StateReverted)
	h := testRouterAction(t, stubReader{one: &dl}, actionReviewer{}, stubCommander{}, stubLive{})

	rr := post(t, h, "/ui/downloads/"+testULID+"/undo", nil, false)
	if rr.Code != http.StatusSeeOther {
		t.Fatalf("undo (no htmx) = %d, want 303", rr.Code)
	}
	if loc := rr.Header().Get("Location"); loc != "/" {
		t.Errorf("redirect на %q, want /", loc)
	}
}

// TestDownloadSurfaceHTMXSwapsMain: действие со страницы загрузки (surface=download)
// при htmx → 200 и фрагмент download_main.
func TestDownloadSurfaceHTMXSwapsMain(t *testing.T) {
	dl := dlState(store.StateReverted)
	rv := actionReviewer{stubReviewer: stubReviewer{data: &worker.ReviewData{Download: dl}}}
	h := testRouterAction(t, stubReader{one: &dl}, rv, stubCommander{}, stubLive{})

	rr := post(t, h, "/ui/downloads/"+testULID+"/undo", url.Values{"surface": {"download"}}, true)
	if rr.Code != http.StatusOK {
		t.Fatalf("undo download (htmx) = %d, want 200", rr.Code)
	}
	if !strings.Contains(rr.Body.String(), `id="download-main"`) {
		t.Errorf("ответ не содержит фрагмент download_main: %s", rr.Body.String())
	}
}

// TestRerecognizeHTMXSwapsReviewMain: перераспознавание при htmx → 200 и тело
// ревью review_main в состоянии recognizing с поллером.
func TestRerecognizeHTMXSwapsReviewMain(t *testing.T) {
	dl := dlState(store.StateRecognizing)
	rv := actionReviewer{stubReviewer: stubReviewer{data: &worker.ReviewData{Download: dl}}}
	h := testRouterAction(t, stubReader{one: &dl}, rv, stubCommander{}, stubLive{})

	rr := post(t, h, "/ui/downloads/"+testULID+"/rerecognize", nil, true)
	if rr.Code != http.StatusOK {
		t.Fatalf("rerecognize (htmx) = %d, want 200", rr.Code)
	}
	body := rr.Body.String()
	if !strings.Contains(body, `id="review-main"`) {
		t.Errorf("ответ не содержит фрагмент review_main: %s", body)
	}
	if !strings.Contains(body, `hx-trigger="every 2s"`) {
		t.Errorf("recognizing без поллера (hx-trigger every 2s): %s", body)
	}
}

// TestRerecognizeNoHTMXRedirects: без htmx перераспознавание → редирект на ревью.
func TestRerecognizeNoHTMXRedirects(t *testing.T) {
	dl := dlState(store.StateRecognizing)
	rv := actionReviewer{stubReviewer: stubReviewer{data: &worker.ReviewData{Download: dl}}}
	h := testRouterAction(t, stubReader{one: &dl}, rv, stubCommander{}, stubLive{})

	rr := post(t, h, "/ui/downloads/"+testULID+"/rerecognize", nil, false)
	if rr.Code != http.StatusSeeOther {
		t.Fatalf("rerecognize (no htmx) = %d, want 303", rr.Code)
	}
	if loc := rr.Header().Get("Location"); loc != "/review/"+testULID {
		t.Errorf("redirect на %q, want /review/{id}", loc)
	}
}

// TestFragReviewPollerStops: фрагмент тела ревью несёт поллер в recognizing и не
// несёт его в review (опрос сам прекращается).
func TestFragReviewPollerStops(t *testing.T) {
	recDL := dlState(store.StateRecognizing)
	hRec := testRouterAction(t, stubReader{one: &recDL},
		actionReviewer{stubReviewer: stubReviewer{data: &worker.ReviewData{Download: recDL}}}, stubCommander{}, stubLive{})
	if rr := get(t, hRec, "/fragments/downloads/"+testULID+"/review"); !strings.Contains(rr.Body.String(), `hx-trigger="every 2s"`) {
		t.Errorf("recognizing: фрагмент без поллера")
	}

	revDL := dlState(store.StateReview)
	hRev := testRouterAction(t, stubReader{one: &revDL},
		actionReviewer{stubReviewer: stubReviewer{data: &worker.ReviewData{Download: revDL}}}, stubCommander{}, stubLive{})
	if rr := get(t, hRev, "/fragments/downloads/"+testULID+"/review"); strings.Contains(rr.Body.String(), `hx-trigger="every 2s"`) {
		t.Errorf("review: поллер не прекратился")
	}
}

// TestActionErrorHTMX200: ошибка действия на htmx-пути → 200 с сообщением в
// фрагменте (иначе htmx не свопит DOM).
func TestActionErrorHTMX200(t *testing.T) {
	dl := dlState(store.StateReview)
	cmd := stubCommander{cancelErr: worker.ErrConflict}
	h := testRouterAction(t, stubReader{one: &dl}, actionReviewer{}, cmd, stubLive{})

	rr := post(t, h, "/ui/downloads/"+testULID+"/cancel", url.Values{"surface": {"list"}}, true)
	if rr.Code != http.StatusOK {
		t.Fatalf("cancel с ошибкой (htmx) = %d, want 200", rr.Code)
	}
	body := rr.Body.String()
	if !strings.Contains(body, `id="card-`+testULID+`"`) {
		t.Errorf("ответ не содержит фрагмент карточки")
	}
	if !strings.Contains(body, "действие недоступно") {
		t.Errorf("нет сообщения об ошибке в фрагменте: %s", body)
	}
}

// TestRefineHTMXSwapsReviewMain: уточнить (refine) при htmx → 200 + review_main,
// подсказка из формы доходит до доменного вызова.
func TestRefineHTMXSwapsReviewMain(t *testing.T) {
	dl := dlState(store.StateRecognizing)
	var hint string
	rv := actionReviewer{stubReviewer: stubReviewer{data: &worker.ReviewData{Download: dl}}, gotHint: &hint}
	h := testRouterAction(t, stubReader{one: &dl}, rv, stubCommander{}, stubLive{})

	rr := post(t, h, "/ui/downloads/"+testULID+"/refine", url.Values{"hint": {"это Fargo 2014"}}, true)
	if rr.Code != http.StatusOK {
		t.Fatalf("refine (htmx) = %d, want 200", rr.Code)
	}
	if !strings.Contains(rr.Body.String(), `id="review-main"`) {
		t.Errorf("refine не отдал review_main")
	}
	if hint != "это Fargo 2014" {
		t.Errorf("hint = %q, want «это Fargo 2014» (форма не разобрана)", hint)
	}
}

// TestDownloadSurfaceNoHTMXRedirects: действие со страницы загрузки без htmx →
// 303 (деградация на редирект, как и для списка).
func TestDownloadSurfaceNoHTMXRedirects(t *testing.T) {
	dl := dlState(store.StateReverted)
	rv := actionReviewer{stubReviewer: stubReviewer{data: &worker.ReviewData{Download: dl}}}
	h := testRouterAction(t, stubReader{one: &dl}, rv, stubCommander{}, stubLive{})

	rr := post(t, h, "/ui/downloads/"+testULID+"/undo", url.Values{"surface": {"download"}}, false)
	if rr.Code != http.StatusSeeOther {
		t.Fatalf("undo download (no htmx) = %d, want 303", rr.Code)
	}
}

// TestReviewCancelNoHTMXNavigates: отклонение из ревью (форма без hx-*/surface) —
// навигация на список (выход из ревью), а не своп.
func TestReviewCancelNoHTMXNavigates(t *testing.T) {
	dl := dlState(store.StateReview)
	h := testRouterAction(t, stubReader{one: &dl}, actionReviewer{}, stubCommander{}, stubLive{})

	rr := post(t, h, "/ui/downloads/"+testULID+"/cancel", nil, false)
	if rr.Code != http.StatusSeeOther {
		t.Fatalf("cancel из ревью (no htmx) = %d, want 303", rr.Code)
	}
	if loc := rr.Header().Get("Location"); loc != "/" {
		t.Errorf("redirect на %q, want / (выход из ревью)", loc)
	}
}

// TestActionErrorDownloadSurface: ошибка действия на странице загрузки (htmx) →
// 200 + фрагмент download_main с сообщением.
func TestActionErrorDownloadSurface(t *testing.T) {
	dl := dlState(store.StateDone)
	rv := actionReviewer{stubReviewer: stubReviewer{data: &worker.ReviewData{Download: dl}}, undoErr: worker.ErrConflict}
	h := testRouterAction(t, stubReader{one: &dl}, rv, stubCommander{}, stubLive{})

	rr := post(t, h, "/ui/downloads/"+testULID+"/undo", url.Values{"surface": {"download"}}, true)
	if rr.Code != http.StatusOK {
		t.Fatalf("undo download с ошибкой (htmx) = %d, want 200", rr.Code)
	}
	body := rr.Body.String()
	if !strings.Contains(body, `id="download-main"`) {
		t.Errorf("ответ не содержит download_main")
	}
	if !strings.Contains(body, "действие недоступно") {
		t.Errorf("нет сообщения об ошибке в download_main: %s", body)
	}
}

// reviewDataWithSource собирает ReviewData ревью с одним нейронка-источником;
// withLinks управляет наличием превью раскладки (HasLinks).
func reviewDataWithSource(withLinks bool) *worker.ReviewData {
	dl := dlState(store.StateReview)
	plan := recognize.Plan{Type: "movie", Title: "Fargo", Year: 1996,
		Files: []recognize.PlanFile{{Src: "fargo.mkv"}}}
	rd := &worker.ReviewData{
		Download:    dl,
		Recognition: &store.Recognition{},
		Plan:        plan,
		Sources: []worker.SourceOption{
			{Kind: worker.SourceNeural, Provider: "none", Title: "Fargo", Year: 1996, Active: true},
		},
	}
	if withLinks {
		rd.Preview = []layout.Link{{Src: "fargo.mkv", Dst: "/movies/Fargo (1996)/fargo.mkv"}}
	}
	return rd
}

// TestSourceSwapUpdatesActionBarOOB: своп выбора источника отдаёт свежий
// #source-block и oob-обновление панели действий (#action-bar с hx-swap-oob),
// чтобы кнопка «Применить» синхронно отражала актуальный HasLinks.
func TestSourceSwapUpdatesActionBarOOB(t *testing.T) {
	t.Run("с превью → есть кнопка Применить", func(t *testing.T) {
		rd := reviewDataWithSource(true)
		rv := actionReviewer{stubReviewer: stubReviewer{data: rd}}
		h := testRouterAction(t, stubReader{one: &rd.Download}, rv, stubCommander{}, stubLive{})

		rr := post(t, h, "/ui/downloads/"+testULID+"/nobase", nil, true)
		if rr.Code != http.StatusOK {
			t.Fatalf("nobase (htmx) = %d, want 200", rr.Code)
		}
		body := rr.Body.String()
		if !strings.Contains(body, `id="source-block"`) {
			t.Errorf("ответ без свежего #source-block: %s", body)
		}
		if !strings.Contains(body, `id="action-bar"`) || !strings.Contains(body, `hx-swap-oob="true"`) {
			t.Errorf("ответ без oob-панели действий: %s", body)
		}
		if !strings.Contains(body, "/apply") {
			t.Errorf("HasLinks=true, но в панели нет кнопки «Применить»: %s", body)
		}
	})

	t.Run("без превью → кнопки Применить нет", func(t *testing.T) {
		rd := reviewDataWithSource(false)
		rv := actionReviewer{stubReviewer: stubReviewer{data: rd}}
		h := testRouterAction(t, stubReader{one: &rd.Download}, rv, stubCommander{}, stubLive{})

		rr := post(t, h, "/ui/downloads/"+testULID+"/nobase", nil, true)
		if rr.Code != http.StatusOK {
			t.Fatalf("nobase (htmx) = %d, want 200", rr.Code)
		}
		body := rr.Body.String()
		if !strings.Contains(body, `id="action-bar"`) || !strings.Contains(body, `hx-swap-oob="true"`) {
			t.Errorf("ответ без oob-панели действий: %s", body)
		}
		if strings.Contains(body, "/apply") {
			t.Errorf("HasLinks=false, но в панели осталась кнопка «Применить»: %s", body)
		}
	})
}

// TestRetryListShowsProgress: retry из списка → карточка downloading с живым
// прогрессом и самообновлением карточки (опрашивает себя карточка, а не
// вложенный блок прогресса).
func TestRetryListShowsProgress(t *testing.T) {
	dl := dlState(store.StateDownloading)
	lv := stubLive{m: map[string]worker.Live{"ihswap": {Progress: 0.42, DlSpeed: 6400000, ETA: 720}}}
	h := testRouterAction(t, stubReader{one: &dl}, actionReviewer{}, stubCommander{}, lv)

	rr := post(t, h, "/ui/downloads/"+testULID+"/retry", url.Values{"surface": {"list"}}, true)
	if rr.Code != http.StatusOK {
		t.Fatalf("retry (htmx) = %d, want 200", rr.Code)
	}
	body := rr.Body.String()
	for _, want := range []string{"width:42%", "/fragments/downloads/" + testULID + "/card"} {
		if !strings.Contains(body, want) {
			t.Errorf("карточка downloading без %q: %s", want, body)
		}
	}
}

// TestActionBarNamesReasonWithoutPreview: при пустом предпросмотре панель
// действий печатает записанную причину, а не общее «Подтверди источник» —
// иначе экран советует подтвердить уже подтверждённое и молчит о настоящей
// причине (см. spec review, «Панель действий при пустом предпросмотре»).
func TestActionBarNamesReasonWithoutPreview(t *testing.T) {
	t.Run("причина показа старше записанной", func(t *testing.T) {
		rd := reviewDataWithSource(false)
		rd.PreviewError = `layout: имя не помещается: "Очень длинное" — 400 байт при пределе 255`
		rd.Download.ErrorMsg = store.NullString("устаревшая причина прошлого перехода")
		rv := actionReviewer{stubReviewer: stubReviewer{data: rd}}
		h := testRouterAction(t, stubReader{one: &rd.Download}, rv, stubCommander{}, stubLive{})

		rr := post(t, h, "/ui/downloads/"+testULID+"/nobase", nil, true)
		if rr.Code != http.StatusOK {
			t.Fatalf("nobase (htmx) = %d, want 200", rr.Code)
		}
		body := rr.Body.String()
		if !strings.Contains(body, "не помещается") {
			t.Errorf("панель не назвала причину: %s", body)
		}
		if strings.Contains(body, "Подтверди источник") {
			t.Errorf("панель печатает общий текст вместо причины: %s", body)
		}
		if strings.Contains(body, "устаревшая") {
			t.Errorf("записанная причина перебила посчитанную на показе: %s", body)
		}
	})

	t.Run("записанная причина, когда посчитанной нет", func(t *testing.T) {
		rd := reviewDataWithSource(false)
		rd.Download.ErrorMsg = store.NullString("целевой файл уже существует")
		rv := actionReviewer{stubReviewer: stubReviewer{data: rd}}
		h := testRouterAction(t, stubReader{one: &rd.Download}, rv, stubCommander{}, stubLive{})

		rr := post(t, h, "/ui/downloads/"+testULID+"/nobase", nil, true)
		if !strings.Contains(rr.Body.String(), "уже существует") {
			t.Errorf("панель не назвала записанную причину: %s", rr.Body.String())
		}
	})

	t.Run("причины нет → прежний общий текст", func(t *testing.T) {
		rd := reviewDataWithSource(false)
		rv := actionReviewer{stubReviewer: stubReviewer{data: rd}}
		h := testRouterAction(t, stubReader{one: &rd.Download}, rv, stubCommander{}, stubLive{})

		rr := post(t, h, "/ui/downloads/"+testULID+"/nobase", nil, true)
		if rr.Code != http.StatusOK {
			t.Fatalf("nobase (htmx) = %d, want 200", rr.Code)
		}
		if !strings.Contains(rr.Body.String(), "Подтверди источник") {
			t.Errorf("без причины ожидался общий текст: %s", rr.Body.String())
		}
	})
}

// TestActionSwapErrorKeepsSwapRoot: действие человека, упавшее на чтении задачи,
// отвечает 200 и фрагментом с корнем своей поверхности — иначе своп унёс бы
// якорь (#card-{id} у списка, #download-main у страницы) и следующие действия
// целились бы в несуществующий узел. Ретрая у действия нет, поэтому уровень лога
// здесь ERROR, а не WARN, как у повторяющегося тика.
func TestActionSwapErrorKeepsSwapRoot(t *testing.T) {
	cases := []struct{ surface, root string }{
		{"list", `id="card-` + testULID + `"`},
		{"download", `id="download-main"`},
	}
	for _, c := range cases {
		rd := stubReader{getErr: errors.New("db is gone")}
		h := testRouterAction(t, rd, actionReviewer{}, stubCommander{}, stubLive{})

		rr := post(t, h, "/ui/downloads/"+testULID+"/cancel", url.Values{"surface": {c.surface}}, true)
		if rr.Code != http.StatusOK {
			t.Errorf("surface=%s: status = %d, want 200", c.surface, rr.Code)
			continue
		}
		body := rr.Body.String()
		if !strings.Contains(body, c.root) {
			t.Errorf("surface=%s: фрагмент отказа без корня %s:\n%s", c.surface, c.root, body)
		}
		if strings.Contains(body, "hx-trigger") {
			t.Errorf("surface=%s: фрагмент отказа не самозавершается:\n%s", c.surface, body)
		}
	}
}
