package http

import (
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"net/url"
	"reflect"
	"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"
)

// Проверки этого файла проходят вход целиком — от увода к провайдеру до куки
// сессии. Без них сердцевина изменения не исполнялась ни разу: прочие проверки
// заводят учётную запись прямым сохранением и останавливаются раньше обмена.

// fakeProvider — подставной провайдер OIDC. Отдаёт токен и сведения о человеке,
// считая обращения: по счётчику видно, дошло ли до сети вообще.
type fakeProvider struct {
	server     *httptest.Server
	tokenHits  int
	failToken  bool
	subject    string
	emailValue string
}

func newFakeProvider(t *testing.T) *fakeProvider {
	t.Helper()

	provider := &fakeProvider{subject: "person-sub-1", emailValue: "person@example.com"}

	write := func(w http.ResponseWriter, body string) {
		if _, err := w.Write([]byte(body)); err != nil {
			t.Errorf("подставной провайдер не ответил: %v", err)
		}
	}

	mux := http.NewServeMux()
	mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) {
		provider.tokenHits++
		if provider.failToken {
			w.WriteHeader(http.StatusBadRequest)
			write(w, `{"error":"invalid_grant"}`)
			return
		}
		w.Header().Set("Content-Type", "application/json")
		write(w, `{"access_token":"provider-access-token","token_type":"bearer","expires_in":3600}`)
	})
	mux.HandleFunc("/userinfo", func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		write(w, `{"sub":"`+provider.subject+`","email":"`+provider.emailValue+`","name":"Person","email_verified":true}`)
	})

	provider.server = httptest.NewServer(mux)
	t.Cleanup(provider.server.Close)

	return provider
}

// loginEnv — окружение проверки входа: хранилище с настроенным подставным
// провайдером и собранный роутер со всеми слоями.
type loginEnv struct {
	app      core.App
	mux      http.Handler
	handler  *AuthHandler
	provider *fakeProvider
}

func setupLoginEnv(t *testing.T) *loginEnv {
	t.Helper()

	app := newTestStorage(t)
	provider := newFakeProvider(t)

	require.NoError(t, pbrepo.ApplyProviderSettings(app, pbrepo.ProviderSettings{
		AuthURL:      provider.server.URL + "/authorize",
		TokenURL:     provider.server.URL + "/token",
		UserInfoURL:  provider.server.URL + "/userinfo",
		ClientID:     "transcriber",
		ClientSecret: "local-test-secret",
	}))

	handler := NewAuthHandler(app, AuthHandlerConfig{
		AuthURL:      provider.server.URL + "/authorize",
		RedirectURL:  "https://transcriber.example.com/auth/callback",
		ClientID:     "transcriber",
		SecureCookie: true,
	}, nil)

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

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

	return &loginEnv{app: app, mux: mux, handler: handler, provider: provider}
}

// startLogin проходит первый шаг входа и отдаёт носитель состояния вместе с
// выданным состоянием — тем, что сервис ждёт обратно.
func (e *loginEnv) startLogin(t *testing.T) (cookie *http.Cookie, state string) {
	t.Helper()

	w := httptest.NewRecorder()
	e.mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/auth/login", nil))
	require.Equal(t, http.StatusFound, w.Code)

	for _, c := range w.Result().Cookies() {
		if c.Name == stateCookieName {
			cookie = c
		}
	}
	require.NotNil(t, cookie, "носитель состояния не поставлен")

	location, err := url.Parse(w.Result().Header.Get("Location"))
	require.NoError(t, err)
	state = location.Query().Get("state")
	require.NotEmpty(t, state)

	return cookie, state
}

// TestLoginCreatesAccountAndSession — вход целиком: человека заводят по слову
// провайдера, и он получает сессию.
//
// Без этой проверки закрытое создание записи в коллекции пользователей выглядит
// работающим: прочие проверки заводят запись мимо входа.
func TestLoginCreatesAccountAndSession(t *testing.T) {
	env := setupLoginEnv(t)

	before, err := env.app.FindAllRecords("users")
	require.NoError(t, err)
	require.Empty(t, before, "учётных записей быть не должно: шаг схемы их не заводит")

	cookie, state := env.startLogin(t)

	req := httptest.NewRequest(http.MethodGet, "/auth/callback?code=provider-code&state="+state, nil)
	req.AddCookie(cookie)
	w := httptest.NewRecorder()
	env.mux.ServeHTTP(w, req)

	require.Equal(t, http.StatusFound, w.Code, "вход не прошёл: тело %s", w.Body.String())
	assert.Equal(t, 1, env.provider.tokenHits, "обмен до провайдера не дошёл")

	after, err := env.app.FindAllRecords("users")
	require.NoError(t, err)
	require.Len(t, after, 1, "учётная запись не заведена — войти не может никто")

	var session *http.Cookie
	for _, c := range w.Result().Cookies() {
		if c.Name == SessionCookieName {
			session = c
		}
	}
	require.NotNil(t, session, "кука сессии не поставлена")

	// Признаки куки нормативны: их потеря делает сессию доступной скриптам либо
	// уносит её по незашифрованному соединению.
	assert.True(t, session.HttpOnly)
	assert.True(t, session.Secure)
	assert.Equal(t, http.SameSiteLaxMode, session.SameSite)
	assert.Equal(t, pbrepo.SessionDuration, session.MaxAge)
	assert.NotEmpty(t, session.Value)

	// Носитель состояния убран — и убран так, что это видно готовому ответу, а
	// не только живой карте заголовков.
	var cleared bool
	for _, c := range w.Result().Cookies() {
		if c.Name == stateCookieName && c.MaxAge < 0 {
			cleared = true
		}
	}
	assert.True(t, cleared, "носитель состояния пережил возврат")

	// Выданная сессия открывает доступ к закрытым адресам.
	check := httptest.NewRequest(http.MethodGet, "/app/audiorecords/nosuchjobid", nil)
	check.AddCookie(session)
	checkResponse := httptest.NewRecorder()

	r, err := apis.NewRouter(env.app)
	require.NoError(t, err)
	NewAppHandler(
		pbrepo.NewAudioRecordRepository(env.app),
		pbrepo.NewTextRepository(env.app),
		pbrepo.NewStructureRepository(env.app),
		nil, nil,
	).Register(r)
	checkMux, err := r.BuildMux()
	require.NoError(t, err)
	checkMux.ServeHTTP(checkResponse, check)

	assert.Equal(t, http.StatusNotFound, checkResponse.Code,
		"сессия не открыла доступ: получен %d", checkResponse.Code)
}

// TestSelfServiceRegistrationStaysClosed: правило создания пускает обмен и не
// пускает постороннего.
func TestSelfServiceRegistrationStaysClosed(t *testing.T) {
	env := setupLoginEnv(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.GreaterOrEqual(t, w.Code, http.StatusBadRequest,
		"посторонний завёл себе учётную запись: %s", w.Body.String())

	accounts, err := env.app.FindAllRecords("users")
	require.NoError(t, err)
	assert.Empty(t, accounts)
}

// TestCallbackDoesNotLeakHooks: обмен не копит обработчики приложения.
//
// Сборка роутера хранилища вешает обработчики на само приложение и без
// идентификатора, поэтому повторная не заменяет прежние. Собранный на каждый
// вход, роутер копил бы их без предела — и копил бы по запросу анонима, потому
// что обмен исполняется раньше обращения к провайдеру.
func TestCallbackDoesNotLeakHooks(t *testing.T) {
	env := setupLoginEnv(t)

	count := func() int {
		hook := reflect.ValueOf(env.app.OnModelAfterCreateSuccess()).Elem().FieldByName("handlers")
		return hook.Len()
	}

	// Первый вход собирает роутер — с него и считаем.
	cookie, state := env.startLogin(t)
	first := httptest.NewRequest(http.MethodGet, "/auth/callback?code=c&state="+state, nil)
	first.AddCookie(cookie)
	env.mux.ServeHTTP(httptest.NewRecorder(), first)

	before := count()

	for range 20 {
		cookie, state := env.startLogin(t)
		req := httptest.NewRequest(http.MethodGet, "/auth/callback?code=c&state="+state, nil)
		req.AddCookie(cookie)
		env.mux.ServeHTTP(httptest.NewRecorder(), req)
	}

	assert.Equal(t, before, count(),
		"обработчики копятся: 20 входов добавили %d", count()-before)
}

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

	cookie, state := env.startLogin(t)
	req := httptest.NewRequest(http.MethodGet, "/auth/callback?code=provider-code&state="+state, nil)
	req.AddCookie(cookie)
	w := httptest.NewRecorder()
	env.mux.ServeHTTP(w, req)
	require.Equal(t, http.StatusFound, w.Code)

	var session *http.Cookie
	for _, c := range w.Result().Cookies() {
		if c.Name == SessionCookieName {
			session = c
		}
	}
	require.NotNil(t, session)

	refresh := httptest.NewRequest(http.MethodPost, RefreshPath, nil)
	refresh.Header.Set("Authorization", session.Value)
	refreshResponse := httptest.NewRecorder()
	env.mux.ServeHTTP(refreshResponse, refresh)

	assert.Equal(t, http.StatusNotFound, refreshResponse.Code,
		"сессия продлилась: %s", refreshResponse.Body.String())

	// И нового значения в ответе нет — продлевать нечем.
	assert.NotContains(t, refreshResponse.Body.String(), `"token"`)
}

// TestCallbackRejectsProviderFailure: отказ обмена не открывает сессию.
func TestCallbackRejectsProviderFailure(t *testing.T) {
	env := setupLoginEnv(t)
	env.provider.failToken = true

	cookie, state := env.startLogin(t)
	req := httptest.NewRequest(http.MethodGet, "/auth/callback?code=provider-code&state="+state, nil)
	req.AddCookie(cookie)
	w := httptest.NewRecorder()
	env.mux.ServeHTTP(w, req)

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

	for _, c := range w.Result().Cookies() {
		assert.NotEqual(t, SessionCookieName, c.Name, "сессия открыта на отказе обмена")
	}

	accounts, err := env.app.FindAllRecords("users")
	require.NoError(t, err)
	assert.Empty(t, accounts)
}

// TestRecordFileNeedsSessionAndToken: файл записи отдаётся вошедшему и не
// отдаётся анониму.
func TestRecordFileNeedsSessionAndToken(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.GreaterOrEqual(t, anonymous.Code, http.StatusBadRequest)
	assert.NotContains(t, anonymous.Body.String(), "audio content")

	// Вошедший берёт короткоживущий токен файла и проходит по ссылке с ним:
	// защищённый файл судится этим токеном, а не сессионной кукой.
	tokenRequest := httptest.NewRequest(http.MethodPost, "/api/files/token", nil)
	tokenRequest.Header.Set("Authorization", env.session)
	tokenResponse := httptest.NewRecorder()
	env.mux.ServeHTTP(tokenResponse, tokenRequest)
	require.Equal(t, http.StatusOK, tokenResponse.Code, "токен файла не выдан: %s", tokenResponse.Body.String())

	var payload struct {
		Token string `json:"token"`
	}
	require.NoError(t, json.Unmarshal(tokenResponse.Body.Bytes(), &payload))
	require.NotEmpty(t, payload.Token)

	withToken := httptest.NewRecorder()
	env.mux.ServeHTTP(withToken, httptest.NewRequest(http.MethodGet, link+"?token="+payload.Token, nil))

	assert.Equal(t, http.StatusOK, withToken.Code,
		"вошедший не получил файл: %d", withToken.Code)
	assert.Contains(t, withToken.Body.String(), "audio content")
}
