package http

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

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

	"git.vakhrushev.me/av/transcriber/internal/entity"
)

// Проверки этого файла судят допуск: кого пускают к адресам приложения, чем
// называется пришедший, кому верят и какие адреса остаются открытыми.

// TestApiRequiresIdentity — первый критерий приёмки. Запрос неузнанного получает
// отказ и ничего не заводит.
func TestApiRequiresIdentity(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")

		// Ни файла, ни записи: отказ наступает раньше, чем запись попадает в
		// каталог данных.
		assert.Equal(t, 0, countFiles(t, env))
		assert.Equal(t, 0, countJobs(t, env))
	})

	t.Run("карточка записи неузнанному", func(t *testing.T) {
		w := env.get("/app/audiorecords/" + strings.Repeat("0", 26))

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

// TestUnknownJobIsIndistinguishableWithoutIdentity: по кодам ответа неузнанному
// не перебирается список заведённых записей.
func TestUnknownJobIsIndistinguishableWithoutIdentity(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)

	existing := env.get("/app/audiorecords/" + intakeItemOf(t, created).ID)
	missing := env.get("/app/audiorecords/" + strings.Repeat("0", 26))

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

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

	const login = "newcomer"

	before := countAccounts(t, env)

	first := env.getAs(login, "/app/me")
	require.Equal(t, http.StatusOK, first.Code, "первое обращение узнано")

	second := env.getAs(login, "/app/me")
	require.Equal(t, http.StatusOK, second.Code)

	assert.Equal(t, before+1, countAccounts(t, env),
		"второе обращение завело вторую запись: архив разъехался бы между ними")
	assert.Equal(t, first.Body.String(), second.Body.String(),
		"второе обращение попало в другую учётную запись")
}

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

	before := countAccounts(t, env)

	req := httptest.NewRequest(http.MethodGet, "/app/me", nil)
	req.Header.Set(LoginHeader, "intruder")
	req.RemoteAddr = untrustedPeer

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

	assert.Equal(t, http.StatusUnauthorized, w.Code)
	assert.Equal(t, before, countAccounts(t, env),
		"заголовок с недоверенного адреса завёл учётную запись")
}

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

	before := countAccounts(t, env)

	// Эталон: путь вне корней сервиса, за которым не стояло ничего никогда.
	reference := env.get("/nothing-was-ever-here")
	require.Equal(t, http.StatusOK, reference.Code)

	cases := map[string]string{
		"перечисление учётных записей": "/api/collections/users/records",
		"вход по паролю":               "/api/collections/users/auth-with-password",
		"обмен кода у провайдера":      "/api/collections/users/auth-with-oauth2",
		"выдача токена файла":          "/api/files/token",
		"адрес панели":                 "/_/",
		"адрес панели знаком кода":     "/%5f/",
	}

	for name, path := range cases {
		t.Run(name, func(t *testing.T) {
			w := env.getOwn(path)

			assert.Equal(t, reference.Code, w.Code,
				"адрес %s отвечает не как всякий неизвестный путь", path)
			assert.Equal(t, reference.Body.String(), w.Body.String(),
				"адрес %s отвечает не тем же телом, что всякий неизвестный путь", path)
			assert.NotContains(t, w.Body.String(), `"token"`,
				"адрес %s выдал значение доступа", path)
		})
	}

	assert.Equal(t, before, countAccounts(t, env),
		"обход прежнего пространства хранилища изменил число учётных записей")
}

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

	body := strings.NewReader(`{"provider_login":"victim"}`)
	req := httptest.NewRequest(http.MethodPatch, "/api/collections/users/records/"+env.account.ID, body)
	req.Header.Set("Content-Type", "application/json")

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

	// Метод правки до раздачи приложения не доходит: она открывает страницу
	// только на `GET` и `HEAD`.
	assert.Equal(t, http.StatusMethodNotAllowed, w.Code)
	assert.Equal(t, env.login, accountLogin(t, env, env.account.ID),
		"ключ учётной записи переписан снаружи")
}

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

	cases := map[string]string{
		"пустое значение":  "",
		"одни пробелы":     "   ",
		"управляющий знак": "ali\x00ce",
		"длиннее предела":  strings.Repeat("a", entity.MaxProviderLoginLength+1),
	}

	for name, value := range cases {
		t.Run(name, func(t *testing.T) {
			before := countAccounts(t, env)

			req := httptest.NewRequest(http.MethodGet, "/app/me", nil)
			req.Header.Set(LoginHeader, value)
			req.RemoteAddr = trustedPeer

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

			assert.Equal(t, http.StatusUnauthorized, w.Code)
			assert.Equal(t, before, countAccounts(t, env), "вырожденное значение завело учётную запись")
		})
	}
}

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

	before := countAccounts(t, env)

	req := httptest.NewRequest(http.MethodGet, "/app/me", nil)
	req.Header.Add(LoginHeader, "intruder")
	req.Header.Add(LoginHeader, env.login)
	req.RemoteAddr = trustedPeer

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

	assert.Equal(t, http.StatusUnauthorized, w.Code)
	assert.Equal(t, before, countAccounts(t, env))
}

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

	stranger := newSecondAccount(t, env)

	req := httptest.NewRequest(http.MethodGet, "/app/me?token=whatever", nil)
	req.Header.Set("Authorization", "Bearer whatever")

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

	require.Equal(t, http.StatusOK, w.Code)
	assert.Contains(t, w.Body.String(), env.account.ID,
		"предъявленное значение победило заголовок")
	assert.NotContains(t, w.Body.String(), stranger.ID)
}

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

	before := countAccounts(t, env)

	for _, path := range []string{HealthPath, "/", "/assets/index-abc123.js"} {
		anonymous := env.get(path)
		assert.Equal(t, http.StatusOK, anonymous.Code, "адрес %s обязан отвечать неузнанному", path)

		withHeader := env.getAs("passerby", path)
		assert.Equal(t, http.StatusOK, withHeader.Code,
			"чужой заголовок изменил ответ адреса %s: наблюдение гасится строкой в запросе", path)
	}

	assert.Equal(t, before, countAccounts(t, env),
		"обращение к открытому адресу завело учётную запись")
}

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

	w := env.getOwn("/app/me")
	require.Equal(t, http.StatusOK, w.Code)

	assert.Empty(t, w.Result().Cookies(), "ответ поставил куку: значение переживёт запрос")
}

// TestIdentityValuesAreNotLogged — не печатается ничто, что даёт доступ.
//
// Проверка ищет в журнале **значения**, а не имена полей: значение, уехавшее под
// другим ключом, поиск по ключу не разбудил бы. Логин здесь наравне с почтой: им
// довольно назваться, чтобы стать этим человеком.
func TestIdentityValuesAreNotLogged(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.login,
		"логин в журнале: строкой довольно назваться, чтобы стать этим человеком")
	assert.NotContains(t, journal, "person@example.com",
		"адрес почты в журнале: он приходит от провайдера и принадлежит человеку")
}

// TestUntrustedPeerIsLogged: недоверенный источник виден владельцу журналом, и
// виден **адресом пира**, а не значением заголовка.
func TestUntrustedPeerIsLogged(t *testing.T) {
	env := setupTestEnv(t, readableMetaViewer())

	const intruder = "intruder-login-value"

	req := httptest.NewRequest(http.MethodGet, "/app/me", nil)
	req.Header.Set(LoginHeader, intruder)
	req.RemoteAddr = untrustedPeer

	w := httptest.NewRecorder()
	env.mux.ServeHTTP(w, req)
	require.Equal(t, http.StatusUnauthorized, w.Code)

	journal := env.journal.String()
	assert.Contains(t, journal, "203.0.113.9", "адреса пира в журнале нет: поломку не отличить")
	assert.NotContains(t, journal, intruder, "значение заголовка уехало в журнал")
}

// countAccounts — сколько учётных записей лежит в базе. Проверки судят заведение
// по числу строк: «запись одна» и «записи две» — разные исходы, а по ответу
// обработчика они неразличимы.
func countAccounts(t *testing.T, env *testEnv) int {
	t.Helper()

	return countRows(t, env, "users")
}

// accountLogin читает ключ учётной записи прямо из базы.
func accountLogin(t *testing.T, env *testEnv, accountID string) string {
	t.Helper()

	var login string
	require.NoError(t, env.db.Reader().
		QueryRow("SELECT provider_login FROM users WHERE id = ?", accountID).Scan(&login))
	return login
}

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

	before := countAccounts(t, env)

	// Бюджет выбирается запросами одного имени, чтобы счётчик успел упереться в
	// потолок раньше, чем начнутся новые имена.
	for range appRateMaxRequests + 5 {
		env.getOwn("/app/me")
	}

	rejected := 0
	for i := range 20 {
		w := env.getAs(fmt.Sprintf("newcomer-%d", i), "/app/me")
		if w.Code == http.StatusTooManyRequests {
			rejected++
		}
	}

	require.Positive(t, rejected, "ограничитель не сработал — проверке не на чем сработать")
	assert.Equal(t, before, countAccounts(t, env),
		"отвергнутый ограничителем запрос завёл учётную запись: узнавание стоит раньше ограничителя")
}

// TestAccountCreationIsLogged — заведение учётной записи видно владельцу.
func TestAccountCreationIsLogged(t *testing.T) {
	env := setupTestEnv(t, readableMetaViewer())

	const login = "brand-new-person"

	w := env.getAs(login, "/app/me")
	require.Equal(t, http.StatusOK, w.Code)

	journal := env.journal.String()
	assert.Contains(t, journal, "Account created from login header", "заведение не оставило строки")
	assert.NotContains(t, journal, login, "значение заголовка уехало в журнал")

	// Второе обращение новой строки не прибавляет: заводится запись однажды.
	before := strings.Count(journal, "Account created from login header")

	again := env.getAs(login, "/app/me")
	require.Equal(t, http.StatusOK, again.Code)

	assert.Equal(t, before, strings.Count(env.journal.String(), "Account created from login header"),
		"повторное обращение отчиталось заведением")
}

// TestDuplicateLoginHeaderIsVisibleToOwner — поломка контура видна в бою.
func TestDuplicateLoginHeaderIsVisibleToOwner(t *testing.T) {
	env := setupTestEnv(t, readableMetaViewer())

	req := httptest.NewRequest(http.MethodGet, "/app/me", nil)
	req.Header.Add(LoginHeader, "intruder")
	req.Header.Add(LoginHeader, env.login)
	req.RemoteAddr = trustedPeer

	w := httptest.NewRecorder()
	env.mux.ServeHTTP(w, req)
	require.Equal(t, http.StatusUnauthorized, w.Code)

	journal := env.journal.String()
	assert.Contains(t, journal, "level=WARN", "поломка контура записана уровнем, невидимым в бою")
	assert.Contains(t, journal, "more than one login header")
	assert.NotContains(t, journal, "intruder", "значение заголовка уехало в журнал")
}

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

	// Колонка ключа переименовывается: выборка по ней перестаёт работать — так
	// же, как она перестанет работать при отказе базы.
	_, err := env.db.Writer().Exec("ALTER TABLE users RENAME COLUMN provider_login TO provider_login_gone")
	require.NoError(t, err)

	w := env.getAs("somebody", "/app/me")

	assert.GreaterOrEqual(t, w.Code, http.StatusInternalServerError,
		"отказ базы выдан за «вас не узнали»")
	assert.Contains(t, env.journal.String(), "Failed to resolve account by login header")
}

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

	res := env.get("/auth/login")

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