package httpapi

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

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

// stubReader — минимальный Reader для проверки рендера списка.
type stubReader struct {
	list     []store.Download
	one      *store.Download
	sizes    map[string]int64 // размеры разложенных файлов по download_id (фолбэк)
	getErr   error            // отказ чтения задачи (не ErrNotFound)
	sizesErr error            // отказ чтения размеров раскладки

	// Для страницы группового удаления: список разрешённых к удалению и
	// поштучное чтение по id (страница подтверждения называет каждую поимённо).
	deletable    []store.Download
	deletableErr error
	byID         map[string]store.Download
}

func (s stubReader) ListDownloads(context.Context) ([]store.Download, error) { return s.list, nil }
func (s stubReader) ListDownloadsPage(context.Context, store.ListFilter) ([]store.Download, int, error) {
	return s.list, len(s.list), nil
}
func (s stubReader) GetDownload(_ context.Context, id string) (*store.Download, error) {
	if s.getErr != nil {
		return nil, s.getErr
	}
	if s.byID != nil {
		d, ok := s.byID[id]
		if !ok {
			return nil, store.ErrNotFound
		}
		return &d, nil
	}
	if s.one == nil {
		return nil, store.ErrNotFound
	}
	return s.one, nil
}
func (s stubReader) ListDeletableDownloads(context.Context) ([]store.Download, error) {
	return s.deletable, s.deletableErr
}
func (s stubReader) LayoutSizeByDownload(context.Context, []string) (map[string]int64, error) {
	if s.sizesErr != nil {
		return nil, s.sizesErr
	}
	return s.sizes, nil
}

// stubReviewer — Reviewer-заглушка (нужна для /download/{id}).
type stubReviewer struct{ data *worker.ReviewData }

func (s stubReviewer) ReviewData(context.Context, string) (*worker.ReviewData, error) {
	if s.data == nil {
		return nil, store.ErrNotFound
	}
	return s.data, nil
}
func (stubReviewer) Apply(context.Context, string) error                         { return nil }
func (stubReviewer) Refine(context.Context, string, string) error                { return nil }
func (stubReviewer) IgnoreFile(context.Context, string, string) error            { return nil }
func (stubReviewer) Defer(context.Context, string) error                         { return nil }
func (stubReviewer) Undo(context.Context, string) error                          { return nil }
func (stubReviewer) Delete(context.Context, string) error                        { return nil }
func (stubReviewer) Dismiss(context.Context, string) error                       { return nil }
func (stubReviewer) Relink(context.Context, string) error                        { return nil }
func (stubReviewer) Rerecognize(context.Context, string) error                   { return nil }
func (stubReviewer) ChooseCandidate(context.Context, string, string) error       { return nil }
func (stubReviewer) SetProviderID(context.Context, string, string, string) error { return nil }
func (stubReviewer) AddManualSource(context.Context, string, string, string) error {
	return nil
}
func (stubReviewer) ClearProvider(context.Context, string) error      { return nil }
func (stubReviewer) RefreshDisplayName(context.Context, string) error { return nil }

// stubLive — заглушка источника живой телеметрии.
type stubLive struct{ m map[string]worker.Live }

func (s stubLive) Live(infohash string) (worker.Live, bool) {
	l, ok := s.m[infohash]
	return l, ok
}

func testRouter(t *testing.T, r stubReader, rv stubReviewer) http.Handler {
	t.Helper()
	return testRouterLive(t, r, rv, stubLive{})
}

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

func get(t *testing.T, h http.Handler, path string) *httptest.ResponseRecorder {
	t.Helper()
	rr := httptest.NewRecorder()
	h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, path, nil))
	return rr
}

// getHTMX — тот же GET, но помеченный как htmx-запрос (тик самообновления).
func getHTMX(t *testing.T, h http.Handler, path string) *httptest.ResponseRecorder {
	t.Helper()
	rr := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodGet, path, nil)
	req.Header.Set("HX-Request", "true")
	h.ServeHTTP(rr, req)
	return rr
}

// testULID — валидный lowercase-ULID для маршрутов (pathID валидирует формат).
const testULID = "01arz3ndektsv4rrffq69g5fav"

// TestRouterRendersPages проверяет, что шаблоны парсятся и страницы рендерятся.
func TestRouterRendersPages(t *testing.T) {
	dl := store.Download{ID: testULID, SourceRef: "Fargo.S02",
		Infohashes: []store.Infohash{{DownloadID: testULID, Infohash: "a1b2c3d4e5f6a7b8", Kind: store.HashV1}},
		State:      store.StateReview}
	h := testRouter(t,
		stubReader{list: []store.Download{dl}, one: &dl},
		stubReviewer{data: &worker.ReviewData{Download: dl}},
	)

	if rr := get(t, h, "/"); rr.Code != http.StatusOK {
		t.Fatalf("GET / = %d, want 200", rr.Code)
	} else if !strings.Contains(rr.Body.String(), "st-review") {
		t.Errorf("index не содержит бейдж статуса")
	}

	if rr := get(t, h, "/download/"+testULID); rr.Code != http.StatusOK {
		t.Fatalf("GET /download/{id} = %d, want 200", rr.Code)
	}
}

// TestDownloadPageShowsDirector — блок «Распознано как» на /download/{id}
// выводит режиссёра слоистым разрешением: из плана (матч) и из сохранённого
// контекста при распознавании без матча (ранее поле было захардкожено прочерком).
func TestDownloadPageShowsDirector(t *testing.T) {
	base := func(plan recognize.Plan, parsedContext string) *worker.ReviewData {
		return &worker.ReviewData{
			Download: store.Download{
				ID: testULID, SourceRef: "Dune", State: store.StateReview,
				ParsedContext: parsedContext,
			},
			Recognition: &store.Recognition{ID: "1", DownloadID: testULID, IsCurrent: true},
			Plan:        plan,
		}
	}
	moviePlan := func(director string) recognize.Plan {
		return recognize.Plan{Type: recognize.MediaMovie, Title: "Дюна", Year: 2024, Director: director,
			Files: []recognize.PlanFile{{Src: "dune.mkv", Role: recognize.RoleMain}}}
	}

	cases := []struct {
		name       string
		rd         *worker.ReviewData
		wantInBody string
	}{
		{
			name:       "режиссёр из плана (матч)",
			rd:         base(moviePlan("Дени Вильнёв"), ""),
			wantInBody: "Дени Вильнёв",
		},
		{
			name:       "режиссёр из контекста при распознавании без матча",
			rd:         base(moviePlan(""), `{"type":"movie","title":"Дюна","director":"Дени Вильнёв"}`),
			wantInBody: "Дени Вильнёв",
		},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			h := testRouter(t, stubReader{one: &c.rd.Download}, stubReviewer{data: c.rd})
			rr := get(t, h, "/download/"+testULID)
			if rr.Code != http.StatusOK {
				t.Fatalf("GET /download/{id} = %d, want 200", rr.Code)
			}
			if !strings.Contains(rr.Body.String(), c.wantInBody) {
				t.Errorf("страница загрузки не содержит режиссёра %q", c.wantInBody)
			}
		})
	}
}

// TestStaticServed проверяет отдачу встроенной статики с кэш-заголовком.
func TestStaticServed(t *testing.T) {
	h := testRouter(t, stubReader{}, stubReviewer{})
	rr := get(t, h, "/static/css/jellybit.css")
	if rr.Code != http.StatusOK {
		t.Fatalf("GET css = %d, want 200", rr.Code)
	}
	if cc := rr.Header().Get("Cache-Control"); cc == "" {
		t.Errorf("нет Cache-Control на статике")
	}
	if !strings.Contains(rr.Body.String(), "@font-face") {
		t.Errorf("css без @font-face (шрифты не self-hosted?)")
	}
}

// TestDownloadNotFound — несуществующая загрузка → 404.
func TestDownloadNotFound(t *testing.T) {
	h := testRouter(t, stubReader{}, stubReviewer{})
	if rr := get(t, h, "/download/01arz3ndektsv4rrffq69g5fff"); rr.Code != http.StatusNotFound {
		t.Fatalf("GET /download/{missing} = %d, want 404", rr.Code)
	}
}

// TestDownloadInvalidID — синтаксически невалидный id → 404 без похода в БД.
func TestDownloadInvalidID(t *testing.T) {
	h := testRouter(t, stubReader{}, stubReviewer{})
	for _, path := range []string{"/download/999", "/download/abc!!!", "/review/12"} {
		if rr := get(t, h, path); rr.Code != http.StatusNotFound {
			t.Fatalf("GET %s = %d, want 404 (невалидный id = несуществующая сущность)", path, rr.Code)
		}
	}
}

// TestDownloadUppercaseIDNormalized — uppercase-вариант id ведёт на ту же
// страницу (нормализация на входной границе).
func TestDownloadUppercaseIDNormalized(t *testing.T) {
	dl := store.Download{ID: testULID, SourceRef: "X", State: store.StateReview}
	h := testRouter(t, stubReader{one: &dl}, stubReviewer{data: &worker.ReviewData{Download: dl}})
	if rr := get(t, h, "/download/"+strings.ToUpper(testULID)); rr.Code != http.StatusOK {
		t.Fatalf("GET /download/{UPPERCASE} = %d, want 200", rr.Code)
	}
}
