package http

import (
	"encoding/json"
	"errors"
	"log/slog"
	"net/http"
	"net/http/httptest"
	"testing"

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

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

// Карточка записи и её текст читаются порознь: шестичасовая расшифровка,
// приехавшая вместе с шапкой, задерживает показ на мобильной сети на время,
// которое человеку не нужно ждать.

// cardOf спрашивает карточку записи от имени её владельца.
func cardOf(t *testing.T, env *testEnv, recordID string) *httptest.ResponseRecorder {
	t.Helper()

	w := httptest.NewRecorder()
	env.serve(w, httptest.NewRequest("GET", "/app/audiorecords/"+recordID, http.NoBody))
	return w
}

// textOf спрашивает текст записи названного вида.
func textOf(t *testing.T, env *testEnv, recordID, view string) *httptest.ResponseRecorder {
	t.Helper()

	w := httptest.NewRecorder()
	env.serve(w, httptest.NewRequest("GET", "/app/audiorecords/"+recordID+"/text?view="+view, http.NoBody))
	return w
}

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

	record := jobWithFile(t, env)

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

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

	w := cardOf(t, env, record.Id)
	require.Equal(t, http.StatusOK, w.Code)

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

	assert.Equal(t, entity.StateDone, card.State)
	assert.NotContains(t, w.Body.String(), "сырая расшифровка",
		"текст в карточку не кладётся: за ним идут отдельным адресом")
	require.NotNil(t, card.AvailableViews)
	assert.Equal(t, []string{entity.TextViewTranscript}, *card.AvailableViews)
}

// Пока запись не дошла до текста, перечень доступных видов пуст. Пустой перечень
// значит «текста ещё нет» — и это состояние, а не отсутствие записи.
func TestRecordCard_NoTextYetGivesEmptyViews(t *testing.T) {
	env := setupTestEnv(t, readableMetaViewer())

	record := jobWithFile(t, env)

	w := cardOf(t, env, record.Id)
	require.Equal(t, http.StatusOK, w.Code)

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

	assert.Equal(t, entity.StateUploaded, card.State)
	assert.False(t, card.Halted, "запись в работе остановленной не значится")
	require.NotNil(t, card.AvailableViews, "поле есть на проводе даже когда текста нет")
	assert.Empty(t, *card.AvailableViews)
}

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

	record := jobWithFile(t, env)
	record.MoveToState(entity.StateNormalized)
	record.Halt(entity.HaltReasonStepFailed, "сбой конвертации файла")
	require.NoError(t, env.handler.recordRepo.Save(record, ""))

	w := cardOf(t, env, record.Id)
	require.Equal(t, http.StatusOK, w.Code)

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

	assert.Equal(t, entity.StateNormalized, card.State, "рубеж тот, на котором запись встала")
	assert.True(t, card.Halted, "признак остановки виден владельцу записи")
	require.NotNil(t, card.HaltReason, "без причины признак не говорит, чего ждать")
	assert.Equal(t, entity.HaltReasonStepFailed, *card.HaltReason)
	assert.NotContains(t, w.Body.String(), "сбой конвертации файла",
		"машинный текст отказа принадлежит журналу владельца сервиса, а не ответу")
}

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

	record := jobWithFile(t, env)

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

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

	w := textOf(t, env, record.Id, entity.TextViewTranscript)
	require.Equal(t, http.StatusOK, w.Code)

	var text TextView
	require.NoError(t, json.Unmarshal(w.Body.Bytes(), &text))

	assert.Equal(t, entity.TextViewTranscript, text.View)
	assert.Equal(t, "сырая расшифровка", text.Contents)
	assert.NotContains(t, w.Body.String(), "вычитанный текст",
		"вычитанный текст этим видом не подменяется: у него своё значение перечня")
}

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

	record := jobWithFile(t, env)

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

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

	w := textOf(t, env, record.Id, entity.TextViewReplicas)
	require.Equal(t, http.StatusOK, w.Code)

	var text TextView
	require.NoError(t, json.Unmarshal(w.Body.Bytes(), &text))

	assert.Equal(t, entity.TextViewReplicas, text.View)
	require.Len(t, text.Replicas, 2)
	assert.Equal(t, "первая реплика", text.Replicas[0].Text)
	assert.Equal(t, int64(1500), text.Replicas[0].EndMs, "у каждой реплики стоит её время")
}

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

	record := jobWithFile(t, env)

	texts := env.handler.textRepo
	transcript, err := texts.Put(record.Id, entity.TextKindTranscript, "сырая расшифровка")
	require.NoError(t, err)
	record.TranscriptTextID = &transcript.Id
	require.NoError(t, env.handler.recordRepo.Save(record, ""))

	// Расшифровка есть, структуры реплик нет — состояние достижимое: шаг
	// завершения пишет их несколькими операциями.
	notReady := textOf(t, env, record.Id, entity.TextViewReplicas)
	assert.Equal(t, http.StatusConflict, notReady.Code)

	var body ErrorBody
	require.NoError(t, json.Unmarshal(notReady.Body.Bytes(), &body))
	assert.Equal(t, CodeNotReady, body.Code)

	missing := textOf(t, env, "nosuchrecordid", entity.TextViewTranscript)
	assert.Equal(t, http.StatusNotFound, missing.Code)

	var missingBody ErrorBody
	require.NoError(t, json.Unmarshal(missing.Body.Bytes(), &missingBody))
	assert.Equal(t, CodeNotFound, missingBody.Code)
	assert.NotEqual(t, body.Code, missingBody.Code,
		"«ещё не готово» и «записи нет» ведут к разным действиям человека")
}

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

	notReady := func(t *testing.T, record *entity.AudioRecord, view string) {
		t.Helper()

		w := textOf(t, env, record.Id, view)
		require.Equal(t, http.StatusConflict, w.Code, view)

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

	t.Run("текста нет вовсе", func(t *testing.T) {
		record := jobWithFile(t, env)
		notReady(t, record, entity.TextViewTranscript)
		notReady(t, record, entity.TextViewLiterary)
		notReady(t, record, entity.TextViewReplicas)
	})

	t.Run("текст заведён пустым", func(t *testing.T) {
		record := jobWithFile(t, env)

		transcript, err := env.handler.textRepo.Put(record.Id, entity.TextKindTranscript, "")
		require.NoError(t, err)
		record.TranscriptTextID = &transcript.Id

		literary, err := env.handler.textRepo.Put(record.Id, entity.TextKindLiterary, "")
		require.NoError(t, err)
		record.LiteraryTextID = &literary.Id

		require.NoError(t, env.handler.recordRepo.Save(record, ""))

		notReady(t, record, entity.TextViewTranscript)
		notReady(t, record, entity.TextViewLiterary)
	})

	t.Run("структура заведена без единой реплики", func(t *testing.T) {
		record := jobWithFile(t, env)

		structure, err := env.handler.structureRepo.Put(record.Id, 1, []entity.Replica{})
		require.NoError(t, err)
		record.StructureID = &structure.Id
		require.NoError(t, env.handler.recordRepo.Save(record, ""))

		notReady(t, record, entity.TextViewReplicas)
	})
}

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

	record := jobWithFile(t, env)

	unknown := textOf(t, env, record.Id, "unknown-view")
	assert.Equal(t, http.StatusBadRequest, unknown.Code)

	w := httptest.NewRecorder()
	env.serve(w, httptest.NewRequest("GET", "/app/audiorecords/"+record.Id+"/text", http.NoBody))
	assert.Equal(t, http.StatusBadRequest, w.Code, "незаданный вид известным не считается")
}

// failingTextRepo отказывает на чтении текста — так выглядит недоступное
// хранилище. Битую ссылку схема завести не даёт (связь проверяется при
// сохранении), и это её защита, а не пробел: остаётся отказ самого чтения.
type failingTextRepo struct{}

func (r *failingTextRepo) Put(string, string, string) (*entity.Text, error) {
	return nil, errors.New("не зовётся этой проверкой")
}

func (r *failingTextRepo) GetByID(string) (*entity.Text, error) {
	return nil, errors.New("хранилище недоступно")
}

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

	record := jobWithFile(t, env)

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

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

	// Обработчик пересобирается с отказывающим хранилищем текстов: остальная
	// цепочка та же, что и в проде.
	journal := &journalBuffer{}
	logger := slog.New(slog.NewTextHandler(journal, nil))
	handler := NewAppHandler(
		env.handler.recordRepo,
		&failingTextRepo{},
		env.handler.structureRepo,
		env.handler.fileRepo,
		env.handler.trsService,
		logger,
	)

	mounts := ServiceMounts(
		AppChain(handler.Routes(), env.users, testTrustedNetworks(t), nil, logger),
		http.NotFoundHandler(),
	)
	mux := BuildHandler(mounts, NewWebappHandler(builtDist(), true, logger), logger)

	req := httptest.NewRequest("GET", "/app/audiorecords/"+record.Id+"/text?view="+entity.TextViewTranscript, http.NoBody)
	asUser(req, env.login)
	w := httptest.NewRecorder()
	mux.ServeHTTP(w, req)

	assert.Equal(t, http.StatusInternalServerError, w.Code,
		"отказ хранилища не выдаётся за отсутствие записи")
	assert.NotContains(t, w.Body.String(), "хранилище недоступно", "внутренности наружу не выходят")
	assert.Contains(t, journal.String(), "Failed to read text",
		"владелец сервиса узнаёт об аварии из журнала")
}

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

	record := jobWithFile(t, env)
	before := countJobs(t, env)

	status := httptest.NewRecorder()
	env.serve(status, httptest.NewRequest("GET", "/api/status/"+record.Id, http.NoBody))
	assert.Equal(t, http.StatusOK, status.Code)
	assert.Contains(t, status.Body.String(), "приложение", "прежний адрес отдал не разметку")
	assert.NotContains(t, status.Body.String(), entity.StateUploaded,
		"прежний адрес отдал данные записи")

	intake := httptest.NewRecorder()
	env.serve(intake, createMultipartRequestAt(t, "/api/audio", "запись.mp3", []byte("данные")))
	assert.Equal(t, http.StatusMethodNotAllowed, intake.Code)
	assert.Equal(t, before, countJobs(t, env), "прежний адрес приёма завёл запись")
}
