package http

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"io/fs"
	"log/slog"
	"mime/multipart"
	"net/http"
	"net/http/httptest"
	"net/netip"
	"regexp"
	"strings"
	"sync"
	"testing"
	"testing/fstest"
	"time"

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

	"git.vakhrushev.me/av/transcriber/internal/adapter/recognizer"
	sqliterepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/sqlite"
	"git.vakhrushev.me/av/transcriber/internal/clock"
	"git.vakhrushev.me/av/transcriber/internal/contract"
	"git.vakhrushev.me/av/transcriber/internal/entity"
	"git.vakhrushev.me/av/transcriber/internal/ident"
	"git.vakhrushev.me/av/transcriber/internal/service"
)

// Подставные адаптеры вместо ffprobe и ffmpeg. Проверки судят приём — что
// запись сохранена, задача заведена и ответ такой, какой обещан, — а не
// способность внешней программы разобрать звук. Внешних программ здесь нет
// ни одной, и видно это по списку импортов.

// stubMetaViewer отдаёт заданную длительность либо заданную ошибку.
type stubMetaViewer struct {
	seconds int
	err     error
}

func (m *stubMetaViewer) GetInfo(ctx context.Context, _ string) (*contract.AudioInfo, error) {
	// Настоящий `ffprobe` заведён с контекстом и по отмене умирает; стаб,
	// который контекст игнорирует, сделал бы проверку приёма неспособной упасть.
	if err := ctx.Err(); err != nil {
		return nil, err
	}
	if m.err != nil {
		return nil, m.err
	}
	return &contract.AudioInfo{Seconds: m.seconds}, nil
}

// stubConverter молчалив: приём конвертацию не делает, и ни одна проверка
// этого файла её не зовёт.
type stubConverter struct{}

func (c *stubConverter) Convert(context.Context, string, string) error { return nil }

// readableMetaViewer — источник метаданных, который читает любую запись.
func readableMetaViewer() *stubMetaViewer {
	return &stubMetaViewer{seconds: 42}
}

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("значок")},
	}
}

// testEnv — собранное окружение одной проверки. Каталог данных свой у каждой:
// рабочий каталог процесса проверки не трогают.
type testEnv struct {
	mux     http.Handler
	handler *AppHandler
	db      *sqliterepo.DB
	store   *sqliterepo.Store
	users   *sqliterepo.UserRepository
	files   *sqliterepo.FileRepository
	journal *journalBuffer
	// login — логин у провайдера, которым доверенный источник называет
	// пришедшего. Адреса приложения закрыты узнаванием, и проверка, судящая их
	// по существу, обязана назваться ровно так же, как это делает прокси.
	login string
	// account — учётная запись, которой принадлежит этот логин.
	account *contract.UserAccount
}

// Доверенный источник проверок.
//
// Подсеть и адрес пира разведены намеренно: проверка недоверенного источника
// берёт адрес **вне** подсети, и обе стороны условия видны рядом.
const (
	trustedPeer   = "10.1.2.3:34567"
	untrustedPeer = "203.0.113.9:34567"
)

func testTrustedNetworks(t *testing.T) []netip.Prefix {
	t.Helper()

	// Подсетей две: своя для запросов через `httptest.NewRecorder` и петлевая
	// для тех проверок, которым нужен **настоящий** HTTP-запрос через поднятую
	// цепочку слоёв — у них адрес пира ставит ядро, и он петлевой.
	networks := make([]netip.Prefix, 0, 2)
	for _, raw := range []string{"10.0.0.0/8", "127.0.0.0/8"} {
		prefix, err := netip.ParsePrefix(raw)
		require.NoError(t, err)
		networks = append(networks, prefix)
	}

	return networks
}

// liveServer поднимает настоящий HTTP-сервер на этой поверхности.
//
// Нужен там, где проверка судит поведение, которого у вызова обработчика
// напрямую не бывает: разбор заголовка диапазона идёт по настоящему запросу, а
// адрес пира ставит ядро.
func (e *testEnv) liveServer(t *testing.T) *httptest.Server {
	t.Helper()

	server := httptest.NewServer(e.mux)
	t.Cleanup(server.Close)

	return server
}

// liveResponse — готовый ответ настоящего запроса: тело прочитано и закрыто, и
// проверке остаётся судить значения.
type liveResponse struct {
	StatusCode    int
	Header        http.Header
	ContentLength int64
	Body          []byte
}

// liveRequest шлёт настоящий запрос от имени учётной записи проверки.
func (e *testEnv) liveRequest(
	t *testing.T, server *httptest.Server, path string, headers map[string]string,
) liveResponse {
	t.Helper()

	req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, server.URL+path, nil)
	require.NoError(t, err)
	req.Header.Set(LoginHeader, e.login)
	for name, value := range headers {
		req.Header.Set(name, value)
	}

	res, err := server.Client().Do(req)
	require.NoError(t, err)
	defer func() { require.NoError(t, res.Body.Close()) }()

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

	return liveResponse{
		StatusCode:    res.StatusCode,
		Header:        res.Header,
		ContentLength: res.ContentLength,
		Body:          body,
	}
}

// asUser делает запрос запросом названного человека: ставит заголовок и адрес
// пира из доверенной подсети — ровно то, что делает обратный прокси.
func asUser(req *http.Request, login string) *http.Request {
	req.Header.Set(LoginHeader, login)
	req.RemoteAddr = trustedPeer
	return req
}

// serve шлёт запрос от имени узнанного. Проверки, судящие отказ неузнанному,
// зовут `mux` напрямую: запрос без заголовка и есть запрос неузнанного.
func (e *testEnv) serve(w http.ResponseWriter, req *http.Request) {
	e.mux.ServeHTTP(w, asUser(req, e.login))
}

// get шлёт запрос **неузнанным**: заголовка на нём нет вовсе.
func (e *testEnv) get(path string) *httptest.ResponseRecorder {
	w := httptest.NewRecorder()
	e.mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
	return w
}

// getAs шлёт запрос от имени названного логина.
func (e *testEnv) getAs(login, path string) *httptest.ResponseRecorder {
	w := httptest.NewRecorder()
	e.mux.ServeHTTP(w, asUser(httptest.NewRequest(http.MethodGet, path, nil), login))
	return w
}

// getOwn шлёт запрос от имени учётной записи проверки.
func (e *testEnv) getOwn(path string) *httptest.ResponseRecorder {
	return e.getAs(e.login, path)
}

// journalBuffer — перехваченный журнал одной проверки. Свой на случай: общий на
// пакет сделал бы исход функцией от соседних случаев — «поля на месте» прошло бы
// на чужой строке, а «маркера нет» покраснело бы от чужой. Замок нужен потому,
// что пишущих в него потоков два: логгер сервиса и логгер обработчика.
type journalBuffer struct {
	mu   sync.Mutex
	text strings.Builder
}

func (b *journalBuffer) Write(p []byte) (int, error) {
	b.mu.Lock()
	defer b.mu.Unlock()
	return b.text.Write(p)
}

// String отдаёт весь перехваченный текст. Проверки ищут в нём значение, а не имя
// поля: имя, вернувшееся под другим ключом, поиск по ключу не разбудил бы.
func (b *journalBuffer) String() string {
	b.mu.Lock()
	defer b.mu.Unlock()
	return b.text.String()
}

// newTestStorage поднимает базу на пустом каталоге и накатывает схему — ровно
// тем же путём, каким это делает сервис при старте.
func newTestStorage(t *testing.T) (*sqliterepo.DB, *sqliterepo.Store, string) {
	t.Helper()

	dir := t.TempDir()
	db, err := sqliterepo.Open(dir, sqliterepo.Settings{BusyTimeoutMs: 5000, ReadConnections: 4})
	require.NoError(t, err)
	t.Cleanup(func() {
		if err := db.Close(); err != nil {
			t.Logf("не удалось закрыть базу: %v", err)
		}
	})

	require.NoError(t, sqliterepo.Migrate(
		context.Background(), db, dir, slog.New(slog.DiscardHandler),
	))

	return db, sqliterepo.NewStore(dir), dir
}

// envOptions — чем одна проверка отличается от другой.
type envOptions struct {
	metaviewer contract.AudioMetaViewer
	dist       fs.FS
	built      bool
	// substitution — имитация заголовков входа отладочного запуска. Пустая
	// значит «предохранитель выключен»: слой подстановки в цепочку не встаёт,
	// и поверхность собирается ровно та же, что в бою.
	substitution map[string]string
}

func setupTestEnv(t *testing.T, metaviewer contract.AudioMetaViewer) *testEnv {
	return setupEnv(t, envOptions{metaviewer: metaviewer, dist: builtDist(), built: true})
}

// setupEnv собирает поверхность сервиса тем же способом, что и точка входа:
// адреса порождает перечень адресного пространства, слои одевает та же цепочка.
// Собери проверка поверхность иначе — и судила бы она не то.
func setupEnv(t *testing.T, opts envOptions) *testEnv {
	t.Helper()

	db, store, _ := newTestStorage(t)

	recordRepo := sqliterepo.NewAudioRecordRepository(db)
	textRepo := sqliterepo.NewTextRepository(db)
	structureRepo := sqliterepo.NewStructureRepository(db)
	fileRepo := sqliterepo.NewFileRepository(db, store)
	users := sqliterepo.NewUserRepository(db)

	repos := service.Repositories{
		Records:      recordRepo,
		Files:        fileRepo,
		Texts:        textRepo,
		Structures:   structureRepo,
		Recognitions: sqliterepo.NewRecognitionRepository(db, store),
		Events:       sqliterepo.NewRecordEventRepository(db),
	}

	// Журнал уходит в буфер, а не в никуда: по нему судит проверка запрета на
	// имя отправителя. Вывод прогона от этого не меняется — ERROR-строки ветки
	// отказа по-прежнему не попадают на экран.
	journal := &journalBuffer{}
	logger := slog.New(slog.NewTextHandler(journal, nil))

	metaviewer := opts.metaviewer
	if metaviewer == nil {
		metaviewer = readableMetaViewer()
	}

	trsService := service.NewTranscribeService(
		repos,
		metaviewer,
		&stubConverter{},
		&recognizer.MemoryAudioRecognizer{},
		entity.StuckLimits{Own: time.Hour, Foreign: 24 * time.Hour},
		logger,
	)

	handler := NewAppHandler(recordRepo, textRepo, structureRepo, fileRepo, trsService, logger)

	mounts := ServiceMounts(
		AppChain(handler.Routes(), users, testTrustedNetworks(t), opts.substitution, logger),
		http.NotFoundHandler(),
	)
	webapp := NewWebappHandler(opts.dist, opts.built, logger)
	mux := BuildHandler(mounts, webapp, logger)

	account, _, err := users.EnsureUser(contract.Identity{
		Login: "person",
		Name:  "Человек",
		Email: "person@example.com",
	})
	require.NoError(t, err)

	return &testEnv{
		mux:     mux,
		handler: handler,
		db:      db,
		store:   store,
		users:   users,
		files:   fileRepo,
		journal: journal,
		login:   "person",
		account: account,
	}
}

// createMultipartRequest собирает запрос из имени и содержимого. Файла на диске
// для этого не нужно: имя проверяет выбор расширения, содержимое — сохранение.
func createMultipartRequest(t *testing.T, fileName string, content []byte) *http.Request {
	return createMultipartRequestWithField(t, "audio", fileName, content)
}

// createMultipartRequestWithField кладёт запись в поле с заданным именем —
// нужно, чтобы построить форму без поля `audio`.
func createMultipartRequestWithField(t *testing.T, field, fileName string, content []byte) *http.Request {
	return createMultipartRequestAtWithField(t, "/app/audiorecords", field, fileName, content)
}

// createMultipartRequestAt собирает тот же запрос по названному адресу — нужно
// проверке, судящей убранные адреса.
func createMultipartRequestAt(t *testing.T, path, fileName string, content []byte) *http.Request {
	return createMultipartRequestAtWithField(t, path, "audio", fileName, content)
}

func createMultipartRequestAtWithField(t *testing.T, path, field, fileName string, content []byte) *http.Request {
	var buf bytes.Buffer
	writer := multipart.NewWriter(&buf)

	part, err := writer.CreateFormFile(field, fileName)
	require.NoError(t, err)

	_, err = part.Write(content)
	require.NoError(t, err)

	err = writer.Close()
	require.NoError(t, err)

	req := httptest.NewRequest("POST", path, &buf)
	req.Header.Set("Content-Type", writer.FormDataContentType())

	return req
}

// intakeItemOf разбирает ответ приёма и отдаёт единственный его элемент. Ответ
// списком всегда, даже на один файл: форма согласована один раз и вперёд.
func intakeItemOf(t *testing.T, w *httptest.ResponseRecorder) IntakeItem {
	t.Helper()

	var items []IntakeItem
	require.NoError(t, json.Unmarshal(w.Body.Bytes(), &items))
	require.Len(t, items, 1)
	return items[0]
}

// storedFileNames отдаёт имена, под которыми копии легли в каталог данных.
func storedFileNames(t *testing.T, env *testEnv) []string {
	t.Helper()

	rows, err := env.db.Reader().QueryContext(context.Background(), "SELECT file_name FROM files")
	require.NoError(t, err)
	defer func() { require.NoError(t, rows.Close()) }()

	var names []string
	for rows.Next() {
		var name string
		require.NoError(t, rows.Scan(&name))
		names = append(names, name)
	}
	require.NoError(t, rows.Err())
	return names
}

// countRows считает строки названной таблицы.
func countRows(t *testing.T, env *testEnv, table string) int {
	t.Helper()

	var total int
	require.NoError(t, env.db.Reader().QueryRowContext(context.Background(), "SELECT COUNT(*) FROM "+table).Scan(&total))
	return total
}

// countFiles считает строки о копиях записей.
func countFiles(t *testing.T, env *testEnv) int {
	return countRows(t, env, "files")
}

// countJobs считает заведённые аудиозаписи.
func countJobs(t *testing.T, env *testEnv) int {
	return countRows(t, env, "audio_records")
}

// jobWithFile заводит запись вместе с её принятой копией: без ссылки на файл
// запись не прошла бы ни одного шага.
func jobWithFile(t *testing.T, env *testEnv) *entity.AudioRecord {
	t.Helper()

	return jobWithFileOf(t, env, env.account.ID)
}

// jobWithFileOf — то же, но от имени названного владельца.
func jobWithFileOf(t *testing.T, env *testEnv, ownerID string) *entity.AudioRecord {
	t.Helper()

	recordID := ident.New()

	work, err := env.files.Stage(".mp3", strings.NewReader("запись"))
	require.NoError(t, err)
	defer func() { require.NoError(t, work.Close()) }()

	// Владелец — учётная запись проверки: ничьей записи в хранилище не бывает, и
	// фикстура без владельца описывала бы состояние, которого в проде нет.
	file, err := env.files.Create(
		recordID, "sample.mp3", work, contract.FileMeta{Format: "mp3"}, ownerID,
	)
	require.NoError(t, err)

	record := &entity.AudioRecord{
		Id:             recordID,
		State:          entity.StateUploaded,
		StateEnteredAt: clock.Now(),
		OwnerID:        ownerID,
		OriginalFileID: &file.Id,
	}
	require.NoError(t, env.handler.recordRepo.Create(record))
	return record
}

// newTopic заводит тему в словаре названного владельца.
//
// Запросом к базе, а не адресом сервиса: адреса, которым заводят тему, у сервиса
// нет — словарь пишет задача языковой модели, которой ещё нет. Проверка обязана
// пройти тот же путь разрешения тем, каким пойдёт она.
func newTopic(t *testing.T, env *testEnv, ownerID, name string) string {
	t.Helper()

	id := ident.New()
	now := clock.Now().UTC().Format("2006-01-02T15:04:05Z")
	_, err := env.db.Writer().ExecContext(context.Background(),
		"INSERT INTO topics (id, owner_id, name, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
		id, ownerID, name, now, now,
	)
	require.NoError(t, err)

	return id
}

// attachTopic связывает тему с записью.
func attachTopic(t *testing.T, env *testEnv, recordID, topicID string) {
	t.Helper()

	_, err := env.db.Writer().ExecContext(context.Background(),
		"INSERT INTO record_topics (record_id, topic_id) VALUES (?, ?)", recordID, topicID,
	)
	require.NoError(t, err)
}

// storedContent читает содержимое копии из каталога данных.
func storedContent(t *testing.T, env *testEnv, fileID string) []byte {
	t.Helper()

	reader, err := env.files.Open(fileID)
	require.NoError(t, err)
	defer func() { require.NoError(t, reader.Close()) }()

	var buf bytes.Buffer
	_, err = buf.ReadFrom(reader)
	require.NoError(t, err)
	return buf.Bytes()
}

func TestCreateTranscribeJob_Success(t *testing.T) {
	env := setupTestEnv(t, readableMetaViewer())

	content := []byte("содержимое записи, которое обязано доехать до диска целиком")
	req := createMultipartRequest(t, "sample.m4a", content)

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

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

	// Имена полей ответа нормативны: контракт объявлен необратимым, а экраны
	// строятся на этих именах. Судим по сырому JSON — разбор в структуру
	// переименовал бы тег вместе с ожиданием, и проверка не смогла бы упасть.
	//
	// Ответ — **список**, даже когда файл в запросе один: форма согласована
	// вперёд, чтобы приём нескольких файлов и распознавание повтора её не
	// переписывали.
	var rawItems []map[string]json.RawMessage
	err := json.Unmarshal(w.Body.Bytes(), &rawItems)
	require.NoError(t, err)
	require.Len(t, rawItems, 1)
	assert.Contains(t, rawItems[0], "id")
	assert.Contains(t, rawItems[0], "state")
	assert.Contains(t, rawItems[0], "duplicate", "место под признак повтора заведено вперёд")
	assert.NotContains(t, rawItems[0], "job_id", "прежние имена полей убраны вместе с опросом")

	response := intakeItemOf(t, w)

	assert.NotEmpty(t, response.ID)
	assert.Equal(t, entity.StateUploaded, response.State)

	// Задача действительно заведена, а не только названа в ответе: иначе
	// отправитель получит идентификатор записи, которой не будет никогда.
	require.Equal(t, 1, countJobs(t, env))

	job, err := env.handler.recordRepo.GetByID(response.ID, env.account.ID)
	require.NoError(t, err)
	assert.Equal(t, entity.StateUploaded, job.State)
	require.NotNil(t, job.OriginalFileID)
	assert.NotEmpty(t, *job.OriginalFileID)

	// Содержимое лежит в хранилище одним файлом и целиком.
	require.Equal(t, 1, countFiles(t, env))
	assert.Equal(t, content, storedContent(t, env, *job.OriginalFileID))
}

func TestCreateTranscribeJob_NoFile(t *testing.T) {
	// Две ветки одного сценария: тела нет вовсе и форма есть, а поля в ней нет.
	// Вторая — та, что описана требованием; первая ходит тем же путём.
	testCases := []struct {
		name string
		req  func(t *testing.T) *http.Request
	}{
		{
			name: "no body at all",
			req: func(t *testing.T) *http.Request {
				// Запрос строится так, как его видит сервер: у пришедшего по
				// проводу тело не бывает пустым указателем.
				return httptest.NewRequest("POST", "/app/audiorecords", http.NoBody)
			},
		},
		{
			name: "form without audio field",
			req: func(t *testing.T) *http.Request {
				return createMultipartRequestWithField(t, "attachment", "sample.m4a", []byte("запись"))
			},
		},
	}

	for _, tc := range testCases {
		t.Run(tc.name, func(t *testing.T) {
			env := setupTestEnv(t, readableMetaViewer())

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

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

			var response ErrorBody
			err := json.Unmarshal(w.Body.Bytes(), &response)
			require.NoError(t, err)

			assert.Equal(t, CodeBadRequest, response.Code)
			assert.NotEmpty(t, response.Message, "рядом с кодом стоит фраза для человека")
			assert.Equal(t, 0, countFiles(t, env))
			assert.Equal(t, 0, countJobs(t, env))
		})
	}
}

func TestCreateTranscribeJob_EmptyFile(t *testing.T) {
	env := setupTestEnv(t, readableMetaViewer())

	// Собственного порога по размеру у приёма нет: годность записи судит
	// источник метаданных, а не приём.
	req := createMultipartRequest(t, "empty.m4a", nil)

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

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

	response := intakeItemOf(t, w)

	assert.NotEmpty(t, response.ID)
	assert.Equal(t, entity.StateUploaded, response.State)
}

func TestCreateTranscribeJob_DifferentFileExtensions(t *testing.T) {
	testCases := []struct {
		name      string
		fileName  string
		expectExt string
	}{
		{
			name:      "m4a file",
			fileName:  "test.m4a",
			expectExt: ".m4a",
		},
		{
			name:      "mp3 file",
			fileName:  "test.mp3",
			expectExt: ".mp3",
		},
		{
			name:      "wav file",
			fileName:  "test.wav",
			expectExt: ".wav",
		},
		{
			name:      "file without extension",
			fileName:  "test",
			expectExt: ".audio",
		},
	}

	for _, tc := range testCases {
		t.Run(tc.name, func(t *testing.T) {
			env := setupTestEnv(t, readableMetaViewer())

			req := createMultipartRequest(t, tc.fileName, []byte("запись"))

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

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

			names := storedFileNames(t, env)
			require.Len(t, names, 1)

			// Имя отправителя в хранилище не попадает: имя файла — свой
			// идентификатор, от отправителя взято только расширение. Суффикс
			// дописывает само хранилище, поэтому сверяем хвост, а не Ext.
			assert.True(t, strings.HasSuffix(names[0], tc.expectExt),
				"имя в хранилище %q оканчивается на %q", names[0], tc.expectExt)
			assert.NotContains(t, names[0], strings.TrimSuffix(tc.fileName, tc.expectExt))
		})
	}
}

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

	req := createMultipartRequest(t, "секретное-слово.mp3", []byte("запись"))

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

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

	names := storedFileNames(t, env)
	require.Len(t, names, 1)

	assert.NotContains(t, names[0], "секретное-слово",
		"имя, данное отправителем, в имя файла не попадает")
	assert.True(t, strings.HasSuffix(names[0], ".mp3"),
		"расширение при этом сохраняется: %q", names[0])

	// И в **путь** к файлу оно не попадает тоже: подкаталог назван
	// идентификатором записи, а не именем отправителя.
	var recordID, storedRecord string
	require.NoError(t, env.db.Reader().
		QueryRow("SELECT id FROM audio_records").Scan(&recordID))
	require.NoError(t, env.db.Reader().
		QueryRow("SELECT record_id FROM files").Scan(&storedRecord))
	assert.Equal(t, recordID, storedRecord, "подкаталог копии назван не записью")
	assert.NotContains(t, storedRecord, "секретное-слово",
		"имя, данное отправителем, попало в путь к файлу")
}

func TestCreateTranscribeJob_MetaViewerFailure(t *testing.T) {
	env := setupTestEnv(t, &stubMetaViewer{err: errors.New("не удалось прочитать запись")})

	req := createMultipartRequest(t, "broken.m4a", []byte("не запись вовсе"))

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

	// Негодная запись — отказ по причине, а не по месту: прежде здесь стоял
	// `500`, и «файл не читается» приходило человеку как «сломался сервер».
	require.Equal(t, http.StatusBadRequest, w.Code)

	var response ErrorBody
	err := json.Unmarshal(w.Body.Bytes(), &response)
	require.NoError(t, err)

	// Причина отказа принадлежит журналу, а не отправителю.
	assert.Equal(t, CodeBadRequest, response.Code)
	assert.NotEmpty(t, response.Message)
	assert.NotContains(t, w.Body.String(), "не удалось прочитать запись")
	assert.NotContains(t, w.Body.String(), "transcibe", "опечатка ушла вместе с прежним текстом")

	assert.Equal(t, 0, countJobs(t, env))
}

// senderNameMarker — метка внутри имени, которое даёт отправитель. ASCII и
// заведомо уникальна: в остальном выводе прогона такой строки нет, поэтому
// находка означает утечку, а не совпадение. Ищется она **значением**, а не
// именем журнального поля: имя, вернувшееся под другим ключом, поиск по ключу
// пропустил бы.
const senderNameMarker = "SENDERNAMELEAKMARKER7Q2"

// Тексты, по которым проверки находят журнальные строки. Первый — записанный
// долг `docs/conventions/logging.md`: `msg` обязан стать короткой категорией.
// Когда долг закроют, правка будет здесь и одна.
const (
	msgIntake = "Creating audio record"
	// Отказ пишет доменная граница — приём, — а не транспорт: конвенция просит
	// логировать ошибку один раз, и повторная запись транспорта снята.
	msgIntakeErr = "Failed to get file info"
)

func TestCreateTranscribeJob_SenderFileNameNotLogged(t *testing.T) {
	env := setupTestEnv(t, readableMetaViewer())

	// Метка стоит в основе имени, а расширение обычное: расширение запретом не
	// накрыто и в журнале остаётся законно.
	req := createMultipartRequest(t, senderNameMarker+".mp3", []byte("запись"))

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

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

	journal := env.journal.String()

	// Сперва — что журнал приёма вообще перехвачен. Без этого утверждения
	// пустой буфер сделал бы проверку запрета зелёной, ничего не прочитав.
	require.Contains(t, journal, msgIntake,
		"строка приёма попадает в перехваченный журнал")

	assert.NotContains(t, journal, senderNameMarker,
		"имя, данное отправителем, не пишется в журнал: инвариант приватности")
}

func TestCreateTranscribeJob_SenderFileNameNotLoggedOnFailure(t *testing.T) {
	// Отказ — тот путь, где имя приехало бы в журнал текстом ошибки: приём
	// назван конвенцией логирующей границей, и цепочка `%w` осядет полем error.
	env := setupTestEnv(t, &stubMetaViewer{err: errors.New("не удалось прочитать запись")})

	req := createMultipartRequest(t, senderNameMarker+".mp3", []byte("не запись вовсе"))

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

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

	journal := env.journal.String()

	// Сперва — что журнал ветки отказа вообще перехвачен: обработчик пишет свою
	// строку, и без неё оракул молча сузился бы вдвое.
	require.Contains(t, journal, msgIntakeErr,
		"строка приёма об отказе попадает в перехваченный журнал")

	assert.NotContains(t, journal, senderNameMarker,
		"имя отправителя не пишется в журнал и на пути отказа")
}

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

	req := createMultipartRequest(t, "sample.mp3", []byte("запись"))

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

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

	names := storedFileNames(t, env)
	require.Len(t, names, 1)

	journal := env.journal.String()
	require.Contains(t, journal, msgIntake, "журнал приёма перехвачен")

	assert.NotContains(t, journal, names[0],
		"имени файла в хранилище в журнале нет: по нему собирается ссылка на скачивание")
}

func TestCreateTranscribeJob_JournalTracesRecord(t *testing.T) {
	env := setupTestEnv(t, readableMetaViewer())

	content := []byte("содержимое записи")
	req := createMultipartRequest(t, "sample.mp3", content)

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

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

	response := intakeItemOf(t, w)

	job, err := env.handler.recordRepo.GetByID(response.ID, env.account.ID)
	require.NoError(t, err)
	require.NotNil(t, job.OriginalFileID)

	// Отбор по идентификатору **этого** прогона: иначе утверждение прошло бы по
	// строке, оставленной соседней проверкой, и прослеживаемость числилась бы
	// сохранённой при пустом журнале.
	journal := env.journal.String()
	assert.Contains(t, journal, *job.OriginalFileID, "по журналу видно, какой файл заведён")
	assert.Contains(t, journal, ".mp3", "расширение принятой записи в журнале остаётся")

	// Разделитель ключа и значения задаёт обработчик: сегодня текстовый, по
	// конвенции — JSON. Утверждение держится на значении и переживёт замену.
	assert.Regexp(t, fmt.Sprintf(`size["=:\s]+%d`, len(content)), journal,
		"размер принятой записи в байтах в журнале остаётся")

	// Запись о приёме не сменила адресата: на DEBUG её в боевой настройке не
	// будет вовсе, и разбор постфактум опереться будет не на что. Уровень
	// ищется в строке самого приёма — соседние строки тоже идут на INFO, и
	// поиск по всему журналу не упал бы от понижения этой.
	assert.Regexp(t, `(?m)^.*level["=:\s]+INFO.*`+regexp.QuoteMeta(msgIntake)+`.*$`, journal,
		"строка приёма остаётся на уровне INFO")

	// И она ровно одна: вторая строка приёма означала бы второй путь заведения
	// задачи мимо общей точки, то есть место, куда запрет не доехал.
	assert.Equal(t, 1, strings.Count(journal, msgIntake),
		"на принятую запись приходится одна журнальная строка приёма")
}

// metricLabelValues собирает значения меток названного семейства метрик из
// общего реестра процесса. Проверка судит реестр, а не функцию приведения:
// приведение, снятое в точке употребления, функцию не ломает, а хвост имени
// уходит на страницу метрик, которая отдаётся без проверки отправителя.
func metricLabelValues(t *testing.T, family string) []string {
	families, err := prometheus.DefaultGatherer.Gather()
	require.NoError(t, err)

	var values []string
	for _, mf := range families {
		if mf.GetName() != family {
			continue
		}
		for _, m := range mf.GetMetric() {
			for _, label := range m.GetLabel() {
				values = append(values, label.GetValue())
			}
		}
	}
	return values
}

func TestCreateTranscribeJob_MetricLabelCarriesNoSenderName(t *testing.T) {
	env := setupTestEnv(t, readableMetaViewer())

	// Хвост после последней точки — это тоже кусок имени, данного отправителем.
	req := createMultipartRequest(t, "sample."+senderNameMarker, []byte("запись"))

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

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

	values := metricLabelValues(t, "transcriber_input_file_size_bytes")
	require.NotEmpty(t, values, "метрика размера принятой записи заполняется приёмом")
	assert.NotContains(t, values, "."+senderNameMarker,
		"метка метрики не несёт хвоста имени, данного отправителем")
	assert.NotContains(t, values, senderNameMarker,
		"метка метрики не несёт хвоста имени и без ведущей точки")
	assert.Contains(t, values, "other",
		"незнакомое расширение приведено к общему значению")

	// А в хранилище расширение остаётся пришедшим: приведение сюда не
	// распространяется.
	names := storedFileNames(t, env)
	require.Len(t, names, 1)
	assert.True(t, strings.HasSuffix(names[0], "."+senderNameMarker),
		"имя в хранилище сохраняет пришедшее расширение: %q", names[0])
}

func TestGetTranscribeJobStatus_Success(t *testing.T) {
	env := setupTestEnv(t, readableMetaViewer())

	job := jobWithFile(t, env)

	req := httptest.NewRequest("GET", "/app/audiorecords/"+job.Id, http.NoBody)

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

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

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

	assert.Equal(t, job.Id, response.ID)
	assert.Equal(t, entity.StateUploaded, response.State)
	assert.NotEmpty(t, response.CreatedAt)
}

func TestGetTranscribeJobStatus_NoTranscriptionText(t *testing.T) {
	env := setupTestEnv(t, readableMetaViewer())

	job := jobWithFile(t, env)

	req := httptest.NewRequest("GET", "/app/audiorecords/"+job.Id, http.NoBody)

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

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

	// Судим по сырому JSON: имена полей карточки нормативны, а разбор в структуру
	// переименовал бы тег вместе с ожиданием — и проверка не смогла бы упасть.
	var raw map[string]json.RawMessage
	require.NoError(t, json.Unmarshal(w.Body.Bytes(), &raw))

	assert.Contains(t, raw, "id")
	assert.Contains(t, raw, "state")
	assert.Contains(t, raw, "created_at")
	assert.Contains(t, raw, "original_filename")
	assert.Contains(t, raw, "duration_ms")
	assert.Contains(t, raw, "size_bytes")
	assert.NotContains(t, raw, "transcription_text", "текст читается своим адресом")
	assert.NotContains(t, raw, "job_id", "прежние имена ушли вместе с опросом готовности")
}

func TestGetTranscribeJobStatus_NotFound(t *testing.T) {
	env := setupTestEnv(t, readableMetaViewer())

	req := httptest.NewRequest("GET", "/app/audiorecords/non-existent-id", http.NoBody)

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

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

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

	assert.Equal(t, CodeNotFound, response.Code, "код отказа машиночитаем")
	assert.NotEmpty(t, response.Message, "и рядом с ним фраза для человека")
}

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

	req := createMultipartRequest(t, "sample.m4a", []byte("аудио"))
	// Так выглядит ушедший отправитель: контекст запроса отменяется сервером,
	// когда соединение закрылось.
	ctx, cancel := context.WithCancel(req.Context())
	cancel()
	req = req.WithContext(ctx)

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

	require.Equal(t, http.StatusCreated, w.Result().StatusCode, "тело ответа: %s", w.Body.String())

	assert.Equal(t, 1, countJobs(t, env), "задача заведена, несмотря на ушедшего отправителя")
}
