package httpapi

import (
	"compress/gzip"
	"errors"
	"io"
	"net/http"
	"strings"
	"time"

	"git.vakhrushev.me/av/healthlog/internal/ingest"
)

// ingestResponse — что видит Health Auto Export в ответ на принятый пакет.
type ingestResponse struct {
	DeliveryID string `json:"delivery_id"`
	Bytes      int64  `json:"bytes"`
	SHA256     string `json:"sha256"`
}

// handleIngest принимает пакет от Health Auto Export.
//
// Код ответа отражает ДОСТАВКУ, а не разбор: 200 означает «тело сохранено в
// архив», и этого достаточно, потому что разобрать сохранённое можно всегда.
func (a *api) handleIngest(w http.ResponseWriter, r *http.Request) {
	a.extendWriteDeadline(w, r)

	body, err := readBody(w, r, a.maxBody)
	if err != nil {
		writeReadError(w, err)
		return
	}

	res, err := a.ingest.Accept(r.Context(), body, a.metaFromHeaders(r))
	if err != nil {
		// Ошибку уже залогировал use-case; транспорт только переводит её в ответ.
		if errors.Is(err, ingest.ErrMalformed) {
			writeError(w, http.StatusBadRequest, "некорректный формат пакета")
			return
		}
		writeError(w, http.StatusInternalServerError, "внутренняя ошибка")
		return
	}

	// Ответ приёма — три поля; обрыв записи на нём означает ушедшего клиента, а
	// доставка уже сохранена, и это единственное, что здесь обещано.
	_ = writeJSON(w, http.StatusOK, ingestResponse{
		DeliveryID: res.DeliveryID,
		Bytes:      res.Bytes,
		SHA256:     res.SHA256,
	})
}

// extendWriteDeadline даёт маршруту приёма собственный бюджет ответа.
//
// `WriteTimeout` сервера ставится в `readRequest`, то есть ДО вызова
// обработчика, и потому покрывает не только запись ответа, но и чтение тела:
// при `read_timeout` в пять минут и `write_timeout` в тридцать секунд загрузка
// длиннее тридцати секунд обрывается, а `read_timeout` при этом обещает пять
// минут. Обрывается молча — обработчик ошибки записи не видит, а `accessLog`
// пишет `status_code=200`.
//
// Лечится это здесь, а не подъёмом общего таймаута: длинный бюджет нужен
// одному маршруту, и подъём снял бы защиту от застрявшей записи со всех
// остальных.
//
// Транспорт, не умеющий дедлайнов, отказом приёма не является: приём —
// единственное место, где поток вообще существует, и терять доставку из-за
// неподдержанной оптимизации нельзя.
func (a *api) extendWriteDeadline(w http.ResponseWriter, r *http.Request) {
	if a.ingestBudget <= 0 {
		return
	}
	if err := http.NewResponseController(w).SetWriteDeadline(time.Now().Add(a.ingestBudget)); err != nil {
		a.log.DebugContext(r.Context(), "write deadline not set", "error", err)
	}
}

// metaFromHeaders достаёт то, что автоматизация Health Auto Export
// рассказывает о себе своими заголовками. Именованные поля — те, по которым
// ходят запросы; полный набор кладётся рядом, потому что документация HAE
// заведомо неполна.
func (a *api) metaFromHeaders(r *http.Request) ingest.Meta {
	return ingest.Meta{
		AutomationName: r.Header.Get("automation-name"),
		AutomationID:   r.Header.Get("automation-id"),
		Aggregation:    r.Header.Get("automation-aggregation"),
		Period:         r.Header.Get("automation-period"),
		SessionID:      r.Header.Get("session-id"),
		Headers:        a.safeHeaders(r),
	}
}

// secretHeaders — заголовки, которые не сохраняем никогда, как бы ни был
// настроен отправитель.
var secretHeaders = map[string]bool{
	"authorization":       true,
	"proxy-authorization": true,
	"cookie":              true,
	"x-api-key":           true,
	"api-key":             true,
	"x-auth-token":        true,
}

// redacted подменяет значение, которое оказалось секретом.
const redacted = "[redacted]"

// isSecretValue — совпало ли значение заголовка с каким-нибудь настроенным
// токеном, всё равно какого контура.
func (a *api) isSecretValue(v string) bool {
	return tokenAllowed(v, a.writeTokens) || tokenAllowed(v, a.readTokens)
}

// safeHeaders собирает заголовки запроса для хранения, вычищая секреты.
//
// Двойная защита: имя из чёрного списка вырезается всегда, а любое значение,
// совпавшее с настроенным токеном, подменяется — токен можно положить в
// заголовок с произвольным именем, и угадать его мы не можем.
//
// Сверяется с токенами ОБОИХ контуров. Токен чтения так же секрет, как токен
// приёма, и посланный на этот маршрут произвольным заголовком осел бы в базе
// доставок навсегда.
func (a *api) safeHeaders(r *http.Request) map[string][]string {
	out := make(map[string][]string, len(r.Header)+1)
	for name, values := range r.Header {
		if secretHeaders[strings.ToLower(name)] {
			out[name] = []string{redacted}
			continue
		}
		safe := make([]string, len(values))
		for i, v := range values {
			if a.isSecretValue(v) || a.isSecretValue(strings.TrimPrefix(v, "Bearer ")) {
				safe[i] = redacted
				continue
			}
			safe[i] = v
		}
		out[name] = safe
	}
	// Host в r.Header не попадает — а знать, на какое имя пришёл запрос,
	// полезно, когда перед сервисом появится прокси.
	if r.Host != "" {
		out["Host"] = []string{r.Host}
	}
	return out
}

// readBody читает тело запроса, при необходимости распаковывая gzip.
//
// Граница стоит на РАСПАКОВАННОМ потоке, а не только на сжатом. Иначе
// `MaxBytesReader` ограничивает то, что приехало по сети, а в память попадает
// то, что из этого развернулось: измерено — 400 КиБ сжатого тела дают 400 МиБ,
// принимаются со статусом 200 и выедают гигабайт. Потолок степени сжатия gzip
// около 1030:1, так что при штатных 64 МиБ речь о десятках гигабайт, и
// параллельные запросы складываются.
//
// Цена отказа здесь наивысшая в проекте: приём — единственное место, где поток
// вообще существует. Доставка, не попавшая в архив, не попадает и в журнал —
// телефон её не перешлёт.
func readBody(w http.ResponseWriter, r *http.Request, maxBody int64) ([]byte, error) {
	var src io.Reader = http.MaxBytesReader(w, r.Body, maxBody)

	if strings.Contains(strings.ToLower(r.Header.Get("Content-Encoding")), "gzip") {
		gz, err := gzip.NewReader(src)
		if err != nil {
			return nil, errBadGzip
		}
		defer func() { _ = gz.Close() }()
		// Лишний байт — способ отличить «ровно по границе» от «больше неё»,
		// не читая всё до конца.
		src = io.LimitReader(gz, maxBody+1)
	}

	body, err := io.ReadAll(src)
	if err != nil {
		return nil, err //nolint:wrapcheck // разбирается writeReadError по типу
	}
	if int64(len(body)) > maxBody {
		return nil, errTooLargeUnpacked
	}
	return body, nil
}

var (
	errBadGzip          = errors.New("тело не распаковывается как gzip")
	errTooLargeUnpacked = errors.New("распакованное тело больше допустимого размера")
)

func writeReadError(w http.ResponseWriter, err error) {
	var tooLarge *http.MaxBytesError
	switch {
	case errors.As(err, &tooLarge), errors.Is(err, errTooLargeUnpacked):
		writeError(w, http.StatusRequestEntityTooLarge, "пакет больше допустимого размера")
	case errors.Is(err, errBadGzip):
		writeError(w, http.StatusBadRequest, "тело не распаковывается как gzip")
	default:
		writeError(w, http.StatusBadRequest, "не удалось прочитать тело запроса")
	}
}
