package http

import (
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"

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

	pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
	"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
)

// Проверки этого файла судят допуск: кого пускают к приёму и опросу, чем
// предъявляется сессия, что её прекращает и какие адреса остаются открытыми.

// TestApiRequiresSession — первый критерий приёмки. Запрос без сессии получает
// отказ и ничего не заводит, а проба здоровья и метрики остаются открытыми.
func TestApiRequiresSession(t *testing.T) {
	env := setupTestEnv(t, readableMetaViewer())

	t.Run("приём записи без сессии", func(t *testing.T) {
		req := createMultipartRequest(t, "test.mp3", []byte("audio"))
		w := httptest.NewRecorder()

		env.mux.ServeHTTP(w, req)

		assert.Equal(t, http.StatusUnauthorized, w.Code)
		assert.NotContains(t, w.Body.String(), "job_id")

		// Ни файла, ни задачи: отказ наступает раньше, чем запись попадает в
		// хранилище.
		files, err := env.app.FindAllRecords(migrations.FilesCollection)
		require.NoError(t, err)
		assert.Empty(t, files)

		jobs, err := env.app.FindAllRecords(migrations.RecordsCollection)
		require.NoError(t, err)
		assert.Empty(t, jobs)
	})

	t.Run("опрос готовности без сессии", func(t *testing.T) {
		req := httptest.NewRequest(http.MethodGet, "/app/audiorecords/anything", nil)
		w := httptest.NewRecorder()

		env.mux.ServeHTTP(w, req)

		assert.Equal(t, http.StatusUnauthorized, w.Code)
		assert.NotContains(t, w.Body.String(), "transcription_text")
		assert.NotContains(t, w.Body.String(), "created_at")
	})
}

// TestUnknownJobIsIndistinguishableWithoutSession: по кодам ответа без сессии не
// перебирается список заведённых задач.
func TestUnknownJobIsIndistinguishableWithoutSession(t *testing.T) {
	env := setupTestEnv(t, readableMetaViewer())

	created := httptest.NewRecorder()
	env.serve(created, createMultipartRequest(t, "test.mp3", []byte("audio")))
	require.Equal(t, http.StatusCreated, created.Code)

	jobs, err := env.app.FindAllRecords(migrations.RecordsCollection)
	require.NoError(t, err)
	require.Len(t, jobs, 1)

	existing := httptest.NewRecorder()
	env.mux.ServeHTTP(existing, httptest.NewRequest(http.MethodGet, "/app/audiorecords/"+jobs[0].Id, nil))

	missing := httptest.NewRecorder()
	env.mux.ServeHTTP(missing, httptest.NewRequest(http.MethodGet, "/app/audiorecords/nosuchjobid", nil))

	assert.Equal(t, http.StatusUnauthorized, existing.Code)
	assert.Equal(t, missing.Code, existing.Code)
}

// TestOpenEndpointsStayOpen — вторая сторона границы: проба здоровья и метрики
// сессии не требуют. Маршруты вешает `main`, поэтому здесь собирается такой же
// роутер с теми же двумя адресами.
func TestOpenEndpointsStayOpen(t *testing.T) {
	app := newTestStorage(t)

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

	r.GET("/health", func(e *core.RequestEvent) error {
		return e.JSON(http.StatusOK, map[string]string{"status": "ok"})
	})
	r.GET("/metrics", func(e *core.RequestEvent) error {
		return e.String(http.StatusOK, "# metrics")
	})

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

	for _, path := range []string{"/health", "/metrics"} {
		w := httptest.NewRecorder()
		mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
		assert.Equal(t, http.StatusOK, w.Code, "адрес %s обязан отвечать без сессии", path)
	}
}

// TestSessionSurvivesRestart — второй критерий приёмки. Подпись сессии считается
// от секрета коллекции и ключа записи, оба лежат в базе, поэтому выкладка
// вошедших не выкидывает.
func TestSessionSurvivesRestart(t *testing.T) {
	env := setupTestEnv(t, readableMetaViewer())

	before := httptest.NewRecorder()
	env.serve(before, createMultipartRequest(t, "test.mp3", []byte("audio")))
	require.Equal(t, http.StatusCreated, before.Code)

	// Сервер пересоздаётся на том же хранилище — то же, что перезапуск процесса
	// поверх прежнего каталога данных.
	r, err := apis.NewRouter(env.app)
	require.NoError(t, err)
	env.handler.Register(r)

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

	req := httptest.NewRequest(http.MethodGet, "/app/audiorecords/nosuchjobid", nil)
	req.AddCookie(&http.Cookie{Name: SessionCookieName, Value: env.session})
	w := httptest.NewRecorder()
	mux.ServeHTTP(w, req)

	// Прежняя кука прошла проверку: до обработчика дошло, и он ответил про
	// ненайденную задачу, а не про отсутствующую сессию.
	assert.Equal(t, http.StatusNotFound, w.Code)
}

// TestLogoutClosesAccess — третий критерий приёмки. Выход обесценивает выданные
// сессии, а не только убирает куку.
func TestLogoutClosesAccess(t *testing.T) {
	env := setupTestEnv(t, readableMetaViewer())

	authHandler := NewAuthHandler(env.app, AuthHandlerConfig{
		AuthURL:      "https://auth.example.com/api/oidc/authorization",
		RedirectURL:  "https://transcriber.example.com/auth/callback",
		ClientID:     "transcriber",
		SecureCookie: true,
	}, nil)

	r, err := apis.NewRouter(env.app)
	require.NoError(t, err)
	authHandler.Register(r)
	env.handler.Register(r)

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

	logout := httptest.NewRequest(http.MethodPost, "/auth/logout", nil)
	logout.AddCookie(&http.Cookie{Name: SessionCookieName, Value: env.session})
	logoutResponse := httptest.NewRecorder()
	mux.ServeHTTP(logoutResponse, logout)
	require.Equal(t, http.StatusOK, logoutResponse.Code)

	// Куку выход убирает.
	assert.Contains(t, logoutResponse.Result().Header.Get("Set-Cookie"), SessionCookieName+"=;")

	// И прежнее значение больше не открывает доступ — этого уборка куки сама по
	// себе не даёт: унесённое значение работало бы до истечения срока.
	after := httptest.NewRequest(http.MethodGet, "/app/audiorecords/nosuchjobid", nil)
	after.AddCookie(&http.Cookie{Name: SessionCookieName, Value: env.session})
	afterResponse := httptest.NewRecorder()
	mux.ServeHTTP(afterResponse, after)

	assert.Equal(t, http.StatusUnauthorized, afterResponse.Code)
}

// TestLogoutWhenAccountIsGone: учётной записи, которой предъявлена сессия, уже
// нет — выход отвечает отказом и не делает вид, что закрыл доступ.
func TestLogoutWhenAccountIsGone(t *testing.T) {
	env := setupTestEnv(t, readableMetaViewer())

	authHandler := NewAuthHandler(env.app, AuthHandlerConfig{
		AuthURL:      "https://auth.example.com/api/oidc/authorization",
		RedirectURL:  "https://transcriber.example.com/auth/callback",
		ClientID:     "transcriber",
		SecureCookie: true,
	}, nil)

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

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

	// Сессия выдана, а запись удалена — так выглядит гонка выхода с удалением
	// учётной записи в панели.
	require.NoError(t, env.app.Delete(env.account))

	logout := httptest.NewRequest(http.MethodPost, "/auth/logout", nil)
	logout.AddCookie(&http.Cookie{Name: SessionCookieName, Value: env.session})
	w := httptest.NewRecorder()
	mux.ServeHTTP(w, logout)

	// Записи нет — проверка сессии её не находит, и до обесценивания дело не
	// доходит: выход отвечает успехом, убрав куку. Доступа при этом всё равно
	// не осталось, потому что не осталось учётной записи.
	assert.Equal(t, http.StatusOK, w.Code)
	assert.Contains(t, w.Result().Header.Get("Set-Cookie"), SessionCookieName+"=;")

	after := httptest.NewRequest(http.MethodGet, "/app/audiorecords/nosuchjobid", nil)
	after.AddCookie(&http.Cookie{Name: SessionCookieName, Value: env.session})
	afterResponse := httptest.NewRecorder()
	env.mux.ServeHTTP(afterResponse, after)
	assert.Equal(t, http.StatusUnauthorized, afterResponse.Code)
}

// TestLogoutWithoutSession: выход без сессии убирает куку и молчит.
func TestLogoutWithoutSession(t *testing.T) {
	env := setupTestEnv(t, readableMetaViewer())

	authHandler := NewAuthHandler(env.app, AuthHandlerConfig{
		AuthURL:      "https://auth.example.com/api/oidc/authorization",
		RedirectURL:  "https://transcriber.example.com/auth/callback",
		ClientID:     "transcriber",
		SecureCookie: true,
	}, nil)

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

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

	w := httptest.NewRecorder()
	mux.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/auth/logout", nil))

	assert.Equal(t, http.StatusOK, w.Code)
	assert.Contains(t, w.Result().Header.Get("Set-Cookie"), SessionCookieName+"=;")
}

// TestLoginRedirectsToProvider: вход уводит к провайдеру и запоминает состояние
// у браузера.
func TestLoginRedirectsToProvider(t *testing.T) {
	env := setupTestEnv(t, readableMetaViewer())

	authHandler := NewAuthHandler(env.app, AuthHandlerConfig{
		AuthURL:      "https://auth.example.com/api/oidc/authorization",
		RedirectURL:  "https://transcriber.example.com/auth/callback",
		ClientID:     "transcriber",
		SecureCookie: true,
	}, nil)

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

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

	w := httptest.NewRecorder()
	mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/auth/login", nil))

	require.Equal(t, http.StatusFound, w.Code)

	location := w.Result().Header.Get("Location")
	assert.Contains(t, location, "https://auth.example.com/api/oidc/authorization")
	assert.Contains(t, location, "code_challenge_method=S256")
	assert.Contains(t, location, "client_id=transcriber")

	// Носитель состояния несёт те же признаки защиты, что и кука сессии.
	stateCookie := w.Result().Header.Get("Set-Cookie")
	assert.Contains(t, stateCookie, stateCookieName)
	assert.Contains(t, stateCookie, "HttpOnly")
	assert.Contains(t, stateCookie, "Secure")
	assert.Contains(t, stateCookie, "SameSite=Lax")
}

// TestCallbackRejectsForeignState — возврат с невыданным состоянием сессии не
// открывает и учётной записи не заводит.
func TestCallbackRejectsForeignState(t *testing.T) {
	env := setupTestEnv(t, readableMetaViewer())

	authHandler := NewAuthHandler(env.app, AuthHandlerConfig{
		AuthURL:      "https://auth.example.com/api/oidc/authorization",
		RedirectURL:  "https://transcriber.example.com/auth/callback",
		ClientID:     "transcriber",
		SecureCookie: true,
	}, nil)

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

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

	accountsBefore, err := env.app.FindAllRecords("users")
	require.NoError(t, err)

	cases := []struct {
		name   string
		cookie *http.Cookie
		query  string
	}{
		{
			name:   "состояния не выдавали вовсе",
			cookie: nil,
			query:  "?code=whatever&state=foreign",
		},
		{
			name:   "состояние не совпало с выданным",
			cookie: &http.Cookie{Name: stateCookieName, Value: "issued:verifier"},
			query:  "?code=whatever&state=foreign",
		},
		{
			name:   "провайдер вернул отказ",
			cookie: &http.Cookie{Name: stateCookieName, Value: "issued:verifier"},
			query:  "?error=access_denied&state=issued",
		},
	}

	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			req := httptest.NewRequest(http.MethodGet, "/auth/callback"+tc.query, nil)
			if tc.cookie != nil {
				req.AddCookie(tc.cookie)
			}
			w := httptest.NewRecorder()
			mux.ServeHTTP(w, req)

			assert.Equal(t, http.StatusUnauthorized, w.Code)

			accountsAfter, err := env.app.FindAllRecords("users")
			require.NoError(t, err)
			assert.Len(t, accountsAfter, len(accountsBefore))

			// Носитель убирается и на отказном возврате: иначе состояние
			// осталось бы годным для новой попытки.
			assert.Contains(t, w.Result().Header.Get("Set-Cookie"), stateCookieName+"=;")
		})
	}
}

// TestHeaderBeatsCookie: предъявленный заголовок побеждает куку.
func TestHeaderBeatsCookie(t *testing.T) {
	env := setupTestEnv(t, readableMetaViewer())

	req := httptest.NewRequest(http.MethodGet, "/app/audiorecords/nosuchjobid", nil)
	req.AddCookie(&http.Cookie{Name: SessionCookieName, Value: "totally-invalid-session"})
	req.Header.Set("Authorization", env.session)

	w := httptest.NewRecorder()
	env.mux.ServeHTTP(w, req)

	// Прошёл заголовок: иначе негодная кука дала бы отказ.
	assert.Equal(t, http.StatusNotFound, w.Code)
}

// TestSelfServiceAccountsAreClosed — то, ради чего задача вообще имеет смысл.
// Пока создание записи и вход по паролю открыты, закрытие приёма обходится
// двумя запросами.
func TestSelfServiceAccountsAreClosed(t *testing.T) {
	env := setupTestEnv(t, readableMetaViewer())

	t.Run("завести учётную запись самому нельзя", func(t *testing.T) {
		body := strings.NewReader(`{"email":"intruder@example.com","password":"12345678901","passwordConfirm":"12345678901"}`)
		req := httptest.NewRequest(http.MethodPost, "/api/collections/users/records", body)
		req.Header.Set("Content-Type", "application/json")

		w := httptest.NewRecorder()
		env.mux.ServeHTTP(w, req)

		assert.NotEqual(t, http.StatusOK, w.Code)
		assert.GreaterOrEqual(t, w.Code, http.StatusBadRequest)
	})

	t.Run("вход паролем недоступен", func(t *testing.T) {
		body := strings.NewReader(`{"identity":"person@example.com","password":"whatever"}`)
		req := httptest.NewRequest(http.MethodPost, "/api/collections/users/auth-with-password", body)
		req.Header.Set("Content-Type", "application/json")

		w := httptest.NewRecorder()
		env.mux.ServeHTTP(w, req)

		assert.GreaterOrEqual(t, w.Code, http.StatusBadRequest)
	})
}

// TestAccessGrantingValuesAreNotLogged — четвёртый критерий приёмки, расширенный
// ревью дизайна: не печатается ничто, что даёт доступ.
//
// Проверка ищет в журнале **значения**, а не имена полей: значение, уехавшее под
// другим ключом, поиск по ключу не разбудил бы.
func TestAccessGrantingValuesAreNotLogged(t *testing.T) {
	env := setupTestEnv(t, readableMetaViewer())

	created := httptest.NewRecorder()
	env.serve(created, createMultipartRequest(t, "test.mp3", []byte("audio")))
	require.Equal(t, http.StatusCreated, created.Code)

	journal := env.journal.String()
	require.NotEmpty(t, journal, "журнал пуст — проверке не на чем сработать")

	assert.NotContains(t, journal, env.session,
		"значение сессии в журнале: строка стала бы ключом к чужому доступу")
	assert.NotContains(t, journal, env.account.Email(),
		"адрес почты в журнале: он приходит от провайдера и принадлежит человеку")
}

// TestProviderSecretIsNotLogged: секрет клиента не появляется в журнале при
// приведении настроек провайдера к конфигу.
func TestProviderSecretIsNotLogged(t *testing.T) {
	app := newTestStorage(t)

	const secret = "super-secret-client-value"

	require.NoError(t, pbrepo.ApplyProviderSettings(app, pbrepo.ProviderSettings{
		AuthURL:      "https://auth.example.com/api/oidc/authorization",
		TokenURL:     "https://auth.example.com/api/oidc/token",
		UserInfoURL:  "https://auth.example.com/api/oidc/userinfo",
		ClientID:     "transcriber",
		ClientSecret: secret,
	}))

	// Настройка доехала до хранилища — иначе проверка отсутствия секрета в
	// журнале прошла бы на невыполненной работе.
	users, err := app.FindCollectionByNameOrId("users")
	require.NoError(t, err)
	provider, found := users.OAuth2.GetProviderConfig(pbrepo.ProviderName)
	require.True(t, found)
	assert.Equal(t, secret, provider.ClientSecret)
}

// TestProviderSecretRotationReachesStorage: смена секрета в конфиге доезжает до
// хранилища. Положенный однажды шагом схемы, он бы не доехал — применённый шаг
// не переписывается.
func TestProviderSecretRotationReachesStorage(t *testing.T) {
	app := newTestStorage(t)

	settings := pbrepo.ProviderSettings{
		AuthURL:      "https://auth.example.com/api/oidc/authorization",
		TokenURL:     "https://auth.example.com/api/oidc/token",
		UserInfoURL:  "https://auth.example.com/api/oidc/userinfo",
		ClientID:     "transcriber",
		ClientSecret: "first-secret",
	}
	require.NoError(t, pbrepo.ApplyProviderSettings(app, settings))

	settings.ClientSecret = "rotated-secret"
	require.NoError(t, pbrepo.ApplyProviderSettings(app, settings))

	users, err := app.FindCollectionByNameOrId("users")
	require.NoError(t, err)
	provider, found := users.OAuth2.GetProviderConfig(pbrepo.ProviderName)
	require.True(t, found)
	assert.Equal(t, "rotated-secret", provider.ClientSecret)
}

// TestRecordFileIsProtected: ссылка на файл перестала быть правом пройти по ней.
func TestRecordFileIsProtected(t *testing.T) {
	app := newTestStorage(t)

	files, err := app.FindCollectionByNameOrId(migrations.FilesCollection)
	require.NoError(t, err)

	field, ok := files.Fields.GetByName("file").(*core.FileField)
	require.True(t, ok)
	assert.True(t, field.Protected,
		"поле файла не защищено: знание ссылки снова стало бы доступом, а отзыва у неё нет")
}

// TestRecordFileNeedsSession: ссылка на файл записи без сессии отказывает, а
// конвейер тот же файл по-прежнему читает — он ходит в файловую систему, а не по
// ссылке.
func TestRecordFileNeedsSession(t *testing.T) {
	env := setupTestEnv(t, readableMetaViewer())

	created := httptest.NewRecorder()
	env.serve(created, createMultipartRequest(t, "test.mp3", []byte("audio content")))
	require.Equal(t, http.StatusCreated, created.Code)

	files, err := env.app.FindAllRecords(migrations.FilesCollection)
	require.NoError(t, err)
	require.Len(t, files, 1)

	names := files[0].GetStringSlice("file")
	require.Len(t, names, 1)

	link := "/api/files/" + migrations.FilesCollection + "/" + files[0].Id + "/" + names[0]

	anonymous := httptest.NewRecorder()
	env.mux.ServeHTTP(anonymous, httptest.NewRequest(http.MethodGet, link, nil))
	// Отказ приходит кодом «не найдено»: защищённый файл не раскрывает даже
	// своего существования. До пометки поля защищённым эта же ссылка отдавала
	// содержимое кому угодно — знание ссылки и было доступом.
	assert.Equal(t, http.StatusNotFound, anonymous.Code,
		"ссылка отдала файл без сессии: знание ссылки снова стало доступом")
	assert.NotContains(t, anonymous.Body.String(), "audio content")

	// Конвейер читает тот же файл своим путём — из файловой системы хранилища.
	fileRepo := pbrepo.NewFileRepository(env.app)
	reader, err := fileRepo.Open(files[0].Id)
	require.NoError(t, err)
	defer func() {
		assert.NoError(t, reader.Close())
	}()

	content := make([]byte, len("audio content"))
	_, err = reader.Read(content)
	require.NoError(t, err)
	assert.Equal(t, "audio content", string(content))
}

// TestSessionLifetimeIsAssigned: срок жизни сессии назначен нами, а не достался
// умолчанием библиотеки в пять суток.
//
// Назначается он приведением настроек при подъёме, а не шагом схемы: применённый
// шаг не переписывается, и число, положенное туда, разошлось бы со сроком жизни
// куки при первой же правке.
func TestSessionLifetimeIsAssigned(t *testing.T) {
	app := newTestStorage(t)

	users, err := app.FindCollectionByNameOrId("users")
	require.NoError(t, err)
	require.NotEqual(t, int64(pbrepo.SessionDuration), users.AuthToken.Duration,
		"шаг схемы назначил срок сам — тогда правка числа до хранилища не доедет")

	require.NoError(t, pbrepo.ApplyProviderSettings(app, pbrepo.ProviderSettings{
		AuthURL:      "https://auth.example.com/api/oidc/authorization",
		TokenURL:     "https://auth.example.com/api/oidc/token",
		UserInfoURL:  "https://auth.example.com/api/oidc/userinfo",
		ClientID:     "transcriber",
		ClientSecret: "local-test-secret",
	}))

	users, err = app.FindCollectionByNameOrId("users")
	require.NoError(t, err)
	assert.Equal(t, int64(pbrepo.SessionDuration), users.AuthToken.Duration)
}
