package http

import (
	"io/fs"
	"log/slog"
	"net/http"
	"net/http/httptest"
	"testing"
	"testing/fstest"
	"time"

	"github.com/pocketbase/pocketbase/apis"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"

	"git.vakhrushev.me/av/transcriber/internal/adapter/recognizer"
	pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
	"git.vakhrushev.me/av/transcriber/internal/entity"
	"git.vakhrushev.me/av/transcriber/internal/service"
)

const testMarkup = `<!doctype html><html lang="ru"><body>приложение</body></html>`

// builtDist — собранное приложение, каким его видит раздача.
func builtDist() fs.FS {
	return fstest.MapFS{
		"index.html":             {Data: []byte(testMarkup)},
		"assets/index-abc123.js": {Data: []byte("console.log(1)")},
		"favicon.ico":            {Data: []byte("значок")},
	}
}

// webappEnv — окружение проверок раздачи. Роутер собирается тем же способом,
// что и боевой: маршруты порождает перечень, а не перечисление в проверке.
type webappEnv struct {
	mux     http.Handler
	journal *journalBuffer
	session string
}

func setupWebappEnv(t *testing.T, dist fs.FS, built bool) *webappEnv {
	t.Helper()

	app := newTestStorage(t)
	pbrepo.BindPanelRules(app)

	recordRepo := pbrepo.NewAudioRecordRepository(app)
	textRepo := pbrepo.NewTextRepository(app)
	structureRepo := pbrepo.NewStructureRepository(app)

	journal := &journalBuffer{}
	logger := slog.New(slog.NewTextHandler(journal, nil))

	trsService := service.NewTranscribeService(
		service.Repositories{
			Records:      recordRepo,
			Files:        pbrepo.NewFileRepository(app),
			Texts:        textRepo,
			Structures:   structureRepo,
			Recognitions: pbrepo.NewRecognitionRepository(app),
			Events:       pbrepo.NewRecordEventRepository(app),
		},
		readableMetaViewer(),
		&stubConverter{},
		&recognizer.MemoryAudioRecognizer{},
		entity.StuckLimits{Own: time.Hour, Foreign: 24 * time.Hour},
		logger,
	)

	appHandler := NewAppHandler(recordRepo, textRepo, structureRepo, trsService, logger)
	authHandler := NewAuthHandler(app, AuthHandlerConfig{
		AuthURL:     "https://provider.example/authorize",
		RedirectURL: "https://service.example/auth/callback",
		ClientID:    "client",
	}, logger)

	mounts := ServiceMounts(appHandler, authHandler, http.NotFoundHandler())

	r, err := apis.NewRouter(app)
	require.NoError(t, err)

	RegisterServiceRoutes(r, mounts)
	NewWebappHandler(dist, built, mounts, logger).Register(r)

	mux, err := r.BuildMux()
	require.NoError(t, err)

	_, session := newTestAccount(t, app)

	return &webappEnv{mux: mux, journal: journal, session: session}
}

func (e *webappEnv) get(path string) *httptest.ResponseRecorder {
	return e.do(http.MethodGet, path, false)
}

func (e *webappEnv) do(method, path string, withSession bool) *httptest.ResponseRecorder {
	req := httptest.NewRequest(method, path, nil)
	if withSession {
		req.AddCookie(&http.Cookie{Name: SessionCookieName, Value: e.session})
	}

	rec := httptest.NewRecorder()
	e.mux.ServeHTTP(rec, req)

	return rec
}

// Обновление страницы посреди приложения открывает тот же экран: адреса у
// приложения обычные, а не после решётки, и сервер обязан отдать разметку.
func TestWebappServesMarkupOutsideServiceRoots(t *testing.T) {
	env := setupWebappEnv(t, builtDist(), true)

	for _, path := range []string{"/", "/records", "/records/abc123def456ghi"} {
		res := env.get(path)

		assert.Equal(t, http.StatusOK, res.Code, path)
		assert.Contains(t, res.Body.String(), "приложение", path)
	}
}

// Путь внутри корня сервиса в приложение не проваливается никогда: иначе
// программа, ошибшаяся адресом, приняла бы разметку с кодом 200 за ответ.
func TestWebappNeverAnswersInsideServiceRoots(t *testing.T) {
	env := setupWebappEnv(t, builtDist(), true)

	for _, path := range []string{"/api/nope", "/_/nope", "/auth/nope"} {
		res := env.get(path)

		assert.NotContains(t, res.Body.String(), "приложение", path)
	}
}

// Под корнем приложения отказ уходит его собственной формой, и код отвечает
// причине: без сессии — «предъяви себя», с сессией — «такого адреса нет».
func TestWebappLeavesAppRootToItsOwnFailureForm(t *testing.T) {
	env := setupWebappEnv(t, builtDist(), true)

	anonymous := env.do(http.MethodGet, "/app/nope", false)
	assert.Equal(t, http.StatusUnauthorized, anonymous.Code)
	assert.NotContains(t, anonymous.Body.String(), "приложение")

	signedIn := env.do(http.MethodGet, "/app/nope", true)
	assert.Equal(t, http.StatusNotFound, signedIn.Code)
	assert.Contains(t, signedIn.Body.String(), CodeNotFound)
}

// Принадлежность корню — два условия разом. По одному префиксу корню досталось
// бы постороннее имя, по одному префиксу с косой чертой голый корень не достался
// бы никому и уехал бы разметкой.
func TestWebappRootMatchNeedsExactOrSlash(t *testing.T) {
	env := setupWebappEnv(t, builtDist(), true)

	bare := env.get(StorageRoot)
	assert.NotContains(t, bare.Body.String(), "приложение", "голый корень разметкой не подменяется")

	neighbour := env.get(AppRoot + "le")
	assert.Equal(t, http.StatusOK, neighbour.Code)
	assert.Contains(t, neighbour.Body.String(), "приложение", "посторонний путь остаётся приложению")
}

// Разметка прежней сборки называет ресурсы прежней сборки. Подменить их
// разметкой значит ответить 200 на то, чего нет.
func TestWebappMissingAssetAnswersNotFound(t *testing.T) {
	env := setupWebappEnv(t, builtDist(), true)

	res := env.get("/assets/index-obsolete.js")

	assert.Equal(t, http.StatusNotFound, res.Code)
	assert.NotContains(t, res.Body.String(), "приложение")
}

// Сам каталог сборщика — тот же случай, что и файл под ним: правило держится
// парой условий, и по одному префиксу с косой чертой голый `assets` уехал бы
// разметкой с кодом `200`.
func TestWebappAssetsDirItselfAnswersNotFound(t *testing.T) {
	env := setupWebappEnv(t, builtDist(), true)

	for _, path := range []string{"/assets", "/assets/"} {
		res := env.get(path)

		assert.Equal(t, http.StatusNotFound, res.Code, path)
		assert.NotContains(t, res.Body.String(), "приложение", path)
	}
}

// Метка ответа даёт `no-cache` его смысл: без неё вшитый файл не несёт времени
// правки, и браузер получает полное тело вместо подтверждения.
func TestWebappMarkupCarriesEntityTag(t *testing.T) {
	env := setupWebappEnv(t, builtDist(), true)

	tag := env.get("/").Result().Header.Get("ETag")

	assert.NotEmpty(t, tag)
}

// Адреса наблюдения приложением не подменяются.
func TestWebappLeavesObservationAddresses(t *testing.T) {
	env := setupWebappEnv(t, builtDist(), true)

	res := env.get(HealthPath)

	assert.Equal(t, http.StatusOK, res.Code)
	assert.Contains(t, res.Body.String(), "ok")
}

// Открывающими страницу считаются GET и HEAD, и только они.
func TestWebappMethods(t *testing.T) {
	env := setupWebappEnv(t, builtDist(), true)

	head := env.do(http.MethodHead, "/records", false)
	assert.Equal(t, http.StatusOK, head.Code)

	post := env.do(http.MethodPost, "/records", false)
	assert.Equal(t, http.StatusMethodNotAllowed, post.Code)
	assert.NotContains(t, post.Body.String(), "приложение")
}

// Долгий срок хранения ставится по каталогу, а не по виду файла: отозвать его у
// браузера сервису нечем.
func TestWebappCacheHeaders(t *testing.T) {
	env := setupWebappEnv(t, builtDist(), true)

	asset := env.get("/assets/index-abc123.js").Result().Header.Get("Cache-Control")
	assert.Contains(t, asset, "immutable")
	assert.Contains(t, asset, "max-age=31536000")

	markup := env.get("/").Result().Header.Get("Cache-Control")
	assert.Equal(t, "no-cache", markup)

	// Файл с постоянным именем лежит в сборке, но вне каталога сборщика:
	// «неизменяемо» ему не достаётся.
	permanent := env.get("/favicon.ico").Result().Header.Get("Cache-Control")
	assert.Equal(t, "no-cache", permanent)
}

// «Приложения нет» отличается от «адреса нет»: первое чинится сборкой, а не
// поиском опечатки в адресе. Проба здоровья при этом остаётся зелёной — сервис
// принимает записи и расшифровывает их, не работает только показ.
func TestWebappNotBuilt(t *testing.T) {
	env := setupWebappEnv(t, fstest.MapFS{}, false)

	res := env.get("/")
	assert.Equal(t, http.StatusServiceUnavailable, res.Code)
	assert.Contains(t, res.Body.String(), "не собрано")

	health := env.get(HealthPath)
	assert.Equal(t, http.StatusOK, health.Code)

	// Владелец сервиса узнаёт об этом журналом подъёма, а не от человека,
	// открывшего страницу.
	assert.Contains(t, env.journal.String(), "Webapp is not built")
}
