package pocketbase

import (
	"testing"
	"time"

	"github.com/pocketbase/pocketbase/core"
	"github.com/pocketbase/pocketbase/tools/types"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"

	"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
	"git.vakhrushev.me/av/transcriber/internal/clock"
	"git.vakhrushev.me/av/transcriber/internal/entity"
)

// Панель — единственный сегодня путь вернуть остановленную запись в работу, и
// хук правил стоит на правке **запросом**. Модельное сохранение его не трогает,
// поэтому проверки ниже идут через запрос — иначе они зеленели бы, не касаясь
// того пути, которым владелец и ходит.

// newPanelStorage поднимает хранилище с повешенными правилами панели — так же,
// как это делает сборка сервиса. Без них проверки судили бы хранилище без
// правил, то есть не то, что работает в проде.
func newPanelStorage(t *testing.T) core.App {
	t.Helper()

	app := newTestStorage(t)
	BindPanelRules(app)
	return app
}

// updateByRequest правит запись так, как это делает панель: запросом, а не
// сохранением модели.
func updateByRequest(t *testing.T, app core.App, recordID string, body map[string]any) *core.Record {
	t.Helper()

	record, err := app.FindRecordById(migrations.RecordsCollection, recordID)
	require.NoError(t, err)

	// Запись, прочитанная из хранилища, помнит прежние значения сама — по ним
	// хук и отличает смену рубежа от правки соседнего поля.
	for key, value := range body {
		record.Set(key, value)
	}

	// Событие правки запросом несёт и запрос, и коллекцию: `RequestEvent` вложен
	// указателем, а по коллекции хук и отбирается — без неё он не сработает вовсе,
	// и проверка зеленела бы, не коснувшись правила.
	collection, err := app.FindCollectionByNameOrId(migrations.RecordsCollection)
	require.NoError(t, err)

	event := &core.RecordRequestEvent{RequestEvent: &core.RequestEvent{}}
	event.App = app
	event.Collection = collection
	event.Record = record

	require.NoError(t, app.OnRecordUpdateRequest(migrations.RecordsCollection).Trigger(event, func(e *core.RecordRequestEvent) error {
		return e.App.Save(e.Record)
	}))

	after, err := app.FindRecordById(migrations.RecordsCollection, recordID)
	require.NoError(t, err)
	return after
}

// haltedRecord заводит остановленную запись со всеми накопленными сторожами —
// такой её видит владелец, открывая панель.
func haltedRecord(t *testing.T, app core.App) *entity.AudioRecord {
	t.Helper()

	record := newRecordOf(t, app, newAccount(t, app).Id)
	record.MoveToState(entity.StateNormalized)
	record.Attempts = 4
	record.AcquisitionID = ptrOf("прежний-захват")
	record.AcquireExpiresAt = ptrOf(clock.Now().Add(8 * time.Hour))
	record.DelayTime = ptrOf(clock.Now().Add(time.Hour))
	record.Halt(entity.HaltReasonStepFailed, "сбой конвертации файла")
	// Время входа в рубеж отодвигаем: запись простояла остановленной дольше
	// предела простоя, и это ровно тот случай, ради которого сторож сбрасывается.
	record.StateEnteredAt = clock.Now().Add(-24 * time.Hour)

	require.NoError(t, NewAudioRecordRepository(app).Save(record, ""))
	return record
}

func ptrOf[T any](v T) *T { return &v } //nolint:newexpr // значение вычисляется, new(x) его не примет

// Снятие признака остановки возвращает запись в работу с сохранённого рубежа и
// сбрасывает **всех** сторожей. Без сброса времени входа в рубеж запись,
// простоявшая остановленной дольше предела, остановилась бы снова первым же
// захватом — и владелец не узнал бы об этом.
func TestPanelResumeClearsEveryGuard(t *testing.T) {
	app := newPanelStorage(t)
	record := haltedRecord(t, app)

	after := updateByRequest(t, app, record.Id, map[string]any{"halted_at": ""})

	assert.Equal(t, entity.StateNormalized, after.GetString("state"), "рубеж сохранён")
	assert.True(t, after.GetDateTime("halted_at").IsZero(), "признак остановки снят")
	assert.Empty(t, after.GetString("halt_reason"), "причина снята вместе с ним")
	assert.Empty(t, after.GetString("error_text"), "и текст отказа")
	assert.Empty(t, after.GetString("acquisition_id"), "признак прежнего захвата очищен")
	assert.True(t, after.GetDateTime("acquire_expires_at").IsZero(), "срок протухания тоже")
	assert.True(t, after.GetDateTime("delay_time").IsZero(), "пауза снята")
	assert.Equal(t, 0, after.GetInt("attempts"), "отказы сброшены")

	entered := after.GetDateTime("state_entered_at").Time()
	assert.WithinDuration(t, clock.Now(), entered, time.Minute,
		"время входа в рубеж поставлено заново: иначе сторож простоя остановит запись снова")

	// И ближайший захват её выдаёт — то есть перезапуск действительно работает.
	acquired, err := NewAudioRecordRepository(app).FindAndAcquire(entity.WorkingStages())
	require.NoError(t, err, "запись вернулась в выборку")
	assert.Equal(t, record.Id, acquired.ID)
}

// Перезапуск виден в журнале событий с указанием, что его сделал человек: иначе
// запись, вернувшаяся в работу, выглядела бы как запись, которая туда и не
// уходила.
func TestPanelResumeIsLogged(t *testing.T) {
	app := newPanelStorage(t)
	record := haltedRecord(t, app)

	updateByRequest(t, app, record.Id, map[string]any{"halted_at": ""})

	events, err := app.FindAllRecords(migrations.RecordEventsCollection)
	require.NoError(t, err)

	var human int
	for _, event := range events {
		if event.GetString("record") == record.Id && event.GetString("origin") == entity.EventOriginHuman {
			human++
			assert.Equal(t, entity.EventOutcomeResumed, event.GetString("outcome"))
		}
	}
	assert.Equal(t, 1, human, "ровно одна строка о перезапуске человеком")
}

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

	record := newRecordOf(t, app, newAccount(t, app).Id)
	record.Attempts = 4
	record.AcquisitionID = ptrOf("прежний-захват")
	record.AcquireExpiresAt = ptrOf(clock.Now().Add(8 * time.Hour))
	require.NoError(t, NewAudioRecordRepository(app).Save(record, ""))

	after := updateByRequest(t, app, record.Id, map[string]any{"state": entity.StateNormalized})

	assert.Equal(t, entity.StateNormalized, after.GetString("state"))
	assert.Empty(t, after.GetString("acquisition_id"))
	assert.Equal(t, 0, after.GetInt("attempts"))
}

// Правка соседнего поля служебных полей не трогает: хук судит смену рубежа и
// снятие остановки, а не всякое сохранение. Иначе владелец, поправивший
// заголовок, снял бы захват у работающего шага.
func TestPanelKeepsGuardsOnUnrelatedEdit(t *testing.T) {
	app := newPanelStorage(t)

	record := newRecordOf(t, app, newAccount(t, app).Id)
	record.Attempts = 3
	record.AcquisitionID = ptrOf("живой-захват")
	require.NoError(t, NewAudioRecordRepository(app).Save(record, ""))

	after := updateByRequest(t, app, record.Id, map[string]any{"title": "Разговор с бабушкой"})

	assert.Equal(t, "Разговор с бабушкой", after.GetString("title"))
	assert.Equal(t, "живой-захват", after.GetString("acquisition_id"), "захват работающего шага не снят")
	assert.Equal(t, 3, after.GetInt("attempts"), "отказы не сброшены")
}

// Захват отдаёт идентификатор и признак **этого** захвата, а срок протухания
// приезжает с рубежом: воркер не привязан к шагу и вывести срок из себя не
// может.
func TestAcquireCarriesStageDeadline(t *testing.T) {
	app := newTestStorage(t)
	repo := NewAudioRecordRepository(app)

	record := newRecordOf(t, app, newAccount(t, app).Id)

	acquired, err := repo.FindAndAcquire(entity.WorkingStages())
	require.NoError(t, err)
	require.Equal(t, record.Id, acquired.ID)
	require.NotEmpty(t, acquired.Holder)

	stored, err := app.FindRecordById(migrations.RecordsCollection, record.Id)
	require.NoError(t, err)
	assert.Equal(t, acquired.Holder, stored.GetString("acquisition_id"))

	stage, ok := entity.StageByName(entity.StateUploaded)
	require.True(t, ok)
	expected := clock.Now().Add(stage.AcquireTimeout)
	assert.WithinDuration(t, expected, stored.GetDateTime("acquire_expires_at").Time(), time.Minute,
		"срок протухания приехал с рубежа записи")
}

// Одна запись достаётся ровно одному захвату: на этом стоит инвариант «Принятая
// запись не теряется молча».
func TestAcquireHandsRecordToExactlyOne(t *testing.T) {
	app := newTestStorage(t)
	repo := NewAudioRecordRepository(app)

	newRecordOf(t, app, newAccount(t, app).Id)

	first, err := repo.FindAndAcquire(entity.WorkingStages())
	require.NoError(t, err, "первому запись досталась")
	require.NotEmpty(t, first.Holder)

	for range 2 {
		_, err = repo.FindAndAcquire(entity.WorkingStages())
		require.Error(t, err, "остальным — признак «работы нет»")
	}
}

// Протухший захват возвращает запись в работу, и признак нового захвата
// отличается от прежнего: условие записи результата сверяет именно значение.
func TestRottenAcquisitionIsHandedOutAgain(t *testing.T) {
	app := newTestStorage(t)
	repo := NewAudioRecordRepository(app)

	record := newRecordOf(t, app, newAccount(t, app).Id)

	first, err := repo.FindAndAcquire(entity.WorkingStages())
	require.NoError(t, err)

	stored, err := app.FindRecordById(migrations.RecordsCollection, record.Id)
	require.NoError(t, err)
	stored.Set("acquire_expires_at", types.NowDateTime().Add(-time.Hour))
	require.NoError(t, app.Save(stored))

	second, err := repo.FindAndAcquire(entity.WorkingStages())
	require.NoError(t, err, "протухший захват не мешает выдать запись следующему")
	assert.Equal(t, record.Id, second.ID)
	assert.NotEqual(t, first.Holder, second.Holder, "признак нового захвата отличается от прежнего")
}
