package http

import (
	"encoding/base64"
	"encoding/json"
	"fmt"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"

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

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

// pageOf спрашивает страницу записей от имени вошедшего.
func pageOf(t *testing.T, env *testEnv, query string) PageView {
	t.Helper()

	w := httptest.NewRecorder()
	env.serve(w, httptest.NewRequest("GET", "/app/audiorecords"+query, http.NoBody))
	require.Equal(t, http.StatusOK, w.Code, "тело: %s", w.Body.String())

	var page PageView
	require.NoError(t, json.Unmarshal(w.Body.Bytes(), &page))
	return page
}

// acceptRecords заводит несколько записей приёмом — тем же путём, каким они
// появляются в проде.
func acceptRecords(t *testing.T, env *testEnv, n int) {
	t.Helper()

	for i := range n {
		w := httptest.NewRecorder()
		env.serve(w, createMultipartRequest(t, fmt.Sprintf("запись-%d.mp3", i), []byte("данные")))
		require.Equal(t, http.StatusCreated, w.Code)
	}
}

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

	acceptRecords(t, env, 3)

	page := pageOf(t, env, "?limit=2")

	require.Len(t, page.Items, 2)
	assert.Equal(t, 3, page.TotalItems, "общее число не зависит от размера страницы")
	assert.Equal(t, "запись-2.mp3", *page.Items[0].OriginalFilename, "первой стоит заведённая последней")
	require.NotNil(t, page.NextCursor, "есть что читать дальше")
}

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

	acceptRecords(t, env, 4)

	first := pageOf(t, env, "?limit=2")
	require.Len(t, first.Items, 2)
	require.NotNil(t, first.NextCursor)

	// Человек загружает ещё одну запись, не закрыв список, — штатный сценарий
	// экрана загрузки.
	acceptRecords(t, env, 1)

	second := pageOf(t, env, "?limit=2&cursor="+*first.NextCursor)

	seen := map[string]bool{}
	for _, item := range first.Items {
		seen[item.ID] = true
	}
	for _, item := range second.Items {
		assert.False(t, seen[item.ID], "элемент первой страницы не приходит вторым разом")
		seen[item.ID] = true
	}

	// Ни одна из четырёх исходных записей не потеряна: дочитываем до конца.
	cursor := second.NextCursor
	for cursor != nil {
		page := pageOf(t, env, "?limit=2&cursor="+*cursor)
		for _, item := range page.Items {
			seen[item.ID] = true
		}
		cursor = page.NextCursor
	}
	assert.Len(t, seen, 4, "все четыре исходные записи дочитаны, ни одна не пропущена")

	// Пятая, заведённая уже после начала листания, стоит **выше** окна и потому
	// движением вперёд не приходит — это и есть искомое свойство ключа. Человек
	// видит её, перечитав первую страницу.
	fresh := pageOf(t, env, "?limit=2")
	assert.Equal(t, 5, fresh.TotalItems)
	assert.Equal(t, "запись-0.mp3", *fresh.Items[0].OriginalFilename,
		"свежая запись видна сверху при перечитывании")
}

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

	acceptRecords(t, env, 4)

	first := pageOf(t, env, "")
	second := pageOf(t, env, "")

	require.Len(t, first.Items, 4)
	for i := range first.Items {
		assert.Equal(t, first.Items[i].ID, second.Items[i].ID,
			"порядок не меняется от прогона к прогону")
	}
}

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

	acceptRecords(t, env, 2)

	page := pageOf(t, env, fmt.Sprintf("?limit=%d", MaxPageLimit+500))
	assert.LessOrEqual(t, len(page.Items), MaxPageLimit)

	for _, bad := range []string{"0", "-3", "много"} {
		w := httptest.NewRecorder()
		env.serve(w, httptest.NewRequest("GET", "/app/audiorecords?limit="+bad, http.NoBody))
		assert.Equal(t, http.StatusBadRequest, w.Code, "размер %q негоден", bad)
	}
}

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

	acceptRecords(t, env, 1)

	cases := []struct {
		name   string
		cursor string
	}{
		{
			name:   "не декодируется вовсе",
			cursor: "мусор",
		},
		{
			name:   "декодируется, но разделителя нет",
			cursor: base64.RawURLEncoding.EncodeToString([]byte("без-разделителя")),
		},
		{
			name:   "декодируется, но времени нет",
			cursor: base64.RawURLEncoding.EncodeToString([]byte("|только-идентификатор")),
		},
		{
			name:   "декодируется, но идентификатора нет",
			cursor: base64.RawURLEncoding.EncodeToString([]byte("2026-08-15T10:00:00Z|")),
		},
		{
			name:   "пара пуста целиком",
			cursor: base64.RawURLEncoding.EncodeToString([]byte("|")),
		},
	}

	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			w := httptest.NewRecorder()
			env.serve(w, httptest.NewRequest("GET", "/app/audiorecords?cursor="+tc.cursor, http.NoBody))
			require.Equal(t, http.StatusBadRequest, w.Code)

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

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

	acceptRecords(t, env, 1)

	all := pageOf(t, env, "")
	require.Len(t, all.Items, 1)

	// Тема заводится напрямую: словарь тем свой у каждого человека, и пишет его
	// задача языковой модели, которой ещё нет.
	topicID := newTopic(t, env, env.account.ID, "семейный архив")
	attachTopic(t, env, all.Items[0].ID, topicID)

	record, err := env.handler.recordRepo.GetByID(all.Items[0].ID, env.account.ID)
	require.NoError(t, err)

	page := pageOf(t, env, "")
	require.Len(t, page.Items, 1)
	assert.Equal(t, []string{"семейный архив"}, page.Items[0].Topics,
		"страница отдаёт название темы, а не её идентификатор")

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

	var view RecordView
	require.NoError(t, json.Unmarshal(card.Body.Bytes(), &view))
	assert.Equal(t, []string{"семейный архив"}, view.Topics)
}

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

	acceptRecords(t, env, 3)

	all := pageOf(t, env, "")
	require.Len(t, all.Items, 3)

	repo := env.handler.recordRepo

	halted, err := repo.GetByID(all.Items[0].ID, env.account.ID)
	require.NoError(t, err)
	halted.Halt(entity.HaltReasonStuck, "застряла")
	require.NoError(t, repo.Save(halted, ""))

	done, err := repo.GetByID(all.Items[1].ID, env.account.ID)
	require.NoError(t, err)
	done.MoveToState(entity.StateDone)
	require.NoError(t, repo.Save(done, ""))

	counts := map[string]int{}
	for _, filter := range []string{"working", "halted", "done"} {
		page := pageOf(t, env, "?filter="+filter)
		for _, item := range page.Items {
			counts[item.ID]++
		}
	}

	require.Len(t, counts, 3, "все три записи видны отбором")
	for id, seen := range counts {
		assert.Equal(t, 1, seen, "запись %s приходит ровно в одном состоянии", id)
	}

	// И остановленная приходит с причиной: без неё признак не говорит человеку,
	// чего ждать.
	haltedPage := pageOf(t, env, "?filter=halted")
	require.Len(t, haltedPage.Items, 1)
	require.NotNil(t, haltedPage.Items[0].HaltReason)
	assert.Equal(t, entity.HaltReasonStuck, *haltedPage.Items[0].HaltReason)
}

// Неизвестное состояние отбора — негодный ввод, а не пустая выборка.
func TestList_UnknownFilterIsRejected(t *testing.T) {
	env := setupTestEnv(t, readableMetaViewer())

	w := httptest.NewRecorder()
	env.serve(w, httptest.NewRequest("GET", "/app/audiorecords?filter=неизвестно", http.NoBody))
	assert.Equal(t, http.StatusBadRequest, w.Code)
}

// Чужих записей в странице нет.
func TestList_ShowsOnlyOwnRecords(t *testing.T) {
	env := setupTestEnv(t, readableMetaViewer())

	acceptRecords(t, env, 2)

	newSecondAccount(t, env)

	w := httptest.NewRecorder()
	req := httptest.NewRequest("GET", "/app/audiorecords", http.NoBody)
	asUser(req, "stranger")
	env.mux.ServeHTTP(w, req)
	require.Equal(t, http.StatusOK, w.Code)

	var page PageView
	require.NoError(t, json.Unmarshal(w.Body.Bytes(), &page))
	assert.Empty(t, page.Items, "чужие записи в страницу не попадают")
	assert.Equal(t, 0, page.TotalItems)
}

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

	acceptRecords(t, env, 1)

	all := pageOf(t, env, "")
	require.Len(t, all.Items, 1)

	repo := env.handler.recordRepo
	record, err := repo.GetByID(all.Items[0].ID, env.account.ID)
	require.NoError(t, err)

	const marker = "СОДЕРЖИМОЕ-РАСШИФРОВКИ-МАРКЕР"
	texts := env.handler.textRepo
	transcript, err := texts.Put(record.Id, entity.TextKindTranscript, marker)
	require.NoError(t, err)
	record.TranscriptTextID = &transcript.Id
	require.NoError(t, repo.Save(record, ""))

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

	assert.NotContains(t, w.Body.String(), marker,
		"текст расшифровки в страницу не попадает")
}

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

	w := httptest.NewRecorder()
	env.serve(w, createMultipartRequest(t, "запись.mp3", []byte("данные")))
	require.Equal(t, http.StatusCreated, w.Code)

	var intake []map[string]json.RawMessage
	require.NoError(t, json.Unmarshal(w.Body.Bytes(), &intake))
	require.Len(t, intake, 1)

	item := httptest.NewRecorder()
	env.serve(item, httptest.NewRequest("GET", "/app/audiorecords", http.NoBody))
	var rawPage struct {
		Items []map[string]json.RawMessage `json:"items"`
	}
	require.NoError(t, json.Unmarshal(item.Body.Bytes(), &rawPage))
	require.Len(t, rawPage.Items, 1)

	id := strings.Trim(string(intake[0]["id"]), `"`)
	card := httptest.NewRecorder()
	env.serve(card, httptest.NewRequest("GET", "/app/audiorecords/"+id, http.NoBody))
	var rawCard map[string]json.RawMessage
	require.NoError(t, json.Unmarshal(card.Body.Bytes(), &rawCard))

	// Карточка = элемент страницы плюс перечень доступных видов.
	assert.Equal(t, fieldNames(rawPage.Items[0]), fieldNames(rawCard, "available_views"),
		"карточка отличается от элемента страницы ровно перечнем видов")

	// Элемент ответа приёма = карточка плюс признак повтора. Перечень видов есть
	// у обоих: у свежей записи он пуст, но на проводе присутствует — отсутствие
	// поля и пустой перечень приложение не различит.
	assert.Equal(t, fieldNames(rawCard), fieldNames(intake[0], "duplicate"),
		"элемент ответа приёма отличается от карточки ровно признаком повтора")
	assert.Contains(t, intake[0], "available_views",
		"перечень видов есть и в ответе приёма, пустым")
}

// fieldNames отдаёт отсортированные имена полей за вычетом названных.
func fieldNames(raw map[string]json.RawMessage, except ...string) []string {
	skip := map[string]bool{}
	for _, name := range except {
		skip[name] = true
	}

	out := []string{}
	for name := range raw {
		if !skip[name] {
			out = append(out, name)
		}
	}
	sortStrings(out)
	return out
}

func sortStrings(v []string) {
	for i := 1; i < len(v); i++ {
		for j := i; j > 0 && v[j] < v[j-1]; j-- {
			v[j], v[j-1] = v[j-1], v[j]
		}
	}
}

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

	acceptRecords(t, env, 1)
	all := pageOf(t, env, "")
	require.Len(t, all.Items, 1)

	stranger := newSecondAccount(t, env)

	foreign := newTopic(t, env, stranger.ID, "ЧУЖАЯ-ТЕМА-МАРКЕР")
	attachTopic(t, env, all.Items[0].ID, foreign)

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

	assert.NotContains(t, w.Body.String(), "ЧУЖАЯ-ТЕМА-МАРКЕР",
		"название чужой темы наружу не выходит")
}
