package http

import (
	"crypto/sha256"
	"encoding/hex"
	"io/fs"
	"log/slog"
	"net/http"
	"path"
	"strconv"
	"strings"

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

// Корни адресного пространства и отдельные адреса наблюдения.
//
// `/api` и `/_` принадлежат хранилищу: первый — его наборам адресов, второй —
// панели владельца. Поменять их нельзя, это литералы библиотеки.
const (
	StorageRoot = "/api"
	PanelRoot   = "/_"
	AuthRoot    = "/auth"

	HealthPath  = "/health"
	MetricsPath = "/metrics"
)

// assetsDir — каталог, который наполняет сборщик приложения.
//
// Имена в нём строит он же и несёт в них отпечаток содержимого, поэтому
// изменившийся ресурс приезжает под новым именем. Отсюда два правила разом:
// такой ресурс отдаётся с долгим сроком хранения, а не совпавший с файлом путь
// под этим каталогом отвечает отсутствием, а не разметкой.
const assetsDir = "assets"

// Срок хранения ресурса сборщика — год.
const assetMaxAgeSeconds = 31536000

// Mount — часть адресного пространства, принадлежащая сервису.
//
// Перечень этих частей — **единственное** описание того, что сервису
// принадлежит, и он не описывает регистрацию, а порождает её: корень,
// заведённый мимо перечня, не получит обработчика вовсе. Прежде такой перечень
// был бы вторым описанием таблицы маршрутов, которую ведут три места, и корень,
// забытый в нём, молча отдавал бы разметку там, где программа ждёт отказ
// контракта.
type Mount struct {
	// Path — корень либо точный адрес.
	Path string

	// Exact — путь является точным адресом, а не корнем: `/health` накрывает
	// только сам себя, а `/app` — всё, что под ним.
	Exact bool

	// Bind вешает обработчики этой части. Пусто у того, что вешает библиотека.
	Bind func(r *router.Router[*core.RequestEvent])
}

// Covers говорит, принадлежит ли путь этой части адресного пространства.
//
// Условий два, и оба обязательны: точное совпадение либо префикс **вместе с
// косой чертой**. По одному префиксу корню `/app` достался бы посторонний
// `/apple`; по одному префиксу с косой чертой голый `/api` не достался бы
// никому и уехал бы разметкой приложения.
func (m Mount) Covers(requestPath string) bool {
	if m.Exact {
		return requestPath == m.Path
	}

	return requestPath == m.Path || strings.HasPrefix(requestPath, m.Path+"/")
}

// ServiceMounts перечисляет адресное пространство сервиса целиком.
func ServiceMounts(
	appHandler *AppHandler,
	authHandler *AuthHandler,
	metricsHandler http.Handler,
) []Mount {
	return []Mount{
		{Path: StorageRoot},
		{Path: PanelRoot},
		{Path: AppRoot, Bind: appHandler.Register},
		{Path: AuthRoot, Bind: authHandler.Register},
		{Path: HealthPath, Exact: true, Bind: bindHealth},
		{Path: MetricsPath, Exact: true, Bind: bindMetrics(metricsHandler)},
	}
}

// RegisterServiceRoutes вешает всё, что сервис вешает сам.
func RegisterServiceRoutes(r *router.Router[*core.RequestEvent], mounts []Mount) {
	for _, mount := range mounts {
		if mount.Bind != nil {
			mount.Bind(r)
		}
	}
}

// IsServiceAddress говорит, принадлежит ли путь сервису хоть какой-то частью.
func IsServiceAddress(mounts []Mount, requestPath string) bool {
	for _, mount := range mounts {
		if mount.Covers(requestPath) {
			return true
		}
	}

	return false
}

// IsObservationAddress говорит, что путь — адрес наблюдения.
//
// Опрос здоровья и метрик идёт постоянно и полезного не несёт, поэтому уровень
// журнала у него свой. Перечень при этом тот же самый: второе перечисление этих
// адресов разошлось бы с первым молча.
func IsObservationAddress(mounts []Mount, requestPath string) bool {
	for _, mount := range mounts {
		if mount.Exact && mount.Covers(requestPath) {
			return true
		}
	}

	return false
}

func bindHealth(r *router.Router[*core.RequestEvent]) {
	r.GET(HealthPath, func(e *core.RequestEvent) error {
		return e.JSON(http.StatusOK, map[string]string{
			"status":  "ok",
			"message": "Transcriber service is running",
		})
	})
}

func bindMetrics(handler http.Handler) func(r *router.Router[*core.RequestEvent]) {
	return func(r *router.Router[*core.RequestEvent]) {
		r.GET(MetricsPath, func(e *core.RequestEvent) error {
			handler.ServeHTTP(e.Response, e.Request)
			return nil
		})
	}
}

// notBuiltPage — что видит человек у бинарника без собранного приложения.
//
// Состояние возможно только у собранного мимо набора проверок: и набор
// проверок, и сборка образа собирают приложение раньше бинарника.
const notBuiltPage = `<!doctype html>
<html lang="ru">
<head><meta charset="utf-8"><title>Приложение не собрано</title></head>
<body><h1>Приложение не собрано</h1>
<p>Бинарник собран без приложения. Соберите его и соберите бинарник заново.</p>
</body>
</html>`

// Исход раздачи для журнала. Сам путь в журнал не идёт: множеством его значений
// распоряжается спрашивающий, а до появления раздачи такой путь ловил отказ
// маршрутизатора и успешным ответом не был.
const (
	OutcomeMarkup  = "markup"
	OutcomeAsset   = "asset"
	OutcomeFailure = "failure"
)

// journalOutcomeKey — под каким ключом раздача оставляет исход слою журнала.
const journalOutcomeKey = "transcriberWebappOutcome"

// WebappOutcome отдаёт исход, оставленный раздачей, либо пустую строку, если
// запрос до неё не дошёл.
func WebappOutcome(e *core.RequestEvent) string {
	outcome, ok := e.Get(journalOutcomeKey).(string)
	if !ok {
		return ""
	}

	return outcome
}

// WebappHandler раздаёт собранное приложение и держит правило неизвестного пути.
type WebappHandler struct {
	dist        fs.FS
	built       bool
	mounts      []Mount
	fingerprint string
	logger      *slog.Logger
}

// NewWebappHandler собирает раздачу приложения.
//
// Файловая система приходит параметром, а не тянется пакетом: так тест
// подставляет свою сборку, не собирая приложение.
func NewWebappHandler(dist fs.FS, built bool, mounts []Mount, logger *slog.Logger) *WebappHandler {
	if logger == nil {
		logger = slog.Default()
	}

	if !built {
		logger.Warn("Webapp is not built, service will answer with a placeholder page")
		return &WebappHandler{dist: dist, built: built, mounts: mounts, logger: logger}
	}

	// Отпечаток вшитой сборки — единственное, чем «не та сборка» отличается от
	// «той». Вне набора проверок порядок шагов ничем не задан: `go run .`
	// вшивает то, что лежит с прошлого раза, а приложение при этом открывается и
	// ведёт себя как прежняя версия. Он же уходит меткой ответа с разметкой.
	fingerprint := buildFingerprint(dist)
	logger.Info("Webapp is embedded", "webapp.build", fingerprint)

	return &WebappHandler{
		dist:        dist,
		built:       built,
		mounts:      mounts,
		fingerprint: fingerprint,
		logger:      logger,
	}
}

// buildFingerprint — короткий отпечаток разметки вшитой сборки.
//
// Считается по самой разметке, а не по файлу, который положил бы сборщик: файл
// пришлось бы заводить настройкой сборки, а разметка меняется вместе с именами
// ресурсов, то есть при всякой пересборке с изменениями.
func buildFingerprint(dist fs.FS) string {
	markup, err := fs.ReadFile(dist, "index.html")
	if err != nil {
		return "unknown"
	}

	sum := sha256.Sum256(markup)

	return hex.EncodeToString(sum[:])[:12]
}

// Register вешает раздачу на корень.
//
// Маршрут стоит ровно на `/`, а не на `/{path...}`: сборка маршрутов сама
// вешает на `/` отказ «ничего не совпало», если такого маршрута ещё нет, и
// второй всепокрывающий образец рядом с ним спорил бы с ним за путь.
func (h *WebappHandler) Register(r *router.Router[*core.RequestEvent]) {
	// Успешная раздача в журнал хранилища не пишется.
	//
	// Журнал хранилища — второй, помимо журнала контейнера, и в него библиотека
	// кладёт путь запроса целиком вместе с адресом отправителя, храня строки
	// пять суток. Путь здесь выбирает спрашивающий, и без этого отказа всякое
	// открытие приложения оставляло бы там его текст и его адрес. Готовая
	// раздача статики ставит тот же признак первой строкой; своя написана мимо
	// неё, и признак перенесён руками.
	//
	// Отказы записываются по-прежнему: признак снимает только успех.
	r.Any("/", h.Serve).Bind(apis.SkipSuccessActivityLog())
}

// Serve отдаёт приложение либо отказ — по порядку, объявленному дизайном.
func (h *WebappHandler) Serve(e *core.RequestEvent) error {
	requestPath := e.Request.URL.Path

	// Путь принадлежит сервису — значит под этим корнем такого адреса просто
	// нет. Отказ уходит формой библиотеки, то есть тем же, чем этот корень
	// отвечает и сегодня: своя форма здесь была бы второй.
	if IsServiceAddress(h.mounts, requestPath) {
		e.Set(journalOutcomeKey, OutcomeFailure)
		return router.NewNotFoundError("", nil)
	}

	if e.Request.Method != http.MethodGet && e.Request.Method != http.MethodHead {
		e.Set(journalOutcomeKey, OutcomeFailure)
		return router.NewApiError(http.StatusMethodNotAllowed, "", nil)
	}

	if !h.built {
		e.Set(journalOutcomeKey, OutcomeFailure)
		return e.HTML(http.StatusServiceUnavailable, notBuiltPage)
	}

	name := strings.TrimPrefix(path.Clean(requestPath), "/")
	if name == "" || name == "." {
		return h.serveIndex(e)
	}

	if info, err := fs.Stat(h.dist, name); err == nil && !info.IsDir() {
		e.Set(journalOutcomeKey, OutcomeAsset)
		h.setCacheHeader(e, name)
		return e.FileFS(h.dist, name)
	}

	// Разметка прежней сборки называет ресурсы прежней сборки. Подменить их
	// разметкой значит ответить `200` на то, чего нет: браузер отвергнет её по
	// типу содержимого, человек увидит пустой экран, а в кодах ответов сервиса
	// не останется ничего.
	if underAssets(name) {
		e.Set(journalOutcomeKey, OutcomeFailure)
		return router.NewNotFoundError("", nil)
	}

	return h.serveIndex(e)
}

func (h *WebappHandler) serveIndex(e *core.RequestEvent) error {
	e.Set(journalOutcomeKey, OutcomeMarkup)
	h.setCacheHeader(e, "index.html")

	// Отпечаток идёт метке ответа: `no-cache` обещает дешёвую проверку —
	// «спроси заново и получи подтверждение», — а вшитый файл не несёт времени
	// правки вовсе, и без метки браузеру каждый раз отдаётся полное тело вместо
	// подтверждения. Отпечаток уже посчитан при подъёме, второго счёта не надо.
	if h.fingerprint != "" {
		e.Response.Header().Set("ETag", `"`+h.fingerprint+`"`)
	}

	return e.FileFS(h.dist, "index.html")
}

// setCacheHeader назначает срок хранения по каталогу, а не по виду файла.
//
// Вид файла признака не даёт: в сборке лежат и файлы с постоянными именами —
// иконка, манифест, — и такой файл, однажды отданный как неизменяемый, не
// отзывается со стороны сервиса ничем. Запросов он больше не увидит.
// underAssets говорит, ведёт ли путь в каталог сборщика.
//
// Условий два, и оба обязательны — та же пара, что у принадлежности корню: сам
// каталог и всё, что под ним. По одному префиксу с косой чертой голый `assets`
// не попал бы никуда и уехал бы разметкой с кодом `200` — то есть ровно тем
// ответом, который правило запрещает.
func underAssets(name string) bool {
	return name == assetsDir || strings.HasPrefix(name, assetsDir+"/")
}

func (h *WebappHandler) setCacheHeader(e *core.RequestEvent, name string) {
	if underAssets(name) {
		e.Response.Header().Set(
			"Cache-Control",
			"public, max-age="+strconv.Itoa(assetMaxAgeSeconds)+", immutable",
		)
		return
	}

	e.Response.Header().Set("Cache-Control", "no-cache")
}
