package http

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

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

// Машиночитаемые коды отказа. Перечень закрыт и объявлен одним местом: код
// HTTP не различает «файл негоден», «поля записи нет» и «неизвестный вид» — все
// три `400`, — а приложению надо решать, предлагать ли повтор и что показать
// человеку. Разбор русской фразы был бы единственным оставшимся путём.
//
// Кода `forbidden` в перечне больше нет: его единственным случаем был владелец
// панели, предъявивший собственный токен хранилища. Ни панели, ни токенов у
// сервиса не осталось, а узнавание по заголовку учётную запись заводит само —
// предъявителя без неё не бывает.
const (
	CodeUnauthorized    = "unauthorized"
	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.ErrCopyNotReady):
		return http.StatusConflict, ErrorBody{
			Code:    CodeNotReady,
			Message: "Этой копии записи ещё нет",
		}

	case errors.Is(err, contract.ErrTooManyRequests):
		return http.StatusTooManyRequests, ErrorBody{
			Code:    CodeTooManyRequests,
			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: "Сервис вас не узнал",
		}
	}

	// Чужая запись, ничья и несуществующая отвечают одним и тем же: по разнице
	// ответов иначе перебирается список заведённых записей.
	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(w http.ResponseWriter, err error) {
	status, body := mapDomainError(err)
	writeJSON(w, status, body)
}

// writeJSON отдаёт тело ответа. Отказ записи в журнал не идёт: соединение к
// этому моменту оборвано, и сказать о нём некому — строка о каждом закрытом
// браузере наполняла бы журнал ничем.
func writeJSON(w http.ResponseWriter, status int, body any) {
	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	w.WriteHeader(status)
	_ = json.NewEncoder(w).Encode(body)
}

// errWithMessage приклеивает к признаку негодного ввода свой текст: причина у
// всех одна, а сказать человеку надо разное.
func errWithMessage(base error, message string) error {
	return &messagedError{base: base, message: message}
}

type messagedError struct {
	base    error
	message string
}

func (e *messagedError) Error() string { return e.message }
func (e *messagedError) Unwrap() error { return e.base }

// Recover — верхняя граница обработчика: паникующий запрос отдаёт `500` нашей
// формой тела, а процесс живёт.
//
// Слой свой, потому что своим стал и роутер: прежде его вешала чужая библиотека.
// У воркеров такой границы по-прежнему нет — паника в шаге конвейера роняет
// процесс целиком, и это осознанно.
func Recover(logger *slog.Logger) func(http.Handler) http.Handler {
	if logger == nil {
		logger = slog.Default()
	}

	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			defer func() {
				if recovered := recover(); recovered != nil {
					logger.Error("Handler panicked",
						"error", recovered, "transport", "http")
					fail(w, errors.New("handler panicked"))
				}
			}()

			next.ServeHTTP(w, r)
		})
	}
}
