package http

import (
	"bufio"
	"context"
	"encoding/json"
	"fmt"
	"io"
	"log/slog"
	"net"
	"net/http"
	"net/http/httptest"
	"strconv"
	"strings"
	"testing"
	"time"

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

	sqliterepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/sqlite"
	"git.vakhrushev.me/av/transcriber/internal/contract"
	"git.vakhrushev.me/av/transcriber/internal/entity"
	"git.vakhrushev.me/av/transcriber/internal/metrics"
)

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

	w := httptest.NewRecorder()
	env.serve(w, httptest.NewRequest("GET", "/app/config", http.NoBody))
	require.Equal(t, http.StatusOK, w.Code)

	var config ConfigView
	require.NoError(t, json.Unmarshal(w.Body.Bytes(), &config))

	// Потолок размера — то же число, которым ограничено тело запроса приёма и
	// которым сервис отвергает запись.
	assert.Equal(t, entity.MaxRecordSize, config.MaxRecordSizeBytes)
	assert.Equal(t, MaxPageLimit, config.MaxPageSize)
	assert.Equal(t, entity.MaxTopicsPerRecord, config.MaxTopicsPerRecord)
	assert.Equal(t, PollIntervalMs, config.PollIntervalMs)

	// Перечень расширений — тот же, что сужает метку метрики, за вычетом
	// собственного умолчания сервиса: `audio` не формат, и подсказкой человеку
	// выходить не должно.
	assert.Equal(t, metrics.PublicFormats(), config.KnownExtensions)
	assert.NotContains(t, config.KnownExtensions, "audio",
		"умолчание сервиса форматом не является")
	assert.Contains(t, config.KnownExtensions, "mp3")
}

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

	w := httptest.NewRecorder()
	env.serve(w, httptest.NewRequest("GET", "/app/me", http.NoBody))
	require.Equal(t, http.StatusOK, w.Code)

	var me MeView
	require.NoError(t, json.Unmarshal(w.Body.Bytes(), &me))

	assert.Equal(t, env.account.ID, me.ID)
	assert.NotContains(t, w.Body.String(), "person@example.com",
		"адрес почты принадлежит человеку, а не сервису")
}

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

	record := jobWithFile(t, env)

	existing := httptest.NewRecorder()
	env.mux.ServeHTTP(existing, httptest.NewRequest("GET", "/app/audiorecords/"+record.Id, http.NoBody))

	unknown := httptest.NewRecorder()
	env.mux.ServeHTTP(unknown, httptest.NewRequest("GET", "/app/audiorecords/"+unknownRecordID, http.NoBody))

	require.Equal(t, http.StatusUnauthorized, existing.Code)
	require.Equal(t, http.StatusUnauthorized, unknown.Code)
	assert.Equal(t, existing.Body.String(), unknown.Body.String(),
		"тело одно: по разнице ответов иначе перебирается список записей")

	var body ErrorBody
	require.NoError(t, json.Unmarshal(existing.Body.Bytes(), &body))
	assert.Equal(t, CodeUnauthorized, body.Code)
}

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

	w := httptest.NewRecorder()
	env.serve(w, createMultipartRequest(t, "разговор.mp3", []byte("данные")))
	require.Equal(t, http.StatusCreated, w.Code)

	item := intakeItemOf(t, w)

	record, err := env.handler.recordRepo.GetByID(item.ID, env.account.ID)
	require.NoError(t, err)

	require.NotNil(t, record.OriginalFilename)
	assert.Equal(t, "разговор.mp3", *record.OriginalFilename)
	assert.Nil(t, record.Title, "колонка заголовка у принятой записи пуста")

	// Длительность и размер — снимок принятого, взятый приёмом.
	require.NotNil(t, record.DurationMs)
	assert.Equal(t, int64(42_000), *record.DurationMs)
	require.NotNil(t, record.SizeBytes)
	assert.Positive(t, *record.SizeBytes)
}

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

	long := strings.Repeat("я", entity.MaxOriginalFilenameLen+50) + ".mp3"

	w := httptest.NewRecorder()
	env.serve(w, createMultipartRequest(t, long, []byte("данные")))
	require.Equal(t, http.StatusCreated, w.Code)

	item := intakeItemOf(t, w)

	record, err := env.handler.recordRepo.GetByID(item.ID, env.account.ID)
	require.NoError(t, err)
	require.NotNil(t, record.OriginalFilename)

	stored := *record.OriginalFilename
	assert.Len(t, []rune(stored), entity.MaxOriginalFilenameLen,
		"имя обрезано по пределу, и режется оно по знакам, а не по байтам")
}

// Отказ по превышению потолка размера проходит через единую форму: прежде он
// уходил телом ограничителя тела и читался как «сломался сервер».
func TestIntake_TooLargeGoesThroughOneErrorForm(t *testing.T) {
	env := setupTestEnv(t, readableMetaViewer())

	status, body := mapDomainError(errTooLargeForTest())

	assert.Equal(t, http.StatusRequestEntityTooLarge, status)
	assert.Equal(t, CodeTooLarge, body.Code)
	require.NotNil(t, body.Limit, "предел уходит человеку числом")
	assert.Equal(t, entity.MaxRecordSize, *body.Limit)
	assert.NotEmpty(t, body.Message)

	// И тот же предел объявлен адресом пределов — одним числом, а не двумя.
	w := httptest.NewRecorder()
	env.serve(w, httptest.NewRequest("GET", "/app/config", http.NoBody))
	var config ConfigView
	require.NoError(t, json.Unmarshal(w.Body.Bytes(), &config))
	assert.Equal(t, *body.Limit, config.MaxRecordSizeBytes)
}

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

	cases := []struct {
		name string
		req  func() *http.Request
		code string
	}{
		{
			name: "записи нет",
			req: func() *http.Request {
				return httptest.NewRequest("GET", "/app/audiorecords/"+unknownRecordID, http.NoBody)
			},
			code: CodeNotFound,
		},
		{
			name: "негодный ввод",
			req: func() *http.Request {
				return httptest.NewRequest("GET", "/app/audiorecords?limit=0", http.NoBody)
			},
			code: CodeBadRequest,
		},
		{
			name: "негодная запись",
			req: func() *http.Request {
				return createMultipartRequestWithField(t, "wrong-field", "sample.mp3", []byte("данные"))
			},
			code: CodeBadRequest,
		},
	}

	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			w := httptest.NewRecorder()
			env.serve(w, tc.req())

			var body ErrorBody
			require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))

			assert.Equal(t, tc.code, body.Code, "код машиночитаем и из закрытого перечня")
			assert.NotEmpty(t, body.Message, "рядом с кодом стоит фраза для человека")

			var raw map[string]json.RawMessage
			require.NoError(t, json.Unmarshal(w.Body.Bytes(), &raw))
			assert.Contains(t, raw, "error_code")
			assert.Contains(t, raw, "message")
			assert.NotContains(t, raw, "error", "прежнее поле ушло вместе с прежним контрактом")
		})
	}
}

// Ограничитель частоты покрывает адреса приложения и **не трогает** адресов
// наблюдения: правило своё, и настроено оно на корень приложения.
func TestRateLimitCoversAppRootOnly(t *testing.T) {
	env := setupTestEnv(t, readableMetaViewer())

	var refused *httptest.ResponseRecorder
	for range appRateMaxRequests + 1 {
		refused = env.getOwn("/app/me")
		if refused.Code == http.StatusTooManyRequests {
			break
		}
	}
	require.Equal(t, http.StatusTooManyRequests, refused.Code,
		"бюджет под корнем приложения не исчерпался — проверке не на чем сработать")

	// Проба здоровья тем же бюджетом не ограничена: она лежит вне корня
	// приложения, а слои одеты на корень.
	assert.Equal(t, http.StatusOK, env.get(HealthPath).Code,
		"ограничитель приложения закрыл наблюдение за сервисом")
}

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

	spend := func(peer, forwarded string) int {
		refused := 0
		for range appRateMaxRequests + 1 {
			req := httptest.NewRequest("GET", "/app/me", http.NoBody)
			req.Header.Set(LoginHeader, env.login)
			req.RemoteAddr = peer
			if forwarded != "" {
				req.Header.Set(ForwardedForHeader, forwarded)
			}
			w := httptest.NewRecorder()
			env.mux.ServeHTTP(w, req)
			if w.Code == http.StatusTooManyRequests {
				refused++
			}
		}
		return refused
	}

	// Первый клиент выбирает свой бюджет целиком.
	require.Positive(t, spend(trustedPeer, "198.51.100.7"),
		"бюджет первого клиента не исчерпался — проверке не на чем сработать")

	// Второй клиент за тем же прокси начинает со своего.
	firstRefusalOfSecond := 0
	for range appRateMaxRequests {
		req := httptest.NewRequest("GET", "/app/me", http.NoBody)
		req.Header.Set(LoginHeader, env.login)
		req.RemoteAddr = trustedPeer
		req.Header.Set(ForwardedForHeader, "198.51.100.8")
		w := httptest.NewRecorder()
		env.mux.ServeHTTP(w, req)
		if w.Code == http.StatusTooManyRequests {
			firstRefusalOfSecond++
		}
	}
	assert.Zero(t, firstRefusalOfSecond,
		"исчерпание бюджета одним клиентом отказало другому: бюджет считается по пиру")
}

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

	refused := 0
	for i := range appRateMaxRequests + 10 {
		req := httptest.NewRequest("GET", "/app/me", http.NoBody)
		req.RemoteAddr = untrustedPeer
		req.Header.Set(ForwardedForHeader, fmt.Sprintf("198.51.100.%d", i%200))
		w := httptest.NewRecorder()
		env.mux.ServeHTTP(w, req)
		if w.Code == http.StatusTooManyRequests {
			refused++
		}
	}

	assert.Positive(t, refused,
		"меняя заголовок пересылки, спрашивающий обошёл ограничитель")
}

// spendBudget шлёт запросы под корнем приложения и считает отказы ограничителя.
// Заголовки пересылки ставит вызывающий: ключ бюджета выводится из них, и
// проверке нужен каждый их вид — одна строка, несколько строк, цепочка.
func spendBudget(env *testEnv, peer string, count int, forwarded func(i int) []string) int {
	refused := 0
	for i := range count {
		req := httptest.NewRequest("GET", "/app/me", http.NoBody)
		req.Header.Set(LoginHeader, env.login)
		req.RemoteAddr = peer
		for _, value := range forwarded(i) {
			req.Header.Add(ForwardedForHeader, value)
		}
		w := httptest.NewRecorder()
		env.mux.ServeHTTP(w, req)
		if w.Code == http.StatusTooManyRequests {
			refused++
		}
	}
	return refused
}

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

	refused := spendBudget(env, trustedPeer, appRateMaxRequests+10, func(i int) []string {
		return []string{fmt.Sprintf("198.51.100.%d, 203.0.113.7", i%200)}
	})

	assert.Positive(t, refused,
		"подставляя своё значение слева, спрашивающий обошёл ограничитель")
}

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

	refused := spendBudget(env, trustedPeer, appRateMaxRequests+10, func(i int) []string {
		return []string{fmt.Sprintf("198.51.100.%d", i%200), "203.0.113.8"}
	})

	assert.Positive(t, refused,
		"вторая строка заголовка не прочитана: ключ достался присланному значению")
}

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

	require.Positive(t,
		spendBudget(env, trustedPeer, appRateMaxRequests+1, func(int) []string {
			return []string{"203.0.113.11, 10.9.9.9"}
		}),
		"бюджет первого клиента не исчерпался — проверке не на чем сработать")

	assert.Zero(t,
		spendBudget(env, trustedPeer, appRateMaxRequests, func(int) []string {
			return []string{"203.0.113.12, 10.9.9.9"}
		}),
		"исчерпание бюджета одним клиентом отказало другому: доверенный шаг стал ключом")
}

// Тип содержимого ответа выбирает сервис, а не отправитель.
//
// Расширение приходит из имени, данное отправителем: `запись.html`, отданный
// типом `text/html` с показом на месте, стал бы страницей в браузере. Тип
// выводится поэтому из **закрытого** перечня известных форматов — той же единой
// точки, что и метка метрики, — а всё прочее отдаётся `application/octet-stream`
// на сохранение.
func TestFileDownload_ContentTypeComesFromKnownFormats(t *testing.T) {
	env := setupTestEnv(t, readableMetaViewer())

	fetch := func(t *testing.T, name string) *httptest.ResponseRecorder {
		t.Helper()

		created := httptest.NewRecorder()
		env.serve(created, createMultipartRequest(t, name, []byte("данные")))
		require.Equal(t, http.StatusCreated, created.Code, name)

		return env.getOwn("/app/audiorecords/" +
			intakeItemOf(t, created).ID + "/file?" + CopyParam + "=" + CopyOriginal)
	}

	t.Run("расширение вне перечня исполняемым типом не отдаётся", func(t *testing.T) {
		for _, name := range []string{"запись.html", "запись.svg", "запись.xhtml"} {
			w := fetch(t, name)
			require.Equal(t, http.StatusOK, w.Code, name)
			header := w.Result().Header
			assert.Equal(t, unknownContentType, header.Get("Content-Type"), name)
			assert.Contains(t, header.Get("Content-Disposition"), dispositionAttachment, name)
		}
	})

	t.Run("известный формат отдаётся своим типом", func(t *testing.T) {
		for name, want := range map[string]string{
			"запись.mkv": "video/x-matroska",
			"запись.mov": "video/quicktime",
			"запись.avi": "video/x-msvideo",
			"запись.mp3": "audio/mpeg",
		} {
			w := fetch(t, name)
			require.Equal(t, http.StatusOK, w.Code, name)
			header := w.Result().Header
			assert.Equal(t, want, header.Get("Content-Type"), name)
			assert.Contains(t, header.Get("Content-Disposition"), dispositionInline, name)
		}
	})
}

// Перечень типов содержимого сверяется с перечнем известных форматов
// механически: формат, объявленный диалогу выбора файла и оставшийся без типа,
// уехал бы ответом `application/octet-stream` — то есть сервис предлагал бы
// загрузить то, что потом не умеет показать.
func TestEveryKnownFormatHasContentType(t *testing.T) {
	formats := metrics.PublicFormats()
	require.NotEmpty(t, formats, "перечень форматов пуст: правилу не на чем сработать")

	for _, format := range formats {
		contentType, disposition := presentationOf(format)
		assert.NotEqual(t, unknownContentType, contentType,
			"формат %q сервис объявляет диалогу выбора файла, но типа содержимого у него нет", format)
		assert.Equal(t, dispositionInline, disposition, format)
	}

	for format := range contentTypes {
		assert.Equal(t, format, metrics.FormatLabel(format),
			"тип содержимого заведён формату %q, которого нет среди известных: ключ никогда не совпадёт", format)
	}
}

// Паника обработчика отдаёт `500` нашей формой тела, а процесс живёт дальше.
//
// Слой восстановления — верхняя граница поверхности, и проверяется он через
// **всю** цепочку: паника ловится снаружи журнала и маршрутизатора, поэтому
// собранная иначе поверхность судила бы не то. Без него один паникующий запрос
// уронил бы процесс вместе с конвейером и всеми, кто в это время что-то грузил.
func TestPanickingHandlerAnswersOurFailureFormAndProcessLives(t *testing.T) {
	db, _, _ := newTestStorage(t)
	users := sqliterepo.NewUserRepository(db)

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

	panicking := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
		panic("шаг обработчика упал")
	})
	mounts := ServiceMounts(
		AppChain(panicking, users, testTrustedNetworks(t), nil, logger),
		http.NotFoundHandler(),
	)
	mux := BuildHandler(mounts, NewWebappHandler(builtDist(), true, logger), logger)

	account, _, err := users.EnsureUser(contract.Identity{Login: "person"})
	require.NoError(t, err)
	require.NotEmpty(t, account.ID)

	w := httptest.NewRecorder()
	mux.ServeHTTP(w, asUser(httptest.NewRequest(http.MethodGet, "/app/me", http.NoBody), "person"))

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

	var body ErrorBody
	require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body), "тело отказа — не наша форма")
	assert.Equal(t, CodeInternal, body.Code)
	assert.NotEmpty(t, body.Message)
	assert.NotContains(t, w.Body.String(), "шаг обработчика упал",
		"значение паники ушло спрашивающему")

	assert.Contains(t, journal.String(), "Handler panicked",
		"владелец сервиса о панике не узнал")

	// Процесс жив: следующий запрос отвечает как ни в чём не бывало.
	alive := httptest.NewRecorder()
	mux.ServeHTTP(alive, httptest.NewRequest(http.MethodGet, HealthPath, http.NoBody))
	assert.Equal(t, http.StatusOK, alive.Code, "после паники поверхность перестала отвечать")
}

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

	record := jobWithFile(t, env)

	texts := env.handler.textRepo
	literary, err := texts.Put(record.Id, entity.TextKindLiterary, "вычитанный текст")
	require.NoError(t, err)
	transcript, err := texts.Put(record.Id, entity.TextKindTranscript, "сырая расшифровка")
	require.NoError(t, err)

	structures := env.handler.structureRepo
	structure, err := structures.Put(record.Id, 1, []entity.Replica{{StartMs: 0, EndMs: 10, Text: "реплика"}})
	require.NoError(t, err)

	record.LiteraryTextID = &literary.Id
	record.TranscriptTextID = &transcript.Id
	record.StructureID = &structure.Id
	require.NoError(t, env.handler.recordRepo.Save(record, ""))

	w := httptest.NewRecorder()
	env.serve(w, httptest.NewRequest("GET", "/app/audiorecords/"+record.Id, http.NoBody))
	require.Equal(t, http.StatusOK, w.Code)

	var card RecordView
	require.NoError(t, json.Unmarshal(w.Body.Bytes(), &card))

	require.NotNil(t, card.AvailableViews, "поле обязано быть на проводе")
	assert.ElementsMatch(t,
		[]string{entity.TextViewTranscript, entity.TextViewLiterary, entity.TextViewReplicas},
		*card.AvailableViews,
		"каждый готовый вид назван перечнем")

	// И каждый названный вид действительно отдаётся своим адресом.
	for _, view := range *card.AvailableViews {
		got := textOf(t, env, record.Id, view)
		assert.Equal(t, http.StatusOK, got.Code, "вид %q обещан перечнем и обязан отдаваться", view)
	}
}

// errTooLargeForTest — отказ по превышению потолка, каким его строит приём.
func errTooLargeForTest() error {
	return contract.ErrRecordTooLarge
}

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

	req := createMultipartRequest(t, "sample.mp3", []byte("данные"))
	req.ContentLength = entity.MaxRecordSize + 1

	w := httptest.NewRecorder()
	env.serve(w, req)

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

	var body ErrorBody
	require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
	assert.Equal(t, CodeTooLarge, body.Code, "код машиночитаем")
	require.NotNil(t, body.Limit, "предел уходит человеку числом")
	assert.Equal(t, entity.MaxRecordSize, *body.Limit)
	assert.Equal(t, 0, countJobs(t, env), "записи не заводится")
}

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

	var last *httptest.ResponseRecorder
	for range appRateMaxRequests + 1 {
		last = httptest.NewRecorder()
		env.serve(last, httptest.NewRequest("GET", "/app/me", http.NoBody))
		if last.Code == http.StatusTooManyRequests {
			break
		}
	}

	require.Equal(t, http.StatusTooManyRequests, last.Code, "ограничитель сработал")

	var body ErrorBody
	require.NoError(t, json.Unmarshal(last.Body.Bytes(), &body))
	assert.Equal(t, CodeTooManyRequests, body.Code)
	assert.NotEmpty(t, body.Message)
}

// Объявленная частота опроса умещается в бюджет ограничителя с запасом: прежде
// она равнялась всему бюджету, и любой соседний запрос в ту же секунду выводил
// приложение за потолок — отказ, которого сервис сам же обещал избежать.
func TestPollIntervalLeavesBudgetHeadroom(t *testing.T) {
	pollsPerWindow := int64(appRateWindowSec) * 1000 / PollIntervalMs

	assert.Less(t, pollsPerWindow, int64(appRateMaxRequests),
		"опрос с объявленной частотой не выбирает бюджет целиком")
	assert.Positive(t, pollsPerWindow, "и при этом опрашивать вообще можно")
}

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

	w := httptest.NewRecorder()
	env.serve(w, createMultipartRequest(t, "x."+strings.Repeat("a", 400), []byte("данные")))

	require.Equal(t, http.StatusCreated, w.Code,
		"запись принята: абсурдное расширение заменено собственным умолчанием")
	assert.NotContains(t, env.journal.String(), "file name too long")
}

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

	cases := []struct {
		name   string
		method string
		path   string
	}{
		{name: "неизвестный путь", method: "GET", path: "/app/nosuchendpoint"},
		{name: "неверный метод у списка", method: "DELETE", path: "/app/audiorecords"},
		{name: "неверный метод у пределов", method: "POST", path: "/app/config"},
	}

	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			w := httptest.NewRecorder()
			env.serve(w, httptest.NewRequest(tc.method, tc.path, http.NoBody))

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

			var body ErrorBody
			require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
			assert.Equal(t, CodeNotFound, body.Code, "код машиночитаем, а не тело библиотеки")
			assert.NotEmpty(t, body.Message)
		})
	}
}

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

	record := jobWithFile(t, env)

	texts := env.handler.textRepo
	empty, err := texts.Put(record.Id, entity.TextKindTranscript, "")
	require.NoError(t, err)

	record.TranscriptTextID = &empty.Id
	record.MoveToState(entity.StateDone)
	require.NoError(t, env.handler.recordRepo.Save(record, ""))

	w := httptest.NewRecorder()
	env.serve(w, httptest.NewRequest("GET", "/app/audiorecords/"+record.Id, http.NoBody))
	require.Equal(t, http.StatusOK, w.Code)

	var card RecordView
	require.NoError(t, json.Unmarshal(w.Body.Bytes(), &card))

	require.NotNil(t, card.AvailableViews)
	assert.Empty(t, *card.AvailableViews,
		"ссылка есть, содержимого нет — вид доступным не считается")

	// И адрес текста отвечает тем же: состоянием, а не обещанием.
	assert.Equal(t, http.StatusConflict, textOf(t, env, record.Id, entity.TextViewTranscript).Code)
}

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

	// Перечень кодов закрыт: значение вне его приложению разбирать нечем.
	known := map[string]bool{
		CodeUnauthorized: true, CodeNotFound: true, CodeBadRequest: true,
		CodeTooLarge: true, CodeNotReady: true, CodeTooManyRequests: true,
		CodeInternal: true,
	}

	assertOurForm := func(t *testing.T, status int, body []byte) {
		t.Helper()

		var raw map[string]any
		require.NoError(t, json.Unmarshal(body, &raw), "тело не наше: %s", body)
		require.Contains(t, raw, "error_code")
		require.Contains(t, raw, "message")

		code, ok := raw["error_code"].(string)
		require.True(t, ok)
		assert.True(t, known[code], "код отказа %q вне закрытого перечня", code)
		assert.NotEmpty(t, raw["message"])
		assert.NotEqual(t, http.StatusOK, status)
	}

	t.Run("предел тела", func(t *testing.T) {
		// Запрос идёт **сырым соединением**: клиент стандартной библиотеки
		// отказывается слать объявленную длину, которой не соответствует тело, а
		// прислать восемь гигабайт на самом деле проверка не может. Сервер при
		// этом видит обычный запрос: заголовки разобраны, длина объявлена, тела
		// он не читает вовсе — отказ наступает раньше.
		status, body := rawRequest(t, server, ""+
			"POST /app/audiorecords HTTP/1.1\r\n"+
			"Host: transcriber.test\r\n"+
			LoginHeader+": "+env.login+"\r\n"+
			"Content-Type: multipart/form-data; boundary=x\r\n"+
			"Content-Length: "+strconv.FormatInt(entity.MaxRecordSize+1, 10)+"\r\n"+
			"Connection: close\r\n\r\n")

		require.Equal(t, http.StatusRequestEntityTooLarge, status)
		assertOurForm(t, status, body)
		assert.Contains(t, string(body), `"limit"`, "предел уходит человеку числом")
		assert.Equal(t, 0, countJobs(t, env), "записи не заводится")
	})

	t.Run("неизвестный путь под корнем приложения", func(t *testing.T) {
		res := env.liveRequest(t, server, "/app/nosuchendpoint", nil)

		require.Equal(t, http.StatusNotFound, res.StatusCode)
		assertOurForm(t, res.StatusCode, res.Body)
	})

	t.Run("ограничитель частоты", func(t *testing.T) {
		var last liveResponse
		for range appRateMaxRequests + 5 {
			last = env.liveRequest(t, server, "/app/me", nil)
			if last.StatusCode == http.StatusTooManyRequests {
				break
			}
		}

		require.Equal(t, http.StatusTooManyRequests, last.StatusCode,
			"ограничитель не сработал — проверке не на чем сработать")
		assertOurForm(t, last.StatusCode, last.Body)
	})
}

// rawRequest шлёт запрос сырым соединением и отдаёт код с телом ответа.
//
// Нужен там, где клиент стандартной библиотеки запрос не отправит: он судит
// соответствие объявленной длины телу, а проверке нужна ровно объявленная.
func rawRequest(t *testing.T, server *httptest.Server, request string) (int, []byte) {
	t.Helper()

	address := strings.TrimPrefix(server.URL, "http://")

	dialer := &net.Dialer{Timeout: 5 * time.Second}
	conn, err := dialer.DialContext(context.Background(), "tcp", address)
	require.NoError(t, err)
	defer func() { require.NoError(t, conn.Close()) }()

	require.NoError(t, conn.SetDeadline(time.Now().Add(5*time.Second)))

	_, err = conn.Write([]byte(request))
	require.NoError(t, err)

	res, err := http.ReadResponse(bufio.NewReader(conn), nil)
	require.NoError(t, err)
	defer func() { require.NoError(t, res.Body.Close()) }()

	body, err := io.ReadAll(res.Body)
	require.NoError(t, err)

	return res.StatusCode, body
}
