package http

import (
	"context"
	"encoding/base64"
	"errors"
	"fmt"
	"log/slog"
	"net/http"
	"strconv"
	"strings"
	"time"

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

// AppRoot — корень адресов приложения.
//
// Корень остался **один**: пространства `/api/`, принадлежавшего встроенному
// хранилищу, и адреса панели `/_/` больше не существует — сервис их не занимает.
// Соседство, ради которого корень был выбран, кончилось вместе с соседом.
const AppRoot = "/app"

// Пределы страницы. Умолчание — столько, сколько помещается на экран телефона
// без прокрутки в два экрана; потолок — против того, чтобы попросить весь архив
// одним запросом и тем обойти постраничность её же параметром.
const (
	DefaultPageLimit = 30
	MaxPageLimit     = 100
)

// pollBudgetShare — какую долю бюджета ограничителя занимает опрос карточки.
//
// Доля, а не весь бюджет: опрос идёт не один. В ту же секунду человек листает
// список, открывает карточку соседней записи и грузит новую, а бюджет
// ограничителя один на все адреса приложения и считается по адресу
// спрашивающего, а не по учётной записи — двое за одним домашним адресом делят
// его пополам.
const pollBudgetShare = 8

// PollIntervalMs — частота, с которой приложению разрешено опрашивать карточку.
//
// Выводится из настройки ограничителя частоты под корнем приложения, а не
// задаётся своей константой: иначе приложение, честно опрашивающее карточку с
// объявленной частотой, упирается в ограничитель сервиса — и получает отказ,
// которого сервис сам же ему обещал избежать.
const PollIntervalMs = int64(appRateWindowSec * 1000 * pollBudgetShare / appRateMaxRequests)

// Значения параметра `copy` у адреса файла записи. Перечень закрыт, и каждое
// значение называет ровно одну хранимую вещь: имя параметра нормативно наравне
// со значениями — разбирает его каждый экран, и выбранное кодом оно стало бы
// публичным контрактом молча.
const (
	CopyParam      = "copy"
	CopyOriginal   = "original"
	CopyNormalized = "normalized"
)

type AppHandler struct {
	recordRepo    contract.AudioRecordRepository
	textRepo      contract.TextRepository
	structureRepo contract.StructureRepository
	fileRepo      contract.FileRepository
	trsService    *service.TranscribeService
	logger        *slog.Logger
}

func NewAppHandler(
	recordRepo contract.AudioRecordRepository,
	textRepo contract.TextRepository,
	structureRepo contract.StructureRepository,
	fileRepo contract.FileRepository,
	trsService *service.TranscribeService,
	logger *slog.Logger,
) *AppHandler {
	if logger == nil {
		logger = slog.Default()
	}
	return &AppHandler{
		recordRepo:    recordRepo,
		textRepo:      textRepo,
		structureRepo: structureRepo,
		fileRepo:      fileRepo,
		trsService:    trsService,
		logger:        logger,
	}
}

// RecordView — карточка записи и элемент страницы: **одна форма**. Две формы
// одной вещи разошлись бы молча, и экран, написанный по одной, ломался бы о
// другую.
//
// Машинного текста отказа здесь нет: он принадлежит журналу владельца сервиса.
// Причина остановки — значение из закрытого перечня, и она не он: без причины
// признак остановки не говорит человеку, чего ждать.
//
// Перечня доступных копий файла здесь нет намеренно: копий две, и каждая
// выводится из рубежа записи, который карточка несёт и так. Второе поле
// повторяло бы рубеж и разошлось бы с ним молча.
type RecordView struct {
	ID               string   `json:"id"`
	Title            *string  `json:"title"`
	OriginalFilename *string  `json:"original_filename"`
	Brief            *string  `json:"brief"`
	Topics           []string `json:"topics"`
	State            string   `json:"state"`
	Halted           bool     `json:"halted"`
	HaltReason       *string  `json:"halt_reason"`
	DurationMs       *int64   `json:"duration_ms"`
	SizeBytes        *int64   `json:"size_bytes"`
	CreatedAt        string   `json:"created_at"`
	// AvailableViews — перечень доступных видов текста, а не признак «текст
	// есть». Видов больше одного, и шаг завершения пишет их несколькими
	// операциями: состояние «сплошной текст есть, реплик ещё нет» достижимо. Один
	// признак отправил бы приложение за репликами, которых нет, и исход стал бы
	// функцией того, где прервался шаг. Пустой перечень значит «текста ещё нет».
	//
	// У элемента страницы поле опущено: страница видов не читает.
	AvailableViews *[]string `json:"available_views,omitempty"`
}

// IntakeItem — элемент ответа приёма: карточка плюс признак повторного файла.
type IntakeItem struct {
	RecordView
	Duplicate bool `json:"duplicate"`
}

type PageView struct {
	Items      []RecordView `json:"items"`
	NextCursor *string      `json:"next_cursor"`
	TotalItems int          `json:"total_items"`
}

type MeView struct {
	ID   string `json:"id"`
	Name string `json:"name"`
}

type ConfigView struct {
	MaxRecordSizeBytes int64    `json:"max_record_size_bytes"`
	MaxPageSize        int      `json:"max_page_size"`
	PollIntervalMs     int64    `json:"poll_interval_ms"`
	KnownExtensions    []string `json:"known_extensions"`
	MaxTopicsPerRecord int      `json:"max_topics_per_record"`
}

type TextView struct {
	View     string        `json:"view"`
	Contents string        `json:"contents,omitempty"`
	Replicas []ReplicaView `json:"replicas,omitempty"`
}

type ReplicaView struct {
	StartMs int64  `json:"start_ms"`
	EndMs   int64  `json:"end_ms"`
	Text    string `json:"text"`
}

// Routes — адреса приложения одним обработчиком.
//
// Слоёв здесь нет: ограничитель частоты, узнавание и требование учётной записи
// вешаются на **всю** цепочку корня приложения, а корень берётся из перечня
// адресного пространства. Так область их действия выводится из объявленного
// пространства, а не перечисляется вторым списком.
//
// Метод разбирается обработчиком, а не образцом маршрута: отказ маршрутизатора
// на неверный метод ушёл бы его формой тела, а форма отказа под корнем
// приложения одна.
func (h *AppHandler) Routes() http.Handler {
	mux := http.NewServeMux()

	for _, pattern := range AppRoutePatterns {
		mux.HandleFunc(pattern, h.handlerOf(pattern))
	}

	// Перехват «под нашим корнем такого адреса нет». Голый корень попадает сюда
	// же: он принадлежит корню приложения, адресом приложения не является и
	// потому отвечает как неизвестный путь под ним.
	//
	// Оба образца обязательны: без точного `/app` маршрутизатор увёл бы его
	// перенаправлением на `/app/`, а перенаправления норма не заказывала. В
	// закрытый перечень образцов они не входят: под них подходит **всё**, что
	// накрыто корнем, а значит путь под ними выбирает спрашивающий.
	mux.HandleFunc(AppRoot+"/", h.notFound)
	mux.HandleFunc(AppRoot, h.notFound)

	return mux
}

// Образцы адресов приложения. Перечень закрытый и **единственный**: из него
// вешаются обработчики, и из него же берётся значение `http.route` для журнала.
// Второй список образцов разошёлся бы с первым молча, и разошёлся бы в сторону
// журнала — путь, не попавший в перечень, уехал бы в строку дословно.
const (
	AppRouteMe         = AppRoot + "/me"
	AppRouteConfig     = AppRoot + "/config"
	AppRouteRecords    = AppRoot + "/audiorecords"
	AppRouteRecord     = AppRoot + "/audiorecords/{id}"
	AppRouteRecordText = AppRoot + "/audiorecords/{id}/text"
	AppRouteRecordFile = AppRoot + "/audiorecords/{id}/file"
)

// AppRoutePatterns — тот самый перечень. Порядок значения не имеет:
// маршрутизатор выбирает образец по точности, а не по месту в списке.
var AppRoutePatterns = []string{
	AppRouteMe,
	AppRouteConfig,
	AppRouteRecords,
	AppRouteRecord,
	AppRouteRecordText,
	AppRouteRecordFile,
}

// handlerOf выдаёт обработчик образца.
//
// Ветка на каждый образец, а не карта рядом с перечнем: недостающий образец
// здесь — отказ на подъёме, а не тихо не заведённый адрес.
func (h *AppHandler) handlerOf(pattern string) http.HandlerFunc {
	switch pattern {
	case AppRouteMe:
		return only(h.Me, http.MethodGet)
	case AppRouteConfig:
		return only(h.Config, http.MethodGet)
	// Приём стоит тем же адресом, что и список, и отличается только методом: он
	// заводит аудиозапись, а не кладёт файл.
	case AppRouteRecords:
		return h.records
	case AppRouteRecord:
		return only(h.GetRecord, http.MethodGet)
	case AppRouteRecordText:
		return only(h.GetRecordText, http.MethodGet)
	case AppRouteRecordFile:
		return only(h.GetRecordFile, http.MethodGet, http.MethodHead)
	}

	panic("адрес приложения " + pattern + " объявлен перечнем, но обработчика у него нет")
}

// only ограничивает адрес перечнем методов.
//
// Неверный метод отвечает «адреса нет»: код отказа принадлежит закрытому
// перечню, и своего значения у «метод не тот» в нём не заведено — адрес,
// которого нет для этого метода, и есть ненайденный адрес.
func only(handler http.HandlerFunc, methods ...string) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		for _, method := range methods {
			if r.Method == method {
				handler(w, r)
				return
			}
		}
		fail(w, errWithMessage(contract.ErrNotFound, "Адрес не найден"))
	}
}

func (h *AppHandler) notFound(w http.ResponseWriter, _ *http.Request) {
	fail(w, errWithMessage(contract.ErrNotFound, "Адрес не найден"))
}

// records — приём и список одним адресом: разница только в методе.
func (h *AppHandler) records(w http.ResponseWriter, r *http.Request) {
	switch r.Method {
	case http.MethodGet:
		h.ListRecords(w, r)
	case http.MethodPost:
		h.CreateRecord(w, r)
	default:
		fail(w, errWithMessage(contract.ErrNotFound, "Адрес не найден"))
	}
}

func (h *AppHandler) Me(w http.ResponseWriter, r *http.Request) {
	account, _ := AccountOf(r)

	// Адрес почты в ответ не идёт: он приходит от провайдера и принадлежит
	// человеку, а не сервису. Логин у провайдера — тоже: это его имя у
	// провайдера, и правило о непечатаемых значениях запрещает ему выходить
	// наружу наравне с журналом.
	writeJSON(w, http.StatusOK, MeView{ID: account.ID, Name: account.Name})
}

func (h *AppHandler) Config(w http.ResponseWriter, _ *http.Request) {
	// Каждый предел — то же значение, которое сервис применяет, а не его копия.
	// Приложение, знающее предел своей константой, расходится с сервером молча —
	// до первого отказа на записи, которую человек уже успел отправить.
	writeJSON(w, http.StatusOK, ConfigView{
		MaxRecordSizeBytes: entity.MaxRecordSize,
		MaxPageSize:        MaxPageLimit,
		PollIntervalMs:     PollIntervalMs,
		KnownExtensions:    metrics.PublicFormats(),
		MaxTopicsPerRecord: entity.MaxTopicsPerRecord,
	})
}

func (h *AppHandler) CreateRecord(w http.ResponseWriter, r *http.Request) {
	account, _ := AccountOf(r)

	// Предел тела назван числом: умолчания здесь не «без предела», а величины на
	// два-три порядка меньше нужного, и оставленные как есть они отвергли бы
	// штатную запись сервиса. Отказ по нему уходит нашей формой тела.
	//
	// Ловится он **дважды**, и это не избыточность. Объявленная длина судится
	// заранее: запись, за которую сервис платить не станет, не должна попасть
	// даже в память. Необъявленная и солгавшая ловятся на чтении — объявленной
	// длины у запроса с кусочной передачей нет вовсе.
	if r.ContentLength > entity.MaxRecordSize {
		fail(w, contract.ErrRecordTooLarge)
		return
	}
	r.Body = http.MaxBytesReader(w, r.Body, entity.MaxRecordSize)

	file, header, err := r.FormFile("audio")
	if err != nil {
		// Предел тела ловит запись на чтении. Не различив этот отказ и
		// отсутствующее поле, приём сказал бы человеку «вы не приложили файл» о
		// записи, которую он приложил и которая просто больше потолка.
		var tooLarge *http.MaxBytesError
		if errors.As(err, &tooLarge) {
			fail(w, contract.ErrRecordTooLarge)
			return
		}
		fail(w, errWithMessage(contract.ErrBadRequest, "Запись не приложена к запросу"))
		return
	}
	defer func() {
		if err := file.Close(); err != nil {
			h.logger.Error("Failed to close uploaded file", "error", err)
		}
	}()

	// Запись доехала целиком, поэтому она заводится независимо от того, дождётся
	// ли отправитель ответа: на контексте запроса приём терял бы полностью
	// загруженную запись от одного обрыва соединения, а забрать результат он
	// может и позже — карточкой записи.
	ctx := context.WithoutCancel(r.Context())

	// Владелец берётся из узнанного предъявителя и ниоткуда больше: владелец,
	// пришедший полем запроса, дал бы всякому узнанному право завести запись на
	// чужое имя.
	record, err := h.trsService.CreateJobFromApi(ctx, file, header.Filename, account.ID)
	if err != nil {
		// Второй раз отказ не логируем: приём назван конвенцией логирующей
		// границей и уже написал о нём. Транспорт переводит ошибку в ответ, и
		// делает это одним местом — по причине отказа, а не по месту.
		fail(w, err)
		return
	}

	// Ответ списком, даже когда файл в запросе один: форма согласована вперёд,
	// чтобы приём нескольких файлов и распознавание повтора её не переписывали.
	writeJSON(w, http.StatusCreated, []IntakeItem{{
		// Свежая запись текстов не имеет, но поле обязано быть на проводе:
		// отсутствие поля и пустой перечень приложение не различит.
		RecordView: h.viewOf(record, nil, &[]string{}),
	}})
}

func (h *AppHandler) ListRecords(w http.ResponseWriter, r *http.Request) {
	account, _ := AccountOf(r)

	q := contract.RecordQuery{OwnerID: account.ID, Limit: DefaultPageLimit}

	if raw := r.URL.Query().Get("limit"); raw != "" {
		limit, err := strconv.Atoi(raw)
		if err != nil || limit <= 0 {
			fail(w, errWithMessage(contract.ErrBadRequest, "Размер страницы должен быть положительным числом"))
			return
		}
		// Сверх потолка — усечение, а не отказ: человек попросил больше, чем
		// сервис отдаёт, но просьба сама по себе не негодна.
		q.Limit = min(limit, MaxPageLimit)
	}

	if raw := r.URL.Query().Get("filter"); raw != "" {
		filter, ok := entity.ParseListFilter(raw)
		if !ok {
			fail(w, errWithMessage(contract.ErrBadRequest, "Неизвестное состояние отбора"))
			return
		}
		q.Filter = &filter
	}

	if raw := r.URL.Query().Get("cursor"); raw != "" {
		cursor, err := decodeCursor(raw)
		if err != nil {
			// Молчаливая отдача первой страницы вместо отказа дала бы человеку
			// архив, листающийся по кругу, и ни строки в журнале.
			fail(w, errWithMessage(contract.ErrBadRequest, "Ключ страницы не читается"))
			return
		}
		q.Cursor = cursor
	}

	page, err := h.recordRepo.List(q)
	if err != nil {
		h.logger.Error("Failed to list audio records", "error", err, "owner_id", account.ID)
		fail(w, err)
		return
	}

	names, err := h.topicNames(account.ID, page.Items)
	if err != nil {
		h.logger.Error("Failed to resolve topics", "error", err, "owner_id", account.ID)
		fail(w, err)
		return
	}

	view := PageView{Items: make([]RecordView, 0, len(page.Items)), TotalItems: page.TotalItems}
	for _, record := range page.Items {
		// Страница видов текста не читает: перечень доступных видов есть только у
		// карточки, и опущенное поле честнее пустого — пустое читалось бы как
		// «текста нет».
		view.Items = append(view.Items, h.viewOf(record, names, nil))
	}
	if page.NextCursor != nil {
		encoded := encodeCursor(page.NextCursor)
		view.NextCursor = &encoded
	}

	writeJSON(w, http.StatusOK, view)
}

func (h *AppHandler) GetRecord(w http.ResponseWriter, r *http.Request) {
	account, _ := AccountOf(r)

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

	names, err := h.topicNames(account.ID, []*entity.AudioRecord{record})
	if err != nil {
		h.logger.Error("Failed to resolve topics", "error", err, "record_id", record.Id)
		fail(w, err)
		return
	}

	views := h.availableViews(record)
	writeJSON(w, http.StatusOK, h.viewOf(record, names, &views))
}

func (h *AppHandler) GetRecordText(w http.ResponseWriter, r *http.Request) {
	account, _ := AccountOf(r)

	view := r.URL.Query().Get("view")
	if !entity.IsKnownTextView(view) {
		fail(w, errWithMessage(contract.ErrBadRequest, "Неизвестный вид текста"))
		return
	}

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

	if view == entity.TextViewReplicas {
		h.replicasOf(w, record)
		return
	}
	h.plainTextOf(w, record, view)
}

// readOwn читает запись спрашивающего. Чужая, ничья, несуществующая и
// нечитаемая по виду идентификатора отвечают одним и тем же: по разнице ответов
// иначе перебирается список заведённых записей.
func (h *AppHandler) readOwn(r *http.Request, ownerID string) (*entity.AudioRecord, error) {
	// Идентификатор разбирается на границе: он приходит от спрашивающего, а
	// сравнение в базе побайтово — запись в верхнем регистре не совпала бы ни с
	// одной строкой. Негодный по виду считается несуществующим и до базы не
	// доходит вовсе.
	recordID, ok := ident.Parse(r.PathValue("id"))
	if !ok {
		return nil, &contract.JobNotFoundError{Message: "record not found"}
	}

	record, err := h.recordRepo.GetByID(recordID, ownerID)
	if err != nil {
		// Наружу ответ один на все исходы, а в журнал они идут по-разному.
		// «Записи нет» и «запись чужая» — штатная работа разграничения, о ней
		// писать нечего; всё прочее — отказ базы, и без этой строки он приходит
		// отправителю как «вашей записи нет», а владелец сервиса об аварии не
		// узнаёт ниоткуда.
		var notFound *contract.JobNotFoundError
		if !errors.As(err, &notFound) {
			h.logger.Error("Failed to read audio record", "error", err, "record_id", recordID)
		}
		return nil, err
	}
	return record, nil
}

func (h *AppHandler) plainTextOf(w http.ResponseWriter, record *entity.AudioRecord, view string) {
	textID := record.TranscriptTextID
	if view == entity.TextViewLiterary {
		textID = record.LiteraryTextID
	}

	if textID == nil {
		fail(w, contract.ErrTextNotReady)
		return
	}

	text, err := h.textRepo.GetByID(*textID)
	if err != nil {
		h.logger.Error("Failed to read text", "error", err, "record_id", record.Id)
		fail(w, err)
		return
	}
	if text.Contents == "" {
		fail(w, contract.ErrTextNotReady)
		return
	}

	writeJSON(w, http.StatusOK, TextView{View: view, Contents: text.Contents})
}

func (h *AppHandler) replicasOf(w http.ResponseWriter, record *entity.AudioRecord) {
	if record.StructureID == nil {
		fail(w, contract.ErrTextNotReady)
		return
	}

	structure, err := h.structureRepo.GetByID(*record.StructureID)
	if err != nil {
		h.logger.Error("Failed to read structure", "error", err, "record_id", record.Id)
		fail(w, err)
		return
	}
	if len(structure.Replicas) == 0 {
		fail(w, contract.ErrTextNotReady)
		return
	}

	replicas := make([]ReplicaView, 0, len(structure.Replicas))
	for _, replica := range structure.Replicas {
		replicas = append(replicas, ReplicaView{
			StartMs: replica.StartMs,
			EndMs:   replica.EndMs,
			Text:    replica.Text,
		})
	}

	writeJSON(w, http.StatusOK, TextView{View: entity.TextViewReplicas, Replicas: replicas})
}

// topicNames разрешает темы всех записей страницы **одним** запросом: страница в
// сотню записей иначе стоила бы сотни обращений к базе.
func (h *AppHandler) topicNames(ownerID string, records []*entity.AudioRecord) (map[string]string, error) {
	seen := map[string]bool{}
	ids := []string{}
	for _, record := range records {
		for _, id := range record.TopicIDs {
			if !seen[id] {
				seen[id] = true
				ids = append(ids, id)
			}
		}
	}
	return h.recordRepo.ResolveTopicNames(ownerID, ids)
}

func (h *AppHandler) viewOf(record *entity.AudioRecord, names map[string]string, views *[]string) RecordView {
	topics := make([]string, 0, len(record.TopicIDs))
	for _, id := range record.TopicIDs {
		if name, ok := names[id]; ok {
			topics = append(topics, name)
		}
	}

	return RecordView{
		ID:               record.Id,
		Title:            record.Title,
		OriginalFilename: record.OriginalFilename,
		Brief:            record.Brief,
		Topics:           topics,
		State:            record.State,
		Halted:           record.IsHalted(),
		HaltReason:       record.HaltReason,
		DurationMs:       record.DurationMs,
		SizeBytes:        record.SizeBytes,
		CreatedAt:        record.CreatedAt.Format(time.RFC3339),
		AvailableViews:   views,
	}
}

// availableViews — какие виды текста у записи есть **сейчас**.
//
// Перечень, а не признак: состояние «сплошной текст есть, реплик ещё нет»
// достижимо, потому что шаг завершения пишет их несколькими операциями.
//
// Вид считается доступным по **содержимому**, а не по наличию ссылки. Ссылка
// без содержимого — состояние штатное: пустой ответ распознавания проект признаёт
// нормой и записывает его в журнал. Строй мы перечень по ссылкам, карточка
// объявляла бы вид доступным, а адрес текста отвечал бы «ещё не готов» вечно.
func (h *AppHandler) availableViews(record *entity.AudioRecord) []string {
	views := []string{}

	if h.hasText(record.TranscriptTextID) {
		views = append(views, entity.TextViewTranscript)
	}
	if h.hasText(record.LiteraryTextID) {
		views = append(views, entity.TextViewLiterary)
	}
	if h.hasReplicas(record.StructureID) {
		views = append(views, entity.TextViewReplicas)
	}
	return views
}

// hasText — есть ли у записи непустой текст этого вида. Отказ чтения читается
// как «вида нет»: перечень доступных видов — подсказка приложению, и уронить
// из-за неё карточку хуже, чем недосказать.
func (h *AppHandler) hasText(textID *string) bool {
	if textID == nil {
		return false
	}
	text, err := h.textRepo.GetByID(*textID)
	if err != nil {
		h.logger.Error("Failed to read text while listing views", "error", err)
		return false
	}
	return text.Contents != ""
}

func (h *AppHandler) hasReplicas(structureID *string) bool {
	if structureID == nil {
		return false
	}
	structure, err := h.structureRepo.GetByID(*structureID)
	if err != nil {
		h.logger.Error("Failed to read structure while listing views", "error", err)
		return false
	}
	return len(structure.Replicas) > 0
}

// encodeCursor и decodeCursor прячут пару «время заведения и идентификатор» за
// непрозрачной строкой: спрашивающему её содержимое не принадлежит, а
// составлять ключ руками значило бы завязаться на порядок сортировки.
//
// Кодирование без набивки и в адресном алфавите — ключ уезжает параметром, а не
// телом.
func encodeCursor(c *contract.RecordCursor) string {
	return base64.RawURLEncoding.EncodeToString(
		[]byte(c.CreatedAt.UTC().Format(time.RFC3339) + "|" + c.ID),
	)
}

func decodeCursor(raw string) (*contract.RecordCursor, error) {
	decoded, err := base64.RawURLEncoding.DecodeString(raw)
	if err != nil {
		return nil, fmt.Errorf("cursor is not decodable: %w", err)
	}

	createdAt, id, ok := strings.Cut(string(decoded), "|")
	if !ok {
		return nil, errors.New("malformed cursor")
	}

	// Обе половины ключа разбираются, а не берутся строкой: время уходит в
	// запрос сравнением, а идентификатор — точным совпадением, и негодная
	// половина дала бы человеку либо пустой архив при непустом счётчике, либо
	// ленту с начала.
	parsed, err := time.Parse(time.RFC3339, createdAt)
	if err != nil {
		return nil, errors.New("cursor carries no readable time")
	}

	recordID, valid := ident.Parse(id)
	if !valid {
		return nil, errors.New("cursor carries no readable record key")
	}

	return &contract.RecordCursor{CreatedAt: parsed.UTC(), ID: recordID}, nil
}
