package service

import (
	"context"
	"errors"
	"io"
	"log/slog"
	"os"
	"path/filepath"
	"strings"
	"sync"
	"testing"
	"time"

	"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"
)

// timeLayout — вид времени в колонках базы. Проверки, двигающие сроки, пишут его
// тем же видом, каким пишет хранилище: сравнение там побайтово.
const timeLayout = "2006-01-02T15:04:05Z"

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

// okConverter переливает исходник в результат — так это выглядит у настоящего
// приведения к рабочему формату.
type okConverter struct{}

func (c *okConverter) Convert(_ context.Context, src, dest string) error {
	content, err := os.ReadFile(src)
	if err != nil {
		return err
	}
	return os.WriteFile(dest, content, 0o600)
}

// failingConverter отказывает на каждой попытке.
type failingConverter struct{}

func (c *failingConverter) Convert(context.Context, string, string) error {
	return errors.New("конвертация не удалась")
}

type okMetaViewer struct{}

func (m *okMetaViewer) GetInfo(context.Context, string) (*contract.AudioInfo, error) {
	return &contract.AudioInfo{Seconds: 1}, nil
}

type failingMetaViewer struct{}

func (m *failingMetaViewer) GetInfo(context.Context, string) (*contract.AudioInfo, error) {
	return nil, errors.New("запись не читается")
}

type pipelineEnv struct {
	db         *sqliterepo.DB
	store      *sqliterepo.Store
	users      *sqliterepo.UserRepository
	service    *TranscribeService
	repos      Repositories
	recordRepo *sqliterepo.AudioRecordRepository
	fileRepo   *sqliterepo.FileRepository
}

// testLimits — пределы простоя проверок. Числа боевые; проверка застревания
// двигает не их, а время входа записи в рубеж: сторож меряет именно его.
var testLimits = entity.StuckLimits{Own: time.Hour, Foreign: 24 * time.Hour}

func newPipelineEnv(t *testing.T, metaviewer contract.AudioMetaViewer, converter contract.AudioFileConverter) *pipelineEnv {
	t.Helper()
	return newPipelineEnvWith(t, metaviewer, converter, &recognizer.MemoryAudioRecognizer{})
}

func newPipelineEnvWith(
	t *testing.T,
	metaviewer contract.AudioMetaViewer,
	converter contract.AudioFileConverter,
	rec contract.AudioRecognizer,
) *pipelineEnv {
	t.Helper()
	return newPipelineEnvWithLogger(t, metaviewer, converter, rec, slog.New(slog.DiscardHandler))
}

// newPipelineEnvWithLogger — то же с подменённым журналом: проверке, судящей
// строку журнала, нужен свой, а не общий.
func newPipelineEnvWithLogger(
	t *testing.T,
	metaviewer contract.AudioMetaViewer,
	converter contract.AudioFileConverter,
	rec contract.AudioRecognizer,
	logger *slog.Logger,
) *pipelineEnv {
	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(t.Context(), db, dir, slog.New(slog.DiscardHandler)))

	store := sqliterepo.NewStore(dir)
	recordRepo := sqliterepo.NewAudioRecordRepository(db)
	fileRepo := sqliterepo.NewFileRepository(db, store)
	repos := Repositories{
		Records:      recordRepo,
		Files:        fileRepo,
		Texts:        sqliterepo.NewTextRepository(db),
		Structures:   sqliterepo.NewStructureRepository(db),
		Recognitions: sqliterepo.NewRecognitionRepository(db, store),
		Events:       sqliterepo.NewRecordEventRepository(db),
	}
	svc := NewTranscribeService(repos, metaviewer, converter, rec, testLimits, logger)

	return &pipelineEnv{
		db:         db,
		store:      store,
		users:      sqliterepo.NewUserRepository(db),
		service:    svc,
		repos:      repos,
		recordRepo: recordRepo,
		fileRepo:   fileRepo,
	}
}

// exec выполняет запрос к базе от имени проверки: фикстуры двигают колонки
// напрямую там, где домен такого перехода не делает.
func (e *pipelineEnv) exec(t *testing.T, query string, args ...any) {
	t.Helper()

	_, err := e.db.Writer().ExecContext(context.Background(), query, args...)
	require.NoError(t, err)
}

// newRecord заводит запись — так, как её заводит приём по HTTP: от имени
// вошедшего, потому что ничьей записи в хранилище не бывает.
func newRecord(t *testing.T, env *pipelineEnv) *entity.AudioRecord {
	t.Helper()

	record, err := env.service.CreateJobFromApi(
		t.Context(), strings.NewReader("запись"), "voice.ogg", newOwner(t, env))
	require.NoError(t, err)
	return record
}

// clearDelay снимает паузу, чтобы следующий прогон взял запись сразу.
func clearDelay(t *testing.T, env *pipelineEnv, recordID string) {
	t.Helper()

	env.exec(t, "UPDATE audio_records SET delay_time = NULL WHERE id = ?", recordID)
}

// enteredStateAt отодвигает время входа записи в рубеж: так это выглядит, когда
// запись простояла в нём дольше предела.
func enteredStateAt(t *testing.T, env *pipelineEnv, recordID string, moment time.Time) {
	t.Helper()

	env.exec(t, "UPDATE audio_records SET state_entered_at = ? WHERE id = ?",
		moment.UTC().Format(timeLayout), recordID)
}

// setAttempts ставит записи число отказов: так она выглядит, когда шаг отказал
// столько раз подряд, что предел исчерпан.
func setAttempts(t *testing.T, env *pipelineEnv, recordID string, attempts int) {
	t.Helper()

	env.exec(t, "UPDATE audio_records SET attempts = ? WHERE id = ?", attempts, recordID)
}

// drain крутит конвейер, пока он двигает записи. Паузы опроса снимаются: они
// проверяются отдельно, а здесь мешают дойти до конца.
func drain(t *testing.T, env *pipelineEnv, recordID string) {
	t.Helper()

	for range 20 {
		clearDelay(t, env, recordID)
		err := env.service.RunStep(t.Context())
		var noop *contract.NoopJobError
		if errors.As(err, &noop) {
			return
		}
		require.NoError(t, err)

		after := readRecord(t, env, recordID)
		if after.State == entity.StateDone || after.IsHalted() {
			return
		}
	}
	t.Fatal("конвейер не дошёл до исхода за отведённое число прогонов")
}

// readRecord читает запись мимо сужения владельцем.
//
// Читающий метод сервиса отдаёт запись только её владельцу, а проверки конвейера
// смотрят записи, принятые ботом: владельца у таких нет вовсе. Проверке нужно не
// право, а состояние записи после шага.
func readRecord(t *testing.T, env *pipelineEnv, id string) *entity.AudioRecord {
	t.Helper()

	record, err := env.recordRepo.Get(id)
	require.NoError(t, err)
	return record
}

// Запись, отказывающая на каждой попытке, останавливается признаком: из выборки
// исчезает, рубеж сохраняет, а отправитель узнаёт о неудаче. Инвариант «Принятая
// запись не теряется молча» допускает два исхода, и молчаливая остановка не
// подходит ни под один.
func TestRecordHaltsAfterAttemptLimit(t *testing.T) {
	env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})

	record := newRecord(t, env)

	// Захват без выполнения шага — так это выглядит при гибели процесса: отказа
	// шаг объявить не успевает, а попытка засчитана.
	for range maxAttempts {
		_, err := env.recordRepo.FindAndAcquire(entity.WorkingStages())
		require.NoError(t, err)
		expireAcquisition(t, env, record.Id)
	}

	err := env.service.RunStep(t.Context())

	var noop *contract.NoopJobError
	require.ErrorAs(t, err, &noop, "остановленная запись шагу не отдаётся")

	after := readRecord(t, env, record.Id)
	assert.True(t, after.IsHalted(), "запись остановлена признаком")
	require.NotNil(t, after.HaltReason)
	assert.Equal(t, entity.HaltReasonAttempts, *after.HaltReason)
	assert.Equal(t, entity.StateUploaded, after.State, "рубеж пережил остановку")
	assert.Greater(t, after.Attempts, maxAttempts, "число отказов сохранено")

	// И из выборки она исчезла.
	_, err = env.recordRepo.FindAndAcquire(entity.WorkingStages())
	var missing *contract.JobNotFoundError
	assert.ErrorAs(t, err, &missing)
}

// expireAcquisition отодвигает срок протухания захвата в прошлое: так это
// выглядит, когда шаг оборвался вместе с процессом.
func expireAcquisition(t *testing.T, env *pipelineEnv, recordID string) {
	t.Helper()

	env.exec(t, "UPDATE audio_records SET acquire_expires_at = ? WHERE id = ?",
		clock.Now().Add(-time.Hour).Format(timeLayout), recordID)
}

// Критерий приёмки 1. Остановленная на шаге запись перезапускается снятием
// признака и продолжает с того рубежа, где стояла, — следующим идёт отправка на
// распознавание, а не повторное приведение.
func TestHaltedRecordResumesFromItsStage(t *testing.T) {
	env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{})

	record := newRecord(t, env)

	// Доводим до рубежа приведения и останавливаем на нём.
	require.NoError(t, env.service.RunStep(t.Context()))
	after := readRecord(t, env, record.Id)
	require.Equal(t, entity.StateNormalized, after.State)

	after.Halt(entity.HaltReasonStepFailed, "проверочная остановка")
	require.NoError(t, env.recordRepo.Save(after, ""))

	// Остановленная запись захвату не выдаётся.
	_, err := env.recordRepo.FindAndAcquire(entity.WorkingStages())
	var missing *contract.JobNotFoundError
	require.ErrorAs(t, err, &missing, "остановленная запись из выборки исчезла")

	// Снятие признака возвращает её в работу с сохранённого рубежа.
	halted := readRecord(t, env, record.Id)
	halted.Resume()
	require.NoError(t, env.recordRepo.Save(halted, ""))

	resumed := readRecord(t, env, record.Id)
	assert.Equal(t, entity.StateNormalized, resumed.State, "рубеж сохранён")
	assert.Equal(t, 0, resumed.Attempts, "отказы сброшены")

	// Следующим идёт отправка на распознавание, а не повторное приведение.
	require.NoError(t, env.service.RunStep(t.Context()))
	next := readRecord(t, env, record.Id)
	assert.Equal(t, entity.StateSubmitted, next.State, "продолжили с места остановки")
	assert.NotNil(t, next.RecognitionID, "отправка состоялась")
}

// Критерий приёмки 2. У прошедшей конвейер записи ссылки на исходник и на
// приведённую копию ведут на разные существующие файлы: прежде ссылка была одна,
// и каждый шаг переставлял её на свой результат.
func TestBothFileLinksSurvivePipeline(t *testing.T) {
	env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{})

	record := newRecord(t, env)
	drain(t, env, record.Id)

	after := readRecord(t, env, record.Id)
	require.Equal(t, entity.StateDone, after.State)

	require.NotNil(t, after.OriginalFileID, "ссылка на принятую копию заполнена")
	require.NotNil(t, after.NormalizedFileID, "ссылка на приведённую копию заполнена")
	assert.NotEqual(t, *after.OriginalFileID, *after.NormalizedFileID, "копии разные")

	for _, id := range []string{*after.OriginalFileID, *after.NormalizedFileID} {
		reader, err := env.fileRepo.Open(id)
		require.NoError(t, err, "обе копии открываются")
		content, err := io.ReadAll(reader)
		require.NoError(t, err)
		assert.NotEmpty(t, content)
		require.NoError(t, reader.Close())
	}
}

// Критерий приёмки 3. Поведение записи не зависит от числа воркеров: при одном
// и при нескольких она доходит до конечного рубежа.
func TestOutcomeDoesNotDependOnWorkerCount(t *testing.T) {
	for _, workers := range []int{1, 4} {
		env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{})
		record := newRecord(t, env)

		for range 20 {
			clearDelay(t, env, record.Id)

			var wg sync.WaitGroup
			for range workers {
				wg.Add(1)
				go func() {
					defer wg.Done()
					// Исход прогона здесь не судится: воркеров несколько, и всем,
					// кроме одного, работы не достаётся. Судится состояние записи.
					if err := env.service.RunStep(t.Context()); err != nil {
						t.Log(err)
					}
				}()
			}
			wg.Wait()

			if readRecord(t, env, record.Id).State == entity.StateDone {
				break
			}
		}

		after := readRecord(t, env, record.Id)
		assert.Equalf(t, entity.StateDone, after.State, "запись дошла до конца при %d воркерах", workers)
		assert.Falsef(t, after.IsHalted(), "запись не остановлена при %d воркерах", workers)
	}
}

// Тот же критерий, вторая половина: нулевое число воркеров — законное значение.
// Записи принимаются и не двигаются, и это режим, а не поломка.
func TestZeroWorkersLeaveRecordUntouched(t *testing.T) {
	env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{})
	record := newRecord(t, env)

	// Пул нулевого размера ни одного прогона не делает — потому запись остаётся
	// там, где её оставил приём.
	after := readRecord(t, env, record.Id)
	assert.Equal(t, entity.StateUploaded, after.State, "запись принята и стоит на первом рубеже")
	assert.False(t, after.IsHalted(), "и не потеряна")
	assert.Equal(t, record.Id, after.Id)
}

// Критерий приёмки 5, первая половина. Откладывание опроса не двигает время
// входа в рубеж и не обнуляет отсчёт застревания: иначе запись, чью чужую
// операцию опрашивают раз в несколько секунд, не достигла бы предела никогда.
func TestPostponeKeepsStuckCountdown(t *testing.T) {
	entered := time.Date(2026, 8, 14, 10, 0, 0, 0, time.UTC)
	record := &entity.AudioRecord{State: entity.StateSubmitted, StateEnteredAt: entered}

	for range 100 {
		record.Postpone(time.Now().Add(5 * time.Second))
	}

	assert.Equal(t, entered, record.StateEnteredAt, "сотня откладываний не двигает отсчёт")
	assert.Equal(t, entity.StateSubmitted, record.State, "и рубежа не трогает")
	assert.Nil(t, record.AcquisitionID, "захват при этом снят")
	assert.NotNil(t, record.DelayTime, "а пауза поставлена")
}

// Критерий приёмки 5, вторая половина. Запись, простоявшая в рубеже дольше
// предела, останавливается признаком с причиной «застряла».
func TestStuckRecordIsHalted(t *testing.T) {
	env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{})

	record := newRecord(t, env)
	enteredStateAt(t, env, record.Id, time.Now().Add(-2*testLimits.Own))

	err := env.service.RunStep(t.Context())
	var noop *contract.NoopJobError
	require.ErrorAs(t, err, &noop, "застрявшая запись шагу не отдаётся")

	after := readRecord(t, env, record.Id)
	require.True(t, after.IsHalted())
	require.NotNil(t, after.HaltReason)
	assert.Equal(t, entity.HaltReasonStuck, *after.HaltReason, "причина названа")
	assert.Equal(t, entity.StateUploaded, after.State, "рубеж сохранён")

}

// Конечный рубеж под предел простоя не подпадает: стоять в нём запись будет
// вечно по построению, и сторож остановил бы всякую доведённую запись.
func TestDoneRecordIsNeverStuck(t *testing.T) {
	env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{})

	record := newRecord(t, env)
	drain(t, env, record.Id)
	enteredStateAt(t, env, record.Id, time.Now().Add(-10*24*time.Hour))

	err := env.service.RunStep(t.Context())
	var noop *contract.NoopJobError
	require.ErrorAs(t, err, &noop, "доведённая запись в работу не берётся")

	after := readRecord(t, env, record.Id)
	assert.Equal(t, entity.StateDone, after.State)
	assert.False(t, after.IsHalted(), "признака остановки у доведённой записи нет")
}

// Критерий приёмки 6. Держатель захвата отличим значением, а не занятостью
// записи: шаг, чей захват достался другому, результата не пишет и отправителю
// ничего не шлёт.
func TestOnlyHolderWritesResult(t *testing.T) {
	env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{})

	record := newRecord(t, env)

	first, err := env.recordRepo.FindAndAcquire(entity.WorkingStages())
	require.NoError(t, err)
	require.Equal(t, record.Id, first.ID)

	// Человек снял признак остановки — панель чистит признак захвата, — и запись
	// достаётся другому воркеру.
	expireAcquisition(t, env, record.Id)
	second, err := env.recordRepo.FindAndAcquire(entity.WorkingStages())
	require.NoError(t, err)
	require.NotEqual(t, first.Holder, second.Holder, "признак нового захвата отличается")

	// Первый доходит до записи результата и получает отказ.
	stale := readRecord(t, env, record.Id)
	stale.MoveToState(entity.StateNormalized)
	err = env.recordRepo.Save(stale, first.Holder)

	var lost *contract.LostAcquisitionError
	require.ErrorAs(t, err, &lost, "потерявший захват не пишет")

	after := readRecord(t, env, record.Id)
	assert.Equal(t, entity.StateUploaded, after.State, "рубеж не сдвинут потерявшим захват")
}

// Критерий приёмки 7. Всякий способ вывести запись из работы оставляет причину
// остановки: причин больше одной, и обязанность у них общая. Отправитель узнаёт
// исход опросом готовности, а владелец сервиса — журналом событий записи.
func TestEveryHaltReasonRecordsItsCause(t *testing.T) {
	reasons := []struct {
		name string
		halt func(t *testing.T, env *pipelineEnv, recordID string)
	}{
		{
			name: "приговор шага",
			halt: func(t *testing.T, env *pipelineEnv, recordID string) {
				require.NoError(t, env.service.RunStep(t.Context()))
			},
		},
		{
			name: "застревание",
			halt: func(t *testing.T, env *pipelineEnv, recordID string) {
				enteredStateAt(t, env, recordID, time.Now().Add(-2*testLimits.Own))
				var noop *contract.NoopJobError
				require.ErrorAs(t, env.service.RunStep(t.Context()), &noop)
			},
		},
		{
			name: "исчерпанные отказы",
			halt: func(t *testing.T, env *pipelineEnv, recordID string) {
				setAttempts(t, env, recordID, maxAttempts+1)
				var noop *contract.NoopJobError
				require.ErrorAs(t, env.service.RunStep(t.Context()), &noop)
			},
		},
	}

	for _, reason := range reasons {
		t.Run(reason.name, func(t *testing.T) {
			env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
			record := newRecord(t, env)

			reason.halt(t, env, record.Id)

			after := readRecord(t, env, record.Id)
			require.True(t, after.IsHalted(), "запись остановлена")

			// Признак остановки и причина ставятся одним движением, поэтому
			// вторым утверждением берётся **журнал событий**: он пишется
			// отдельной строкой, отдельным сохранением, и упасть может сам по
			// себе. Прежде эту роль играл счёт ответов отправителю; ответы ушли
			// вместе с входом Telegram, и без замены проверка вывелась бы из
			// собственной предыдущей строки.
			events := recordEvents(t, env, record.Id)
			require.Len(t, events, 1, "остановка оставила строку журнала событий")

			assert.Contains(t,
				[]string{entity.EventOutcomeHalted, entity.EventOutcomeFailed}, events[0].Outcome,
				"строка журнала называет исход остановкой")
			assert.NotEmpty(t, events[0].OutcomeText, "и несёт причину")
		})
	}
}

// Критерий приёмки 8. Повтор шага не создаёт второго приложения: пара «запись и
// вид» уникальна, и вопрос «какой текст отдавать человеку» не становится
// вопросом порядка записи.
func TestRepeatedStoreKeepsSingleText(t *testing.T) {
	env := newPipelineEnv(t, &okMetaViewer{}, &okConverter{})

	record := newRecord(t, env)

	first, err := env.repos.Texts.Put(record.Id, entity.TextKindTranscript, "первый разбор")
	require.NoError(t, err)
	second, err := env.repos.Texts.Put(record.Id, entity.TextKindTranscript, "второй разбор")
	require.NoError(t, err)

	assert.Equal(t, first.Id, second.Id, "строка та же, а не вторая")

	stored, err := env.repos.Texts.GetByID(first.Id)
	require.NoError(t, err)
	assert.Equal(t, "второй разбор", stored.Contents)

	var count int
	require.NoError(t, env.db.Reader().QueryRowContext(context.Background(), "SELECT COUNT(*) FROM texts").Scan(&count))
	assert.Equal(t, 1, count, "второго комплекта строк не завелось")
}

// Пауза растёт с числом отказов и упирается в потолок.
func TestRetryDelayGrowsAndCaps(t *testing.T) {
	assert.Equal(t, retryDelayBase, retryDelay(1))
	assert.Equal(t, 2*retryDelayBase, retryDelay(2))
	assert.Greater(t, retryDelay(3), retryDelay(2))
	assert.Equal(t, retryDelayCap, retryDelay(100), "пауза упирается в потолок")
	assert.Equal(t, retryDelayBase, retryDelay(0), "нулевая попытка не даёт нулевой паузы")
}

// Отказ шага не оставляет запись захваченной до конца срока: захват снимается,
// и запись ждёт нарастающую паузу. Иначе повтор наступал бы через часы.
func TestFailedStepSchedulesRetryWithGrowingDelay(t *testing.T) {
	env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})

	record := newRecord(t, env)

	// Ссылка переставляется на запись о файле без содержимого: шаг отказывает на
	// получении рабочей копии — то есть отказом, а не приговором записи.
	emptyID := ident.New()
	// Владелец обязателен и у файла: схема ничьих не принимает.
	env.exec(t, `INSERT INTO files (id, owner_id, record_id, file_name, size_bytes, created_at)
	             VALUES (?, ?, ?, ?, ?, ?)`,
		emptyID, record.OwnerID, record.Id, "missing.mp3", 1, clock.Now().Format(timeLayout))
	env.exec(t, "UPDATE audio_records SET original_file_id = ? WHERE id = ?", emptyID, record.Id)

	require.Error(t, env.service.RunStep(t.Context()))

	after := readRecord(t, env, record.Id)
	require.Nil(t, after.AcquisitionID, "захват снят: запись пригодна к повтору")
	require.NotNil(t, after.DelayTime, "пауза поставлена")
	firstDelay := time.Until(*after.DelayTime)

	clearDelay(t, env, record.Id)
	require.Error(t, env.service.RunStep(t.Context()))

	after = readRecord(t, env, record.Id)
	require.NotNil(t, after.DelayTime)
	assert.Greater(t, time.Until(*after.DelayTime), firstDelay, "вторая пауза длиннее первой")
}

// Рабочая копия убирается на любом исходе, включая отказ. Забытая копия — это
// шестичасовая запись во временном каталоге, и узнать о ней неоткуда.
func TestWorkFileRemovedAfterIntakeFailure(t *testing.T) {
	tempDir := t.TempDir()
	t.Setenv("TMPDIR", tempDir)

	env := newPipelineEnv(t, &failingMetaViewer{}, &failingConverter{})

	_, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "sample.mp3", newOwner(t, env))
	require.Error(t, err, "отказ источника метаданных роняет приём")

	leftovers, err := filepath.Glob(filepath.Join(tempDir, "transcriber-*"))
	require.NoError(t, err)
	assert.Empty(t, leftovers, "рабочей копии после отказа не остаётся")
}

// Успешный приём тоже за собой убирает: копия нужна была только на время
// укладки в хранилище.
func TestWorkFileRemovedAfterSuccessfulIntake(t *testing.T) {
	tempDir := t.TempDir()
	t.Setenv("TMPDIR", tempDir)

	env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})

	_, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "sample.mp3", newOwner(t, env))
	require.NoError(t, err)

	leftovers, err := filepath.Glob(filepath.Join(tempDir, "transcriber-*"))
	require.NoError(t, err)
	assert.Empty(t, leftovers, "рабочей копии после успеха не остаётся")
}

// Уборка проверяется и на шаге приведения: репозиторий даёт единственный способ
// убрать копию, но зовёт его шаг. Копий здесь две — исходник и результат.
func TestWorkFilesRemovedAfterConversionFailure(t *testing.T) {
	tempDir := t.TempDir()
	t.Setenv("TMPDIR", tempDir)

	env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})

	newRecord(t, env)

	leftovers, err := filepath.Glob(filepath.Join(tempDir, "transcriber-*"))
	require.NoError(t, err)
	require.Empty(t, leftovers, "приём убрал свою рабочую копию")

	require.NoError(t, env.service.RunStep(t.Context()))

	leftovers, err = filepath.Glob(filepath.Join(tempDir, "transcriber-*"))
	require.NoError(t, err)
	assert.Empty(t, leftovers, "ни исходной копии, ни копии под результат не осталось")
}

// Запись не остаётся ссылающейся на файл, которого нет: ссылка ставится только
// после того, как запись о файле существует.
func TestRecordNeverPointsToMissingFile(t *testing.T) {
	env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})

	record := newRecord(t, env)

	require.NoError(t, env.service.RunStep(t.Context()))

	after := readRecord(t, env, record.Id)
	assert.True(t, after.IsHalted(), "приговор шага остановил запись")
	require.NotNil(t, after.OriginalFileID)
	assert.Nil(t, after.NormalizedFileID, "ссылки на несозданный результат не появилось")

	file, err := env.fileRepo.GetByID(*after.OriginalFileID)
	require.NoError(t, err, "ссылка ведёт на существующую запись о файле")
	assert.NotEmpty(t, file.FileName)
}

// Содержимое доезжает до хранилища целиком и читается обратно тем же.
func TestStoredContentSurvivesRoundTrip(t *testing.T) {
	env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})

	content := strings.Repeat("запись ", 1000)

	record, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader(content), "sample.mp3", newOwner(t, env))
	require.NoError(t, err)
	require.NotNil(t, record.OriginalFileID)

	reader, err := env.fileRepo.Open(*record.OriginalFileID)
	require.NoError(t, err)
	defer reader.Close()

	stored, err := io.ReadAll(reader)
	require.NoError(t, err)
	assert.Equal(t, content, string(stored))

	file, err := env.fileRepo.GetByID(*record.OriginalFileID)
	require.NoError(t, err)
	assert.Equal(t, int64(len(content)), file.Size)
	assert.Equal(t, "mp3", file.Format, "формат копии записан")
}

// Рабочая копия хранимого файла отдаётся именем на диске — так её получают шаги,
// отдающие файл внешней программе.
func TestLocalizeGivesReadableCopy(t *testing.T) {
	env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})

	record, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("содержимое"), "sample.mp3", newOwner(t, env))
	require.NoError(t, err)
	require.NotNil(t, record.OriginalFileID)

	work, err := env.fileRepo.Localize(*record.OriginalFileID)
	require.NoError(t, err)

	content, err := os.ReadFile(work.Path())
	require.NoError(t, err)
	assert.Equal(t, "содержимое", string(content))

	require.NoError(t, work.Close())
	_, err = os.Stat(work.Path())
	assert.True(t, os.IsNotExist(err), "закрытая копия убрана")
}

// newOwner заводит учётную запись и отдаёт её идентификатор.
//
// Владелец — связь с таблицей учётных записей, и база проверяет, что такая
// строка есть: выдуманный идентификатор запись завести не даст.
func newOwner(t *testing.T, env *pipelineEnv) string {
	t.Helper()

	// Логин у провайдера — ключ учётной записи, и он уникален: две записи с
	// одним ключом схема не примет.
	account, _, err := env.users.EnsureUser(contract.Identity{Login: ident.New()})
	require.NoError(t, err)

	return account.ID
}

// Остановка приговором шага засчитывается **отказом**, а не успехом, и пишет в
// журнал записи одну строку, а не две.
//
// Пока исход шага выводился из «шаг не вернул ошибку», остановленная запись
// получала событием `done` вслед за `halted` и растила счётчик успехов — то есть
// единственный канал владельца молчал ровно там, где запись встала.
func TestHaltIsCountedAsFailureAndLoggedOnce(t *testing.T) {
	env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})

	beforeOk := stageCount(t, entity.StateUploaded, "false")
	beforeErr := stageCount(t, entity.StateUploaded, "true")

	record := newRecord(t, env)
	require.NoError(t, env.service.RunStep(t.Context()))

	after := readRecord(t, env, record.Id)
	require.True(t, after.IsHalted(), "приговор шага остановил запись")

	assert.InDelta(t, beforeErr+1, stageCount(t, entity.StateUploaded, "true"), 0,
		"остановка засчитана отказом")
	assert.InDelta(t, beforeOk, stageCount(t, entity.StateUploaded, "false"), 0,
		"и успехом не засчитана")

	events := recordEvents(t, env, record.Id)
	require.Len(t, events, 1, "одна строка журнала, а не две")
	assert.Equal(t, entity.EventOutcomeFailed, events[0].Outcome,
		"исход назван приговором, а не сделанной работой")
}

// Откладывание опроса в журнал событий не пишется: часовое ожидание чужой
// операции дало бы там сотни строк ни о чём, и журнал, заведённый для человека,
// стал бы нечитаемым ровно у долгой записи.
func TestPostponeWritesNoEvent(t *testing.T) {
	rec := &countingRecognizer{}
	rec.inProgress.Store(5)
	env := newPipelineEnvWith(t, &okMetaViewer{}, &okConverter{}, rec)

	record := newRecord(t, env)
	require.NoError(t, env.service.RunStep(t.Context())) // приведение
	require.NoError(t, env.service.RunStep(t.Context())) // отправка
	require.Equal(t, entity.StateSubmitted, readRecord(t, env, record.Id).State)

	before := len(recordEvents(t, env, record.Id))

	for range 5 {
		clearDelay(t, env, record.Id)
		require.NoError(t, env.service.RunStep(t.Context()))
	}

	assert.Len(t, recordEvents(t, env, record.Id), before,
		"пять откладываний не оставили в журнале ни строки")
}

// recordEventRow — строка журнала событий записи, какой её видит проверка.
type recordEventRow struct {
	Origin      string
	Step        string
	Outcome     string
	OutcomeText string
}

// recordEvents читает журнал событий одной записи в порядке заведения.
func recordEvents(t *testing.T, env *pipelineEnv, recordID string) []recordEventRow {
	t.Helper()

	rows, err := env.db.Reader().QueryContext(context.Background(),
		"SELECT origin, step, outcome, outcome_text FROM record_events WHERE record_id = ? ORDER BY id",
		recordID,
	)
	require.NoError(t, err)
	defer func() { require.NoError(t, rows.Close()) }()

	var own []recordEventRow
	for rows.Next() {
		var event recordEventRow
		require.NoError(t, rows.Scan(&event.Origin, &event.Step, &event.Outcome, &event.OutcomeText))
		own = append(own, event)
	}
	require.NoError(t, rows.Err())

	return own
}
