package http

import (
	"fmt"
	"io"
	"net/http"
	"net/url"
	"strconv"
	"strings"

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

// contentTypes — тип содержимого по **известному** формату.
//
// Ключи здесь — значения той же единой точки, что и метка метрики: формат копии
// сперва приводится `metrics.FormatLabel`, и только приведённое ищется в этой
// карте. Справочник системы не спрашивается вовсе — он читается из файла хоста,
// которого в рабочем слое образа нет, и тип содержимого стал бы функцией того,
// где сервис собран.
//
// Перечень закрыт, и это главное его свойство. Расширение приходит из имени,
// данного отправителем, и потому может быть чем угодно: `запись.html` без
// приведения ушёл бы ответом `text/html`, который браузер показывает как
// страницу. Всё, чего в перечне нет, отдаётся `application/octet-stream` с
// расположением `attachment` — тип, который браузер не исполняет.
//
// Полноту перечня держит проверка: всякий формат, который сервис объявляет
// диалогу выбора файла, обязан иметь здесь тип содержимого.
var contentTypes = map[string]string{
	"aac":  "audio/aac",
	"avi":  "video/x-msvideo",
	"flac": "audio/flac",
	"m4a":  "audio/mp4",
	"mkv":  "video/x-matroska",
	"mov":  "video/quicktime",
	"mp3":  "audio/mpeg",
	"mp4":  "video/mp4",
	"oga":  "audio/ogg",
	"ogg":  "audio/ogg",
	"opus": "audio/ogg",
	"wav":  "audio/wav",
	"webm": "video/webm",
	"wma":  "audio/x-ms-wma",
}

// Расположение ответа. Известному формату — показ на месте: проигрыватель в
// браузере иначе не откроет запись. Всему прочему — сохранение: содержимым и
// расширением распоряжается отправитель, а `attachment` браузер не исполняет.
const (
	dispositionInline     = "inline"
	dispositionAttachment = "attachment"
)

// unknownContentType — тип содержимого всего, чего нет в закрытом перечне.
const unknownContentType = "application/octet-stream"

// GetRecordFile отдаёт копию записи её владельцу.
//
// **Порядок проверок один: владение записью судится до разбора значения копии.**
// Неизвестное либо незаданное значение копии у чужой и у несуществующей записи
// даёт тот же ответ, что и неизвестный идентификатор. Неотличимость чужой записи
// от несуществующей главнее формы ответа на негодный ввод: разбор параметра,
// выполненный раньше, отвечал бы одинаково на чужую и на неизвестную только
// случайно, а стоило бы ответам разойтись — по этой разнице перебирался бы
// список заведённых записей одним негодным параметром.
func (h *AppHandler) GetRecordFile(w http.ResponseWriter, r *http.Request) {
	account, _ := AccountOf(r)

	record, err := h.readOwn(r, account.ID)
	if err != nil {
		fail(w, err)
		return
	}

	fileID, known := copyOf(record, r.URL.Query().Get(CopyParam))
	if !known {
		fail(w, errWithMessage(contract.ErrBadRequest, "Неизвестная копия записи"))
		return
	}
	// Копии, которой у записи ещё нет, отвечает отказ состояния: пустой ответ
	// читался бы как пустой файл, а «не найдено» слилось бы с ответом на чужую и
	// неизвестную запись — человек увидел бы его на своей записи, загруженной
	// минуту назад.
	if fileID == nil {
		fail(w, contract.ErrCopyNotReady)
		return
	}

	file, err := h.fileRepo.GetByID(*fileID)
	if err != nil {
		h.logger.Error("Failed to read the file row of a record", "error", err, "record_id", record.Id)
		fail(w, err)
		return
	}

	content, err := h.fileRepo.Open(*fileID)
	if err != nil {
		h.logger.Error("Failed to open the file of a record", "error", err, "record_id", record.Id)
		fail(w, err)
		return
	}
	defer func() { _ = content.Close() }()

	h.serveCopy(w, r, record, file, content)
}

// copyOf выбирает копию по значению параметра. Второе значение ложно у копии,
// которой сервис не знает, и у незаданной: умолчание сделало бы ответ функцией
// того, что успел записать конвейер, а не состояния записи.
func copyOf(record *entity.AudioRecord, name string) (*string, bool) {
	switch name {
	case CopyOriginal:
		return record.OriginalFileID, true
	case CopyNormalized:
		return record.NormalizedFileID, true
	}
	return nil, false
}

// serveCopy отдаёт содержимое целиком либо запрошенным куском.
//
// Выдача по частям обязательна: запись расчётного потолка — шесть часов, и
// проигрыватель в браузере перематывает её запросом диапазона, а не повторной
// загрузкой целиком.
//
// **Негодный диапазон приводится к обычному отказу сервиса** — телом той же
// формы и кодом из закрытого перечня, — а не отвечает `416` телом библиотеки.
// Негодных диапазонов два вида, и оба ведут себя одинаково: неудовлетворимый
// (начало за концом файла) и множественный (в запросе назван больше чем один
// диапазон). Второй сервис не отдаёт намеренно: ответ из нескольких частей — это
// отдельный тип содержимого со своими границами, а просит его один только
// самодельный запрос.
func (h *AppHandler) serveCopy(
	w http.ResponseWriter,
	r *http.Request,
	record *entity.AudioRecord,
	file *entity.File,
	content io.ReadSeeker,
) {
	contentType, disposition := presentationOf(file.Format)

	header := w.Header()
	header.Set("Content-Type", contentType)
	header.Set("Accept-Ranges", "bytes")
	// Имя файла на диске в ответ не идёт: имя, предлагаемое браузеру при
	// сохранении, строится из имени, данного отправителем, и лежит оно колонкой
	// записи.
	header.Set("Content-Disposition", dispositionOf(record, disposition))

	raw := r.Header.Get("Range")
	if raw == "" {
		header.Set("Content-Length", strconv.FormatInt(file.Size, 10))
		w.WriteHeader(http.StatusOK)
		h.copyBody(w, r, content, file.Size)
		return
	}

	start, length, ok := parseSingleRange(raw, file.Size)
	if !ok {
		fail(w, errWithMessage(contract.ErrBadRequest, "Запрошенный диапазон записи не читается"))
		return
	}

	if _, err := content.Seek(start, io.SeekStart); err != nil {
		h.logger.Error("Failed to seek the file of a record", "error", err, "record_id", record.Id)
		fail(w, err)
		return
	}

	header.Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, start+length-1, file.Size))
	header.Set("Content-Length", strconv.FormatInt(length, 10))
	w.WriteHeader(http.StatusPartialContent)
	h.copyBody(w, r, content, length)
}

// copyBody переливает содержимое в ответ. Запрос `HEAD` тела не получает: у него
// те же заголовки и пустое тело.
//
// Отказ переливания идёт **отладочной** строкой: он значит оборванное
// соединение — человек закрыл вкладку или перемотал запись, — и владельцу
// сервиса разбирать здесь нечего. Проглотить его молча всё же нельзя: тогда
// оборванная отдача не отличалась бы от полной ничем.
func (h *AppHandler) copyBody(w http.ResponseWriter, r *http.Request, content io.Reader, length int64) {
	if r.Method == http.MethodHead {
		return
	}

	if _, err := io.CopyN(w, content, length); err != nil {
		h.logger.Debug("Failed to send the file of a record", "error", err, "transport", "http")
	}
}

// presentationOf называет тип содержимого копии и её расположение.
//
// Оба значения выводятся из одного приведения, и порознь их выводить нельзя:
// известный формат, показанный на месте, и незнакомый, отданный на сохранение, —
// это одно решение, а два независимых дали бы `text/html` с `inline` у первого
// же расширения, которого сервис не знает.
func presentationOf(format string) (contentType, disposition string) {
	if known, ok := contentTypes[metrics.FormatLabel(format)]; ok {
		return known, dispositionInline
	}
	return unknownContentType, dispositionAttachment
}

// dispositionOf строит расположение ответа вместе с именем, предлагаемым
// браузеру при сохранении.
//
// Имя берётся у записи — то, что дал отправитель, — и кодируется по правилам
// заголовка: оно приходит извне и содержимым своим сервису не подконтрольно.
// Записи без имени получают одно расположение, без имени файла.
func dispositionOf(record *entity.AudioRecord, disposition string) string {
	if record.OriginalFilename == nil || *record.OriginalFilename == "" {
		return disposition
	}
	return disposition + "; filename*=UTF-8''" + url.PathEscape(*record.OriginalFilename)
}

// parseSingleRange разбирает заголовок диапазона.
//
// Второе значение ложно у всего, что сервис не отдаёт: у нечитаемого заголовка,
// у неудовлетворимого диапазона и у запроса, называющего больше одного
// диапазона.
func parseSingleRange(raw string, size int64) (start, length int64, ok bool) {
	const prefix = "bytes="

	spec, found := strings.CutPrefix(strings.TrimSpace(raw), prefix)
	if !found || strings.Contains(spec, ",") {
		return 0, 0, false
	}

	first, last, found := strings.Cut(strings.TrimSpace(spec), "-")
	if !found {
		return 0, 0, false
	}
	first, last = strings.TrimSpace(first), strings.TrimSpace(last)

	switch {
	case first == "":
		// Хвост записи: `bytes=-N` просит последние N байтов.
		suffix, err := strconv.ParseInt(last, 10, 64)
		if err != nil || suffix <= 0 || size == 0 {
			return 0, 0, false
		}
		if suffix > size {
			suffix = size
		}
		return size - suffix, suffix, true

	case last == "":
		start, err := strconv.ParseInt(first, 10, 64)
		if err != nil || start < 0 || start >= size {
			return 0, 0, false
		}
		return start, size - start, true

	default:
		start, err := strconv.ParseInt(first, 10, 64)
		if err != nil || start < 0 || start >= size {
			return 0, 0, false
		}
		end, err := strconv.ParseInt(last, 10, 64)
		if err != nil || end < start {
			return 0, 0, false
		}
		if end >= size {
			end = size - 1
		}
		return start, end - start + 1, true
	}
}
