// Package httpapi предоставляет HTTP API и веб-UI (server-rendered).
//
// Тонкий транспорт над ядром: приём идёт в ingest, команды (cancel/retry) —
// в worker, чтение — в store. В v1 без авторизации (доверенная LAN).
package httpapi

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"html/template"
	"io"
	"io/fs"
	"log/slog"
	"net/http"
	"net/url"
	"strconv"
	"strings"
	"time"
	"unicode"
	"unicode/utf8"

	"github.com/go-chi/chi/v5"
	"github.com/go-chi/chi/v5/middleware"

	"git.vakhrushev.me/av/jellybit/internal/ident"
	"git.vakhrushev.me/av/jellybit/internal/ingest"
	"git.vakhrushev.me/av/jellybit/internal/layout"
	"git.vakhrushev.me/av/jellybit/internal/magnet"
	"git.vakhrushev.me/av/jellybit/internal/store"
	"git.vakhrushev.me/av/jellybit/internal/torrent"
	"git.vakhrushev.me/av/jellybit/internal/worker"
	"git.vakhrushev.me/av/jellybit/web"
)

// Ingestor принимает загрузку (ingest.Service).
type Ingestor interface {
	Ingest(ctx context.Context, req ingest.Request) (ingest.Result, error)
}

// Commander исполняет команды над задачей (worker.Worker).
type Commander interface {
	Cancel(ctx context.Context, id string) error
	Retry(ctx context.Context, id string) error
}

// Reader читает задачи (store.Store).
type Reader interface {
	ListDownloads(ctx context.Context) ([]store.Download, error)
	ListDownloadsPage(ctx context.Context, f store.ListFilter) ([]store.Download, int, error)
	GetDownload(ctx context.Context, id string) (*store.Download, error)
	// LayoutSizeByDownload — суммарный размер разложенных файлов по каждой из
	// загрузок (фолбэк размера раздачи в карточке, когда торрента нет в снимке).
	LayoutSizeByDownload(ctx context.Context, ids []string) (map[string]int64, error)
	// ListDeletableDownloads — загрузки, разрешённые к полному удалению, без
	// постраничной выдачи (страница группового удаления показывает их разом).
	ListDeletableDownloads(ctx context.Context) ([]store.Download, error)
}

// Deps — зависимости транспорта.
type Deps struct {
	Logger    *slog.Logger
	Ingestor  Ingestor
	Commander Commander
	Reader    Reader
	Reviewer  Reviewer
	Live      LiveStatus
	// Loc — таймзона отображения дат в веб-UI (хранение всегда UTC). nil → UTC.
	Loc *time.Location
}

type server struct {
	deps     Deps
	tmpl     *template.Template
	assetVer string
}

// NewRouter собирает HTTP-обработчик сервиса.
func NewRouter(d Deps) (http.Handler, error) {
	assetVer, err := assetVersion()
	if err != nil {
		return nil, err
	}
	funcs := template.FuncMap{
		"add":        func(a, b int) int { return a + b },
		"asset":      func(p string) string { return "/static/" + p + "?v=" + assetVer },
		"badgeLabel": badgeLabel,
	}
	tmpl, err := template.New("").Funcs(funcs).
		ParseFS(web.FS, "templates/*.html", "templates/partials/*.html")
	if err != nil {
		return nil, err
	}
	staticFS, err := fs.Sub(web.FS, "static")
	if err != nil {
		return nil, err
	}
	if d.Live == nil {
		d.Live = noLive{} // источник телеметрии не подключён — деградируем штатно
	}
	if d.Loc == nil {
		d.Loc = time.UTC // таймзона отображения не задана — показываем в UTC
	}
	s := &server{deps: d, tmpl: tmpl, assetVer: assetVer}

	r := chi.NewRouter()
	r.Use(middleware.RequestID)
	r.Use(middleware.Recoverer)
	r.Use(requestLogger(d.Logger))

	r.Get("/healthz", handleHealthz)

	// Статика (встроенная, с длинным кэшем; URL версионируются ?v=).
	r.Handle("/static/*", http.StripPrefix("/static/", staticHandler(staticFS)))

	// Веб-UI.
	r.Get("/", s.handleIndex)
	r.Get("/download/{id}", s.handleDownload)
	// Групповое удаление: выбор → подтверждение → исполнение. Отдельная
	// страница, потому что живая перерисовка списка стёрла бы выбор человека.
	r.Get("/delete", s.handleBulkDeletePage)

	// Партиалы телеметрии без потребителя в новой разметке: оставлены гасителями
	// вкладок, отрисованных прошлой версией (см. handleFragProgress).
	r.Get("/fragments/downloads/{id}/progress", s.handleFragProgress)
	r.Get("/fragments/downloads/{id}/seeding", s.handleFragSeeding)
	// Карточка целиком — тик самообновления списка: пока задача наблюдаема,
	// карточка приносит текущее состояние без перезагрузки страницы.
	r.Get("/fragments/downloads/{id}/card", s.handleFragCard)
	// Тело ревью для поллинга recognizing (htmx-своп до готового плана).
	r.Get("/fragments/downloads/{id}/review", s.handleFragReview)
	r.Post("/ui/downloads", s.handleUIAdd)
	r.Post("/ui/delete/confirm", s.handleBulkDeleteConfirm)
	r.Post("/ui/delete", s.handleBulkDelete)
	r.Post("/ui/downloads/{id}/cancel", s.handleUICancel)
	r.Post("/ui/downloads/{id}/retry", s.handleUIRetry)

	// Веб-UI: ревью раскладки.
	r.Get("/review/{id}", s.handleReview)
	r.Post("/ui/downloads/{id}/apply", s.handleApply)
	r.Post("/ui/downloads/{id}/refine", s.handleRefine)
	r.Post("/ui/downloads/{id}/rerecognize", s.handleRerecognize)
	r.Post("/ui/downloads/{id}/ignore", s.handleIgnore)
	r.Post("/ui/downloads/{id}/candidate", s.handleChooseCandidate)
	r.Post("/ui/downloads/{id}/provider", s.handleSetProvider)
	r.Post("/ui/downloads/{id}/source", s.handleAddSource)
	r.Post("/ui/downloads/{id}/refresh-name", s.handleRefreshName)
	r.Post("/ui/downloads/{id}/nobase", s.handleNoBase)
	r.Post("/ui/downloads/{id}/defer", s.handleDefer)
	r.Post("/ui/downloads/{id}/undo", s.handleUndo)
	r.Post("/ui/downloads/{id}/relink", s.handleRelink)
	r.Post("/ui/downloads/{id}/delete", s.handleDelete)
	r.Post("/ui/downloads/{id}/dismiss", s.handleDismiss)

	// REST API.
	r.Route("/api", func(r chi.Router) {
		r.Get("/downloads", s.handleAPIList)
		r.Post("/downloads", s.handleAPIAdd)
		r.Get("/downloads/{id}", s.handleAPIGet)
		r.Post("/downloads/{id}/cancel", s.handleAPICancel)
		r.Post("/downloads/{id}/retry", s.handleAPIRetry)
	})

	return r, nil
}

func handleHealthz(w http.ResponseWriter, _ *http.Request) {
	writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}

// --- Веб-UI ---

// pageSize — размер страницы списка загрузок (серверная пагинация).
const pageSize = 25

type indexView struct {
	Error     string
	Downloads []downloadView

	// Фильтр/поиск (серверные, в query).
	Filter  string       // активная группа (all/review/active/done/problem)
	Query   string       // текст поиска
	ShowAll bool         // показывать удалённые
	Chips   []filterChip // чипы фильтра со ссылками
	ShowURL string       // ссылка тумблера «показать всё»

	// Пагинация.
	Page      int
	Pages     int        // всего страниц (>=1)
	Total     int        // всего строк под фильтром
	Searching bool       // активны фильтр/поиск — влияет на текст пустого состояния
	PrevURL   string     // пусто — на первой странице
	NextURL   string     // пусто — на последней
	PageLinks []pageLink // пронумерованные страницы (окно)
}

type filterChip struct {
	Key    string
	Label  string
	URL    string
	Active bool
}

type pageLink struct {
	Num    int
	URL    string
	Active bool
}

type downloadView struct {
	ID            string
	Title         string // отображаемый заголовок карточки
	MediaType     string // тип контента для значка строки списка (movie/series; пусто — не распознан)
	State         string
	Error         string
	Terminal      bool
	IsDownloading bool         // активная загрузка → живой прогресс-бар
	SelfPoll      bool         // задача наблюдаема → карточка сама опрашивает себя
	PollEvery     string       // интервал самообновления карточки (pollFast/pollSlow)
	Progress      progressView // живой прогресс (заполняется в handleIndex из снимка)
	Reviewable    bool         // review/deferred — есть экран ревью
	Undoable      bool         // done — можно откатить раскладку
	Relinkable    bool         // reverted/cancelled/target_missing — можно перепривязать заново
	Retriable     bool         // failed/stuck — можно повторить попытку
	Note          string       // пояснение рассинхрона (target_missing/orphaned/deleted)
	ActionError   string       // ошибка действия на htmx-пути (своп карточки), не error_msg

	// Обзор жизненного цикла (мета-строка карточки).
	Added    string // абсолютная дата добавления (TZ сервера), «2006-01-02»
	AddedAgo string // относительная давность, «5 дней назад»
	Size     string // размер раздачи (снимок → фолбэк по файлам → «—»)
	Ratio    string // рейтинг отдачи (снимок → «—»)
}

// listChips — определения чипов фильтра списка (порядок = порядок показа).
var listChips = []struct {
	Key   string
	Label string
	Group store.StateGroup
}{
	{"all", "Все", store.GroupAll},
	{"review", "Ждут меня", store.GroupReview},
	{"active", "В работе", store.GroupActive},
	{"done", "Готово", store.GroupDone},
	{"problem", "Проблемы", store.GroupProblem},
}

func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) {
	q := r.URL.Query()
	group := parseGroup(q.Get("f"))
	query := strings.TrimSpace(q.Get("q"))
	showAll := q.Get("all") == "1"
	page := parsePage(q.Get("page"))

	downloads, total, err := s.deps.Reader.ListDownloadsPage(r.Context(), store.ListFilter{
		Group:          group,
		Query:          query,
		IncludeDeleted: showAll,
		Limit:          pageSize,
		Offset:         (page - 1) * pageSize,
	})
	if err != nil {
		s.deps.Logger.Error("list downloads", "error", err)
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}

	pages := max((total+pageSize-1)/pageSize, 1)

	view := indexView{
		Error:     q.Get("err"),
		Filter:    string(group),
		Query:     query,
		ShowAll:   showAll,
		Page:      page,
		Pages:     pages,
		Total:     total,
		Searching: query != "" || group != store.GroupAll || showAll || page > 1,
	}
	// Чипы: сохраняют q/all, сбрасывают страницу.
	for _, c := range listChips {
		view.Chips = append(view.Chips, filterChip{
			Key:    c.Key,
			Label:  c.Label,
			URL:    listURL(c.Group, query, showAll, 1),
			Active: c.Group == group,
		})
	}
	// Тумблер «показать всё» переключает all, сохраняя фильтр/поиск.
	view.ShowURL = listURL(group, query, !showAll, 1)
	// Пагинация: сохраняет f/q/all.
	if page > 1 {
		view.PrevURL = listURL(group, query, showAll, page-1)
	}
	if page < pages {
		view.NextURL = listURL(group, query, showAll, page+1)
	}
	for _, n := range pageWindow(page, pages) {
		view.PageLinks = append(view.PageLinks, pageLink{
			Num: n, URL: listURL(group, query, showAll, n), Active: n == page,
		})
	}

	// Суммарный размер разложенных файлов по странице — фолбэк размера раздачи,
	// когда торрента нет в живом снимке (один батч-запрос, не N+1).
	ids := make([]string, len(downloads))
	for i, d := range downloads {
		ids[i] = d.ID
	}
	layoutSizes, err := s.deps.Reader.LayoutSizeByDownload(r.Context(), ids)
	if err != nil {
		s.deps.Logger.Error("layout sizes", "error", err)
		layoutSizes = nil // деградируем: размер уедет в фолбэк «—», страница не падает
	}

	now := store.Now()
	for _, d := range downloads {
		view.Downloads = append(view.Downloads, s.buildCardView(d, now, layoutSizes[d.ID]))
	}
	s.render(w, "index.html", view)
}

// buildCardView собирает представление карточки списка из доменных данных и
// живого снимка. Общий для полной страницы (handleIndex), тика самообновления
// (handleFragCard) и htmx-свопа после действия (renderCardFragment): чтобы
// htmx-ветки не дублировали обвязку (рейтинг/размер/прогресс). Живые цифры едут
// вместе с карточкой — своего опроса у блока прогресса нет, поэтому Progress
// заполняется здесь на каждом пути.
func (s *server) buildCardView(d store.Download, now time.Time, layoutSize int64) downloadView {
	v := s.toView(d, now)
	// Живой снимок читаем для всех карточек (map-lookup, без сети/БД): рейтинг
	// и размер нужны в любом состоянии, пока торрент есть в qBittorrent.
	l, ok := s.liveFor(d)
	v.Ratio = ratioText(l, ok)
	v.Size = sizeText(l, ok, layoutSize)
	// Живой прогресс активных загрузок — со значениями уже в первом кадре
	// (без мигания); дальше карточка дозапрашивает фрагмент поллингом.
	if v.IsDownloading {
		v.Progress = buildProgress(d.ID, true, l, ok)
	}
	return v
}

// parseGroup разбирает параметр фильтра `f`; неизвестное → all.
func parseGroup(s string) store.StateGroup {
	switch store.StateGroup(s) {
	case store.GroupReview:
		return store.GroupReview
	case store.GroupActive:
		return store.GroupActive
	case store.GroupDone:
		return store.GroupDone
	case store.GroupProblem:
		return store.GroupProblem
	default:
		return store.GroupAll
	}
}

// parsePage разбирает номер страницы (1-based); мусор/<1 → 1. За последней
// страницей отдаём как есть — запрос вернёт пустую страницу (не ошибка).
func parsePage(s string) int {
	n, err := strconv.Atoi(s)
	if err != nil || n < 1 {
		return 1
	}
	return n
}

// listURL строит ссылку списка с сохранением состояния фильтра/поиска/страницы.
// Дефолты (all, пустой поиск, page 1) в query не пишем — URL чистый.
func listURL(group store.StateGroup, query string, showAll bool, page int) string {
	v := url.Values{}
	if group != store.GroupAll {
		v.Set("f", string(group))
	}
	if query != "" {
		v.Set("q", query)
	}
	if showAll {
		v.Set("all", "1")
	}
	if page > 1 {
		v.Set("page", strconv.Itoa(page))
	}
	if len(v) == 0 {
		return "/"
	}
	return "/?" + v.Encode()
}

// pageWindow возвращает номера страниц вокруг текущей (окно до 7), чтобы пагинация
// не разрасталась на больших списках.
func pageWindow(page, pages int) []int {
	const win = 7
	if pages <= win {
		out := make([]int, pages)
		for i := range out {
			out[i] = i + 1
		}
		return out
	}
	start := max(page-win/2, 1)
	end := start + win - 1
	if end > pages {
		end = pages
		start = end - win + 1
	}
	out := make([]int, 0, win)
	for n := start; n <= end; n++ {
		out = append(out, n)
	}
	return out
}

func (s *server) handleUIAdd(w http.ResponseWriter, r *http.Request) {
	// Форма — multipart (файл-пикер .torrent). Лимит тела: размер торрента +
	// небольшой запас на прочие поля. ParseMultipartForm сперва разбирает
	// urlencoded-тело (ParseForm), поэтому на обычной (не-multipart) отправке —
	// напр. из curl или устаревшей страницы — вернёт ErrNotMultipart, но поля
	// уже в PostForm: такую ошибку глотаем, текстовый путь не ломается.
	r.Body = http.MaxBytesReader(w, r.Body, ingest.MaxTorrentSize+1<<20)
	if err := r.ParseMultipartForm(ingest.MaxTorrentSize + 1<<20); err != nil && !errors.Is(err, http.ErrNotMultipart) {
		redirectErr(w, r, "не удалось разобрать форму (возможно, файл слишком большой)")
		return
	}

	req := ingest.Request{
		Source:  r.FormValue("source"),
		Context: r.FormValue("context"),
	}
	// Выбран .torrent-файл — приём по байтам (в приоритете над текстом).
	if file, header, err := r.FormFile("torrent"); err == nil {
		defer func() { _ = file.Close() }()
		data, rerr := io.ReadAll(file)
		if rerr != nil {
			redirectErr(w, r, "не удалось прочитать .torrent-файл")
			return
		}
		req.TorrentData = data
		req.TorrentName = header.Filename // фолбек для source_ref
	}

	res, err := s.deps.Ingestor.Ingest(r.Context(), req)
	if err != nil {
		// Нулевой Result на любом пути ошибки — контракт ingest.Ingest;
		// корреляционный ключ веб-формы, как и REST, — request_id.
		redirectErr(w, r, userErr(r, err, ""))
		return
	}
	if res.Deduplicated {
		// Приём привязался к существующей записи (активной или «спящей» desync —
		// target_missing/orphaned): ведём пользователя на её страницу, а не на
		// список. Так видно, что нового не завели, и доступны действия записи
		// (привязать заново / danger-зона «Закрыть»).
		http.Redirect(w, r, "/download/"+res.DownloadID, http.StatusSeeOther)
		return
	}
	http.Redirect(w, r, "/", http.StatusSeeOther)
}

func (s *server) handleUICancel(w http.ResponseWriter, r *http.Request) {
	id, err := pathID(r)
	if err != nil {
		redirectErr(w, r, "некорректный id")
		return
	}
	s.surfaceAction(w, r, id, s.deps.Commander.Cancel(r.Context(), id))
}

func (s *server) handleUIRetry(w http.ResponseWriter, r *http.Request) {
	id, err := pathID(r)
	if err != nil {
		redirectErr(w, r, "некорректный id")
		return
	}
	s.surfaceAction(w, r, id, s.deps.Commander.Retry(r.Context(), id))
}

// surfaceAction завершает мутирующее действие, доступное и в списке, и на
// странице загрузки (undo/relink/retry/cancel). На htmx свопит фрагмент той
// поверхности, откуда пришло действие (скрытое поле surface=list|download), на
// ошибке — тот же фрагмент с сообщением и HTTP 200 (иначе htmx не подменит DOM).
// Без htmx — прежний PRG-редирект на список (форма выхода из ревью тоже сюда:
// нет htmx → навигация). actionErr — результат доменного вызова.
func (s *server) surfaceAction(w http.ResponseWriter, r *http.Request, id string, actionErr error) {
	if !isHTMX(r) {
		if actionErr != nil {
			redirectErr(w, r, userErr(r, actionErr, id))
			return
		}
		http.Redirect(w, r, "/", http.StatusSeeOther)
		return
	}
	if r.PostFormValue("surface") == "download" {
		s.renderDownloadFragment(w, r, id, actionErr)
		return
	}
	s.renderCardFragment(w, r, id, actionErr)
}

// renderCardFragment перечитывает загрузку и рендерит партиал карточки списка
// (htmx-своп). На ошибке действия кладёт сообщение в ActionError и всё равно
// отвечает 200 — htmx не свопит DOM на 4xx/5xx.
func (s *server) renderCardFragment(w http.ResponseWriter, r *http.Request, id string, actionErr error) {
	d, err := s.deps.Reader.GetDownload(r.Context(), id)
	if err != nil {
		s.fragActionErr(w, err, id, "card-"+id)
		return
	}
	sizes, err := s.deps.Reader.LayoutSizeByDownload(r.Context(), []string{id})
	if err != nil {
		s.deps.Logger.Error("layout sizes", "download_id", id, "error", err)
		sizes = nil // деградируем: размер уедет в фолбэк «—», фрагмент не падает
	}
	v := s.buildCardView(*d, store.Now(), sizes[id])
	if actionErr != nil {
		v.ActionError = userErr(r, actionErr, id)
	}
	s.render(w, "card", v)
}

// renderDownloadFragment перечитывает загрузку и рендерит главную область
// страницы загрузки (htmx-своп). Ошибка — в ActionError, ответ 200.
func (s *server) renderDownloadFragment(w http.ResponseWriter, r *http.Request, id string, actionErr error) {
	rd, err := s.deps.Reviewer.ReviewData(r.Context(), id)
	if err != nil {
		s.fragActionErr(w, err, id, "download-main")
		return
	}
	v := s.buildDownloadView(id, rd)
	if actionErr != nil {
		v.ActionError = userErr(r, actionErr, id)
	}
	s.render(w, "download_main", v)
}

// --- REST API ---

type downloadDTO struct {
	ID         string   `json:"id"` // ULID (lowercase)
	SourceType string   `json:"source_type"`
	Infohashes []string `json:"infohashes,omitempty"` // все хеши загрузки (v1 раньше v2)
	Context    string   `json:"context,omitempty"`
	State      string   `json:"state"`
	ErrorCode  string   `json:"error_code,omitempty"`
	ErrorMsg   string   `json:"error_msg,omitempty"`
	CreatedAt  string   `json:"created_at"`
	UpdatedAt  string   `json:"updated_at"`
}

type addRequest struct {
	Source  string `json:"source"`
	Context string `json:"context"`
}

type addResponse struct {
	ID           string   `json:"id"`         // ULID (lowercase)
	Infohashes   []string `json:"infohashes"` // хеши принятого источника (v1 раньше v2) — симметрично downloadDTO
	State        string   `json:"state"`
	Deduplicated bool     `json:"deduplicated"`
}

func (s *server) handleAPIList(w http.ResponseWriter, r *http.Request) {
	downloads, err := s.deps.Reader.ListDownloads(r.Context())
	if err != nil {
		s.apiErr(w, r, err, "")
		return
	}
	out := make([]downloadDTO, 0, len(downloads))
	for _, d := range downloads {
		out = append(out, toDTO(d))
	}
	writeJSON(w, http.StatusOK, out)
}

func (s *server) handleAPIGet(w http.ResponseWriter, r *http.Request) {
	id, err := pathID(r)
	if err != nil {
		// Синтаксически невалидный id = несуществующая сущность (404), в БД не ходим.
		writeJSON(w, http.StatusNotFound, errBody(r, "не найдено", ""))
		return
	}
	d, err := s.deps.Reader.GetDownload(r.Context(), id)
	if err != nil {
		// ErrNotFound → 404, реальный сбой БД → 500 (не маскируем под 404).
		s.apiErr(w, r, err, id)
		return
	}
	writeJSON(w, http.StatusOK, toDTO(*d))
}

func (s *server) handleAPIAdd(w http.ResponseWriter, r *http.Request) {
	var req addRequest
	if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&req); err != nil {
		writeJSON(w, http.StatusBadRequest, errBody(r, "некорректный запрос", ""))
		return
	}
	res, err := s.deps.Ingestor.Ingest(r.Context(), ingest.Request{Source: req.Source, Context: req.Context})
	if err != nil {
		// Приём на любом пути ошибки возвращает нулевой Result (контракт
		// ingest.Ingest) — идентификатора загрузки тут нет и быть не может,
		// коррелируем по request_id.
		s.apiErr(w, r, err, "")
		return
	}
	status := http.StatusCreated
	if res.Deduplicated {
		status = http.StatusOK
	}
	writeJSON(w, status, addResponse{
		ID:           res.DownloadID,
		Infohashes:   res.Infohashes,
		State:        string(res.State),
		Deduplicated: res.Deduplicated,
	})
}

func (s *server) handleAPICancel(w http.ResponseWriter, r *http.Request) {
	s.apiCommand(w, r, s.deps.Commander.Cancel)
}

func (s *server) handleAPIRetry(w http.ResponseWriter, r *http.Request) {
	s.apiCommand(w, r, s.deps.Commander.Retry)
}

func (s *server) apiCommand(w http.ResponseWriter, r *http.Request, cmd func(context.Context, string) error) {
	id, err := pathID(r)
	if err != nil {
		// Синтаксически невалидный id = несуществующая сущность (404), в БД не ходим.
		writeJSON(w, http.StatusNotFound, errBody(r, "не найдено", ""))
		return
	}
	if err := cmd(r.Context(), id); err != nil {
		// Тонкий транспорт: возвращённую use-case'ом/воркером ошибку переводим в
		// статус+сообщение и не логируем повторно (доменный слой уже залогировал,
		// а невалидный ввод — норма, разбирать команде нечего).
		s.apiErr(w, r, err, id)
		return
	}
	d, err := s.deps.Reader.GetDownload(r.Context(), id)
	if err != nil {
		writeJSON(w, http.StatusOK, map[string]string{"id": id})
		return
	}
	writeJSON(w, http.StatusOK, toDTO(*d))
}

// --- helpers ---

func toDTO(d store.Download) downloadDTO {
	return downloadDTO{
		ID:         d.ID,
		SourceType: string(d.SourceType),
		Infohashes: d.HashList(),
		Context:    d.Context,
		State:      string(d.State),
		ErrorCode:  d.ErrorCode.String,
		ErrorMsg:   d.ErrorMsg.String,
		CreatedAt:  d.CreatedAt,
		UpdatedAt:  d.UpdatedAt,
	}
}

func (s *server) toView(d store.Download, now time.Time) downloadView {
	state := string(d.State)
	v := downloadView{
		ID:            d.ID,
		Title:         downloadTitle(d),
		MediaType:     d.RecMediaType.String, // пусто, если распознавания/типа ещё нет
		State:         state,
		Error:         d.ErrorMsg.String,
		Terminal:      d.State.IsTerminal(),
		IsDownloading: d.State == store.StateDownloading,
		SelfPoll:      d.State.IsObservable(),
		PollEvery:     pollSlow,
		Reviewable:    d.State == store.StateReview || d.State == store.StateDeferred,
		Undoable:      d.State == store.StateDone,
		Relinkable: d.State == store.StateReverted || d.State == store.StateCancelled ||
			d.State == store.StateTargetMissing,
		Retriable: d.State == store.StateFailed || d.State == store.StateStuck,
		Note:      desyncNote(d.State),
	}
	// Быстрый интервал — только там, где на поверхности бегут цифры качания.
	if v.IsDownloading {
		v.PollEvery = pollFast
	}
	// Дата добавления в карточке — всегда (source_added_at → фолбэк created_at,
	// как в порядке списка); неразбираемое время просто опускаем.
	if t, ok := addedTime(d); ok {
		v.Added = fmtDate(t, s.deps.Loc)
		v.AddedAgo = humanizeAge(t, now)
	}
	return v
}

// downloadTitle — заголовок загрузки для списка: имя раздачи (display_name,
// то, что ушло в qBittorrent) → распознанное название (RecTitle из листинга) →
// усечённый до одной строки сырой источник. Сырой magnet не должен занимать
// несколько строк заголовка.
func downloadTitle(d store.Download) string {
	if d.DisplayName != "" {
		return displaySafe(d.DisplayName)
	}
	if d.RecTitle.Valid && d.RecTitle.String != "" {
		return displaySafe(d.RecTitle.String)
	}
	return displaySafe(shorten(oneLine(d.SourceRef), 80))
}

// displaySafe готовит заголовок к показу: снимает управляющие символы
// направления письма (`unicode.Bidi_Control` — с ними строка читается не в том
// порядке, в каком хранится) и заменяет управляющие пробелом (перевод строки в
// заголовке склеил бы слова). Имя раздачи — недоверенный вход, а
// `html/template` экранирует разметку, но эти символы пропускает. Цена высока
// на экране подтверждения группового удаления: там поимённое чтение заголовков
// и есть предохранитель необратимой операции.
//
// Снимается ровно этот класс, не весь `unicode.Cf`: в `Cf` лежат и ZWJ/ZWNJ,
// без которых рассыпаются составные эмодзи и меняется написание персидских и
// индийских имён. Чистим на показе, а не на записи — хранение дословное, и
// поиск по списку идёт по сохранённому имени.
func displaySafe(s string) string {
	return strings.Map(func(r rune) rune {
		if unicode.Is(unicode.Bidi_Control, r) {
			return -1
		}
		if r < 0x20 || r == 0x7f {
			return ' '
		}
		return r
	}, s)
}

// oneLine схлопывает переводы строк и лишние пробелы — сырой источник в
// заголовок кладём одной строкой.
func oneLine(s string) string {
	return strings.Join(strings.Fields(s), " ")
}

// addedTime — базис даты добавления карточки: время добавления в источник
// (source_added_at, qBittorrent added_on) с фолбэком на время создания загрузки
// (created_at), согласованно с порядком списка. ok=false — распарсить нечего.
func addedTime(d store.Download) (time.Time, bool) {
	s := d.CreatedAt
	if d.SourceAddedAt.Valid && d.SourceAddedAt.String != "" {
		s = d.SourceAddedAt.String
	}
	t, err := store.ParseTime(s)
	if err != nil {
		return time.Time{}, false
	}
	return t, true
}

// desyncNote — пояснение состояния рассинхрона для UI (см. state-reconciliation).
func desyncNote(s store.State) string {
	switch s {
	case store.StateTargetMissing:
		return "разложено, но файлов в библиотеке нет — можно привязать заново"
	case store.StateOrphaned:
		return "источник удалён — это последняя копия данных, откат недоступен"
	case store.StateDeleted:
		return "удалены и источник, и файлы в библиотеке"
	default:
		return ""
	}
}

// shorten обрезает строку до n рун (не байт), добавляя многоточие. Рунобезопасно:
// кириллица (2 байта/руна) иначе резалась бы посреди руны в U+FFFD — частый случай
// для source_ref-заголовков.
func shorten(s string, n int) string {
	if utf8.RuneCountInString(s) <= n {
		return s
	}
	return string([]rune(s)[:n]) + "…"
}

// pathID валидирует {id} из URL как ULID и нормализует к lowercase — до
// любого обращения к БД (сравнение в SQLite побайтовое). Невалидный id
// трактуется вызывающими как несуществующая сущность (404).
func pathID(r *http.Request) (string, error) {
	return ident.Parse(chi.URLParam(r, "id"))
}

// liveFor достаёт живую телеметрию по любому из хешей загрузки.
func (s *server) liveFor(d store.Download) (worker.Live, bool) {
	for _, h := range d.HashList() {
		if l, ok := s.deps.Live.Live(h); ok {
			return l, true
		}
	}
	return worker.Live{}, false
}

func redirectErr(w http.ResponseWriter, r *http.Request, msg string) {
	http.Redirect(w, r, "/?err="+url.QueryEscape(msg), http.StatusSeeOther)
}

func writeJSON(w http.ResponseWriter, status int, v any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	_ = json.NewEncoder(w).Encode(v)
}

// classifyErr транслирует доменную ошибку в HTTP-статус и нейтральное
// человекочитаемое сообщение публичного канала (без сырого err.Error() и
// деталей реализации): ErrNotFound → 404; валидация источника
// (magnet.ErrNotMagnet), oversized `.torrent` (ingest.ErrTorrentTooLarge) и
// некорректный ввод команды (worker.ErrInvalidInput) →
// 400; недокачанный источник (worker.ErrNotReady), коллизия цели
// (layout.ErrCollision), непомещающееся целевое имя (layout.ErrNameTooLong) и
// конфликт состояния (worker.ErrConflict) → 409; прочее
// → 500. Полная ошибка уже в логах на доменной границе — наружу отдаём только
// сообщение + корреляционный ключ.
func classifyErr(err error) (int, string) {
	switch {
	case errors.Is(err, store.ErrNotFound):
		return http.StatusNotFound, "не найдено"
	case errors.Is(err, magnet.ErrNotMagnet), errors.Is(err, torrent.ErrNotTorrent):
		return http.StatusBadRequest, "некорректный источник"
	case errors.Is(err, ingest.ErrTorrentTooLarge):
		// Промах ввода (файл больше лимита), не сбой сервера — 400, а не 500.
		return http.StatusBadRequest, "файл .torrent слишком большой"
	case errors.Is(err, worker.ErrInvalidInput):
		// Промах пользователя (пустая подсказка, неизвестный тип/провайдер, …),
		// не сбой сервера.
		return http.StatusBadRequest, "некорректный ввод"
	case errors.Is(err, worker.ErrNotReady):
		// Источник ещё качается — actionable причина, показываем конкретно.
		return http.StatusConflict, "торрент ещё качается, дождитесь докачки"
	case errors.Is(err, layout.ErrCollision):
		// Целевой путь уже занят: задача штатно ушла в review с причиной —
		// это не сбой, а требующий разбора конфликт.
		return http.StatusConflict, "целевой файл уже существует, задача отправлена в ревью"
	case errors.Is(err, layout.ErrNameTooLong):
		// Целевое имя не помещается в файловую систему: задача штатно ушла в
		// review, где название правится подсказкой. Не сбой сервера.
		return http.StatusConflict, "целевое имя слишком длинное, задача отправлена в ревью"
	case errors.Is(err, worker.ErrConflict):
		// Нормальный конфликт состояния (операция недопустима сейчас), не сбой.
		return http.StatusConflict, "действие недоступно в текущем состоянии"
	case errors.Is(err, errManualSource):
		// Ошибка ручного ввода источника — показываем пользователю как есть.
		return http.StatusBadRequest, errManualSource.Error()
	case errors.Is(err, errInvalidCandidate):
		return http.StatusBadRequest, errInvalidCandidate.Error()
	case errors.Is(err, errBatchEmpty), errors.Is(err, errBatchTooLarge),
		errors.Is(err, errBatchBadID):
		// Отказы разбора пачки группового удаления — промах ввода, не сбой:
		// текст sentinel'а показывается человеку как есть.
		return http.StatusBadRequest, err.Error()
	default:
		return http.StatusInternalServerError, "внутренняя ошибка"
	}
}

// errBody — тело ошибки REST API: нейтральное сообщение + корреляционный ключ
// для владельца (download_id, если операция привязана к загрузке, иначе
// request_id запроса), по которому он найдёт полную ошибку в логах.
func errBody(r *http.Request, msg string, downloadID string) map[string]any {
	body := map[string]any{"error": msg}
	if downloadID != "" {
		body["download_id"] = downloadID
	} else {
		body["request_id"] = middleware.GetReqID(r.Context())
	}
	return body
}

// apiErr пишет ответ об ошибке REST API по доменной ошибке (статус + тело).
func (s *server) apiErr(w http.ResponseWriter, r *http.Request, err error, downloadID string) {
	status, msg := classifyErr(err)
	writeJSON(w, status, errBody(r, msg, downloadID))
}

// userErr — сообщение публичного канала для веб-UI: нейтральный текст по
// доменной ошибке + корреляционный ключ владельцу (download_id, если операция
// привязана к загрузке, иначе request_id). Сырой текст ошибки наружу не идёт.
func userErr(r *http.Request, err error, downloadID string) string {
	_, msg := classifyErr(err)
	if downloadID != "" {
		return fmt.Sprintf("%s (download_id=%s)", msg, downloadID)
	}
	return fmt.Sprintf("%s (request_id=%s)", msg, middleware.GetReqID(r.Context()))
}

// requestLogger пишет структурированный лог по каждому запросу. Служебные и
// навигационные GET (healthcheck, страницы веб-UI, статика) пишем на DEBUG,
// чтобы не зашумлять INFO; мутации и REST API остаются на INFO.
func requestLogger(logger *slog.Logger) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
			start := time.Now() //nolint:forbidigo // измеряем длительность запроса, а не метку времени в БД

			next.ServeHTTP(ww, r)

			// http.route — низкокардинальный шаблон маршрута (chi), а не
			// конкретный путь; при отсутствии шаблона падаем на путь.
			route := chi.RouteContext(r.Context()).RoutePattern()
			if route == "" {
				route = r.URL.Path
			}
			logger.Log(r.Context(), requestLogLevel(r), "http request",
				"transport", "http",
				"http.method", r.Method,
				"http.route", route,
				"http.status_code", ww.Status(),
				"bytes", ww.BytesWritten(),
				"duration_ms", time.Since(start).Milliseconds(),
				"request_id", middleware.GetReqID(r.Context()),
			)
		})
	}
}

// requestLogLevel понижает уровень для служебных и навигационных запросов:
// healthcheck и GET-страницы веб-UI (включая статику). Мутации и REST
// API (`/api/...`) остаются на INFO.
func requestLogLevel(r *http.Request) slog.Level {
	switch {
	case r.URL.Path == "/healthz":
		return slog.LevelDebug
	case r.Method == http.MethodGet && !strings.HasPrefix(r.URL.Path, "/api"):
		return slog.LevelDebug
	default:
		return slog.LevelInfo
	}
}
