package http

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

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

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

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

// unknownRecordID — идентификатор годного вида, которого нет ни у одной записи.
const unknownRecordID = "00000000000000000000000000"

// newSecondAccount заводит вторую учётную запись со своим логином.
func newSecondAccount(t *testing.T, env *testEnv) *contract.UserAccount {
	t.Helper()

	account, created, err := env.users.EnsureUser(contract.Identity{
		Login: "stranger",
		Name:  "Посторонний",
		Email: "stranger@example.com",
	})
	require.NoError(t, err)
	require.True(t, created)

	return account
}

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

	job := jobWithFile(t, env)
	newSecondAccount(t, env)

	foreign := env.getAs("stranger", "/app/audiorecords/"+job.Id)
	unknown := env.getAs("stranger", "/app/audiorecords/"+unknownRecordID)

	require.Equal(t, http.StatusNotFound, foreign.Code, "чужая запись не отдаётся")
	assert.Equal(t, unknown.Code, foreign.Code, "код тот же, что у неизвестного идентификатора")
	assert.JSONEq(t, unknown.Body.String(), foreign.Body.String(), "и тело то же")

	assert.NotContains(t, foreign.Body.String(), entity.StateUploaded)
}

// Нечитаемый по виду идентификатор отвечает тем же, чем неизвестный: разбор идёт
// на границе, и до базы такой запрос не доходит вовсе.
func TestRecordCard_MalformedIDLooksLikeUnknown(t *testing.T) {
	env := setupTestEnv(t, readableMetaViewer())

	malformed := env.getOwn("/app/audiorecords/не-идентификатор")
	unknown := env.getOwn("/app/audiorecords/" + unknownRecordID)

	assert.Equal(t, http.StatusNotFound, malformed.Code)
	assert.JSONEq(t, unknown.Body.String(), malformed.Body.String())
}

// Владельцем принятой записи становится узнанный предъявитель — и у записи, и у
// её копии.
func TestCreateTranscribeJob_OwnerIsSession(t *testing.T) {
	env := setupTestEnv(t, readableMetaViewer())

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

	response := intakeItemOf(t, w)

	var owner string
	require.NoError(t, env.db.Reader().
		QueryRow("SELECT owner_id FROM audio_records WHERE id = ?", response.ID).Scan(&owner))
	assert.Equal(t, env.account.ID, owner, "владелец записи — предъявитель")

	var fileOwner string
	require.NoError(t, env.db.Reader().
		QueryRow("SELECT owner_id FROM files WHERE record_id = ?", response.ID).Scan(&fileOwner))
	assert.Equal(t, env.account.ID, fileOwner, "владелец копии — он же")
}

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

	stranger := newSecondAccount(t, env)

	req := createMultipartRequest(t, "sample.mp3", []byte("запись"))
	query := req.URL.Query()
	query.Set("owner", stranger.ID)
	req.URL.RawQuery = query.Encode()

	w := httptest.NewRecorder()
	env.serve(w, req)
	require.Equal(t, http.StatusCreated, w.Code)

	response := intakeItemOf(t, w)

	var owner string
	require.NoError(t, env.db.Reader().
		QueryRow("SELECT owner_id FROM audio_records WHERE id = ?", response.ID).Scan(&owner))
	assert.Equal(t, env.account.ID, owner)
}

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

	job := jobWithFile(t, env)
	newSecondAccount(t, env)

	link := "/app/audiorecords/" + job.Id + "/file?" + CopyParam + "=" + CopyOriginal

	mine := env.getOwn(link)
	require.Equal(t, http.StatusOK, mine.Code, "свой файл отдаётся")
	assert.Equal(t, "запись", mine.Body.String(), "и отдаётся содержимым")

	foreign := env.getAs("stranger", link)
	unknown := env.getAs("stranger",
		"/app/audiorecords/"+unknownRecordID+"/file?"+CopyParam+"="+CopyOriginal)

	assert.Equal(t, http.StatusNotFound, foreign.Code, "чужой файл не отдаётся")
	assert.Equal(t, unknown.Code, foreign.Code, "код тот же, что у неизвестной записи")
	assert.JSONEq(t, unknown.Body.String(), foreign.Body.String(), "и тело то же")
	assert.NotContains(t, foreign.Body.String(), "запись", "содержимого в отказе нет")
}

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

	job := jobWithFile(t, env)
	newSecondAccount(t, env)

	foreign := env.getAs("stranger", "/app/audiorecords/"+job.Id+"/file?"+CopyParam+"=whatever")
	unknown := env.getAs("stranger", "/app/audiorecords/"+unknownRecordID+"/file?"+CopyParam+"=whatever")
	missing := env.getAs("stranger", "/app/audiorecords/"+unknownRecordID+"/file")

	assert.Equal(t, http.StatusNotFound, foreign.Code)
	assert.Equal(t, unknown.Code, foreign.Code)
	assert.JSONEq(t, unknown.Body.String(), foreign.Body.String())
	assert.JSONEq(t, missing.Body.String(), foreign.Body.String(),
		"незаданная копия у чужой записи отвечает не как неизвестная запись")
}

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

	job := jobWithFile(t, env)

	w := env.getOwn("/app/audiorecords/" + job.Id + "/file?" + CopyParam + "=" + CopyNormalized)

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

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

	unknown := env.getOwn("/app/audiorecords/" + unknownRecordID + "/file?" + CopyParam + "=" + CopyOriginal)
	assert.NotEqual(t, unknown.Code, w.Code, "отказ состояния слился с ответом на неизвестную запись")
}

// Копия, которой сервис не знает, и незаданная копия у **своей** записи дают
// отказ по негодному вводу.
func TestFileDownload_UnknownCopyIsBadRequest(t *testing.T) {
	env := setupTestEnv(t, readableMetaViewer())

	job := jobWithFile(t, env)

	for name, query := range map[string]string{
		"копия неизвестна": "?" + CopyParam + "=whatever",
		"копия не названа": "",
	} {
		t.Run(name, func(t *testing.T) {
			w := env.getOwn("/app/audiorecords/" + job.Id + "/file" + query)

			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 TestFileDownload_RangeGivesRequestedSlice(t *testing.T) {
	env := setupTestEnv(t, readableMetaViewer())

	const content = "0123456789abcdef"

	created := httptest.NewRecorder()
	env.serve(created, createMultipartRequest(t, "sample.mp3", []byte(content)))
	require.Equal(t, http.StatusCreated, created.Code)

	server := env.liveServer(t)
	link := "/app/audiorecords/" + intakeItemOf(t, created).ID + "/file?" + CopyParam + "=" + CopyOriginal

	res := env.liveRequest(t, server, link, map[string]string{"Range": "bytes=4-9"})

	require.Equal(t, http.StatusPartialContent, res.StatusCode)
	assert.Equal(t, "456789", string(res.Body), "ответ несёт не запрошенный кусок")
	assert.Equal(t, int64(len("456789")), res.ContentLength, "длина ответа — длина файла целиком")
	assert.Equal(t, "bytes 4-9/16", res.Header.Get("Content-Range"))
}

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

	created := httptest.NewRecorder()
	env.serve(created, createMultipartRequest(t, "sample.mp3", []byte("0123456789")))
	require.Equal(t, http.StatusCreated, created.Code)

	server := env.liveServer(t)
	link := "/app/audiorecords/" + intakeItemOf(t, created).ID + "/file?" + CopyParam + "=" + CopyOriginal

	cases := map[string]string{
		"неудовлетворимый диапазон": "bytes=99999999-",
		"два диапазона в запросе":   "bytes=0-1,4-5",
	}

	for name, rangeHeader := range cases {
		t.Run(name, func(t *testing.T) {
			res := env.liveRequest(t, server, link, map[string]string{"Range": rangeHeader})

			require.Equal(t, http.StatusBadRequest, res.StatusCode, "ответ не `400`")
			assert.NotContains(t, res.Header.Get("Content-Type"), "multipart",
				"сервис отдал ответ из нескольких частей")

			var body map[string]any
			require.NoError(t, json.Unmarshal(res.Body, &body))
			assert.Contains(t, body, "error_code")
			assert.Contains(t, body, "message")
		})
	}
}

// Имя файла на диске в ответ не идёт: имя, предлагаемое браузеру при сохранении,
// строится из имени, данного отправителем.
func TestFileDownload_CarriesNoStoredName(t *testing.T) {
	env := setupTestEnv(t, readableMetaViewer())

	created := httptest.NewRecorder()
	env.serve(created, createMultipartRequest(t, "разговор.mp3", []byte("запись")))
	require.Equal(t, http.StatusCreated, created.Code)

	recordID := intakeItemOf(t, created).ID

	w := env.getOwn("/app/audiorecords/" + recordID + "/file?" + CopyParam + "=" + CopyOriginal)
	require.Equal(t, http.StatusOK, w.Code)

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

	headers := w.Result().Header
	for _, value := range headers.Values("Content-Disposition") {
		assert.NotContains(t, value, names[0], "имя файла на диске уехало в ответ")
	}
	assert.Contains(t, strings.Join(headers.Values("Content-Disposition"), " "), "%D1%80",
		"имя, данное отправителем, в ответ не попало")
}

// Перечня доступных копий карточка не объявляет: копий две, и каждая выводится
// из рубежа записи, который карточка несёт и так.
func TestRecordCard_HasNoCopyList(t *testing.T) {
	env := setupTestEnv(t, readableMetaViewer())

	job := jobWithFile(t, env)

	w := env.getOwn("/app/audiorecords/" + job.Id)
	require.Equal(t, http.StatusOK, w.Code)

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

	assert.NotContains(t, raw, "available_copies")
	assert.NotContains(t, raw, "copies")
}
