package http

import (
	"errors"
	"net/http"

	"github.com/pocketbase/pocketbase/apis"
	"github.com/pocketbase/pocketbase/core"
	"github.com/pocketbase/pocketbase/tools/hook"
	"github.com/pocketbase/pocketbase/tools/router"

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

// Машиночитаемые коды отказа. Перечень закрыт и объявлен одним местом: код
// HTTP не различает «файл негоден», «поля записи нет» и «неизвестный вид» — все
// три `400`, — а приложению надо решать, предлагать ли повтор и что показать
// человеку. Разбор русской фразы был бы единственным оставшимся путём.
const (
	CodeUnauthorized    = "unauthorized"
	CodeForbidden       = "forbidden"
	CodeNotFound        = "not_found"
	CodeBadRequest      = "bad_request"
	CodeTooLarge        = "too_large"
	CodeNotReady        = "not_ready"
	CodeTooManyRequests = "too_many_requests"
	CodeInternal        = "internal"
)

// ErrorBody — единая форма тела отказа на всех адресах приложения.
//
// Два поля, а не одно: код разбирает программа, сообщение читает человек. Сырой
// текст ошибки сюда не попадает — ни `err.Error()`, ни детали устройства: имена
// внешних сервисов, пути на диске, ключи файлов. Полная ошибка остаётся в
// журнале владельца сервиса.
//
// Limit заполняется только у отказа по размеру: экран обязан показать предел
// числом, а не пересказать его словами.
type ErrorBody struct {
	Code    string `json:"error_code"`
	Message string `json:"message"`
	Limit   *int64 `json:"limit,omitempty"`
}

// mapDomainError — **единственная** точка, где доменная ошибка становится кодом
// ответа и сообщением. Прежде такой точки не было вовсе, и каждый обработчик
// решал сам: опрос отвечал «записи нет» на упавшую базу, а приём — «внутренняя
// ошибка» на негодный файл. Человек читал первое как «моя запись пропала», а
// второе не говорило ему ничего.
//
// Ветвь по умолчанию определена намеренно: новая штатная ветвь отказа заводится
// добавлением сюда, а не строкой в обработчике. Иначе обычный конфликт уезжает в
// `internal`, и владелец сервиса видит в журнале аварию там, где её нет.
func mapDomainError(err error) (int, ErrorBody) {
	switch {
	case errors.Is(err, contract.ErrBadRequest):
		// Причина у всех негодных вводов одна, а сказать человеку надо разное:
		// «размер страницы отрицательный» и «неизвестный вид текста» ведут к
		// разным действиям. Свой текст приезжает обёрткой; его нет — говорим
		// общее. Сырой `err.Error()` наружу при этом не идёт: сообщение пишем мы,
		// а не библиотека.
		message := "Запрос составлен неверно"
		var owned *messagedError
		if errors.As(err, &owned) {
			message = owned.message
		}
		return http.StatusBadRequest, ErrorBody{Code: CodeBadRequest, Message: message}

	case errors.Is(err, contract.ErrRecordUnreadable):
		return http.StatusBadRequest, ErrorBody{
			Code:    CodeBadRequest,
			Message: "Не удалось прочитать запись: формат не распознан или файл повреждён",
		}

	case errors.Is(err, contract.ErrRecordTooLarge):
		limit := entity.MaxRecordSize
		return http.StatusRequestEntityTooLarge, ErrorBody{
			Code:    CodeTooLarge,
			Message: "Запись больше допустимого размера",
			Limit:   &limit,
		}

	case errors.Is(err, contract.ErrTextNotReady):
		return http.StatusConflict, ErrorBody{
			Code:    CodeNotReady,
			Message: "Текст этого вида для записи ещё не готов",
		}

	case errors.Is(err, contract.ErrNotFound):
		message := "Адрес не найден"
		var owned *messagedError
		if errors.As(err, &owned) {
			message = owned.message
		}
		return http.StatusNotFound, ErrorBody{Code: CodeNotFound, Message: message}

	case errors.Is(err, contract.ErrUnauthorized):
		return http.StatusUnauthorized, ErrorBody{
			Code:    CodeUnauthorized,
			Message: "Требуется вход",
		}

	case errors.Is(err, contract.ErrOwnerRequired):
		return http.StatusForbidden, ErrorBody{
			Code:    CodeForbidden,
			Message: "У вашей сессии нет учётной записи пользователя",
		}
	}

	// Чужая запись, ничья и несуществующая отвечают одним и тем же: по разнице
	// ответов иначе перебирается список заведённых записей.
	var notFound *contract.JobNotFoundError
	if errors.As(err, &notFound) {
		return http.StatusNotFound, ErrorBody{
			Code:    CodeNotFound,
			Message: "Запись не найдена",
		}
	}

	return http.StatusInternalServerError, ErrorBody{
		Code:    CodeInternal,
		Message: "Внутренняя ошибка сервиса",
	}
}

// fail отвечает отказом по доменной ошибке — единственный способ, которым отказ
// уходит наружу с адресов приложения.
func fail(e *core.RequestEvent, err error) error {
	status, body := mapDomainError(err)
	return e.JSON(status, body)
}

// OneErrorForm переводит отказ библиотеки в нашу форму тела.
//
// Своей единой точки мало: часть отказов на адресах приложения рождается **не в
// обработчике** и до `mapDomainError` не доходит вовсе. Их три, и все три частые:
// предел тела (`413`), ограничитель частоты (`429`) и неизвестный путь под нашим
// корнем (`404`). Каждый уходил бы телом `router.ApiError` — без машиночитаемого
// кода, — и форм отказа на адресах приложения оказалось бы две вместо одной.
//
// Дороже всего первый: «запись больше потолка» — самый частый отказ у человека
// на мобильной сети, и приложение, разобрав чужое тело, показало бы ветвь
// «внутренняя ошибка» вместо предела числом.
//
// Слой стоит **самым внешним**: он обязан видеть отказ, рождённый слоями ниже
// него, включая предел тела и ограничитель частоты.
func OneErrorForm() *hook.Handler[*core.RequestEvent] {
	return &hook.Handler[*core.RequestEvent]{
		Id:       "transcriberOneErrorForm",
		Priority: apis.DefaultRateLimitMiddlewarePriority - 100,
		Func: func(e *core.RequestEvent) error {
			err := e.Next()
			if err == nil {
				return nil
			}

			// Обработчик, ответивший через fail, ошибки не возвращает — его
			// форма уже ушла в ответ, и сюда доходит только чужая.
			var apiErr *router.ApiError
			if !errors.As(err, &apiErr) {
				return err
			}

			status, translated := translateAPIError(apiErr)
			return e.JSON(status, translated)
		},
	}
}

// translateAPIError переводит отказ библиотеки в перечень наших кодов. Ветви
// названы поимённо: значение вне перечня приложению разбирать нечем.
func translateAPIError(apiErr *router.ApiError) (int, ErrorBody) {
	switch apiErr.Status {
	case http.StatusRequestEntityTooLarge:
		limit := entity.MaxRecordSize
		return http.StatusRequestEntityTooLarge, ErrorBody{
			Code:    CodeTooLarge,
			Message: "Запись больше допустимого размера",
			Limit:   &limit,
		}
	case http.StatusTooManyRequests:
		return http.StatusTooManyRequests, ErrorBody{
			Code:    CodeTooManyRequests,
			Message: "Слишком много запросов подряд, попробуйте позже",
		}
	case http.StatusNotFound:
		return http.StatusNotFound, ErrorBody{
			Code:    CodeNotFound,
			Message: "Адрес не найден",
		}
	case http.StatusUnauthorized:
		return mapDomainError(contract.ErrUnauthorized)
	}

	return apiErr.Status, ErrorBody{
		Code:    CodeInternal,
		Message: "Внутренняя ошибка сервиса",
	}
}

// RequireUser — слой предъявления адресов приложения.
//
// Своя проверка, а не `apis.RequireAuth`, по одной причине: отказ библиотеки
// уходит **её** формой тела, и на адресах приложения оказалось бы две формы
// отказа вместо одной. Проверка при этом та же самая, и коллекция названа
// поимённо: без имени пускается всякая учётная запись хранилища, включая
// владельца панели, — а записи в коллекции пользователей у него нет, и владельцем
// записи он стать не может.
//
// Отказ наступает **до чтения тела**: запись, за которую не заплатит узнанный
// отправитель, не должна попасть даже в память, а позже пришлось бы убирать уже
// уложенный файл — чего сервис не умеет вовсе.
func RequireUser(usersCollection string) *hook.Handler[*core.RequestEvent] {
	return &hook.Handler[*core.RequestEvent]{
		Id: "transcriberRequireUser",
		// Сразу после слоя, который читает предъявленный токен: раньше него
		// `e.Auth` ещё пуст, и всякий запрос получал бы отказ.
		Priority: apis.DefaultLoadAuthTokenMiddlewarePriority + 1,
		Func: func(e *core.RequestEvent) error {
			if e.Auth == nil {
				return fail(e, contract.ErrUnauthorized)
			}

			// Узнан он всё же узнан, а учётной записи пользователя у него нет:
			// код здесь другой не по оплошности. `401` значит «предъяви себя», а
			// предъявитель себя предъявил.
			if e.Auth.Collection().Name != usersCollection {
				return fail(e, contract.ErrOwnerRequired)
			}

			return e.Next()
		},
	}
}
