package httpapi

import (
	"errors"
	"fmt"
	"net/http"
	"time"

	"git.vakhrushev.me/av/jellybit/internal/store"
	"git.vakhrushev.me/av/jellybit/internal/worker"
)

// LiveStatus — источник живой телеметрии загрузок (снимок воркера). Контракт
// узкий и не зависит от способа доставки в браузер (поллинг сейчас, SSE позже).
type LiveStatus interface {
	// Live возвращает телеметрию по infohash; ok=false — данных нет (нет
	// торрента в последнем тике), читатель деградирует без живых значений.
	Live(infohash string) (worker.Live, bool)
}

// Интервалы самообновления поверхностей (значение hx-trigger="every …").
//
//   - pollFast — поверхность с живыми цифрами качания (карточка в downloading).
//     Равен [worker].poll_interval: воркер снимает телеметрию раз в 5 с, и
//     опрашивать чаще значит возвращать тот же кадр (docs/database.md).
//   - pollSlow — все прочие наблюдаемые поверхности, включая страницу
//     /download/{id} в любом состоянии: там меняется только состояние, а сборка
//     страницы считает предпросмотр раскладки и ходит в ФС.
const (
	pollFast = "5s"
	pollSlow = "15s"
)

// noLive — заглушка на случай, когда источник телеметрии не подключён
// (Deps.Live == nil): живых данных нет, UI деградирует штатно.
type noLive struct{}

func (noLive) Live(string) (worker.Live, bool) { return worker.Live{}, false }

// progressView — живой прогресс активной загрузки (вложенный блок карточки).
// Active управляется store-состоянием (downloading), а не qbt: вне downloading
// скорость и ETA смысла не имеют, и блок не рисуется. Своего опроса блок не
// ведёт — цифры приезжают с тиком карточки (web-ui, «Самообновление живой
// задачи»).
type progressView struct {
	ID      string
	Active  bool // store-состояние downloading → показываем бар
	Has     bool // есть данные снимка
	Percent int
	DlSpeed string
	ETA     string
}

// seedingView — живая статистика раздачи (секция страницы загрузки).
// Has истинно только если торрент сидирует и данные есть — иначе секция
// деградирует (пустой контейнер). Своего опроса секция не ведёт: она лежит
// внутри свопаемой области страницы, и её цифры приезжают с тиком страницы.
type seedingView struct {
	ID       string
	Has      bool
	Percent  int
	Ratio    string
	Uploaded string
	Seeds    int
	Peers    int
	UpSpeed  string
}

func buildProgress(id string, active bool, l worker.Live, ok bool) progressView {
	v := progressView{ID: id, Active: active}
	if ok {
		v.Has = true
		v.Percent = pct(l.Progress)
		v.DlSpeed = fmtSpeed(l.DlSpeed)
		// ETA опускаем при неизвестном/sentinel (stalled) — «осталось —» уродливо;
		// шаблонный {{if .ETA}} тогда скрывает хвост строки.
		if e := fmtETA(l.ETA); e != "—" {
			v.ETA = e
		}
	}
	return v
}

func buildSeeding(id string, l worker.Live, ok bool) seedingView {
	v := seedingView{ID: id}
	if ok && l.Seeding {
		v.Has = true
		v.Percent = pct(l.Progress)
		v.Ratio = fmtRatio(l.Ratio)
		v.Uploaded = fmtBytes(l.Uploaded)
		v.Seeds = l.Seeds
		v.Peers = l.Peers
		v.UpSpeed = fmtSpeed(l.UpSpeed)
	}
	return v
}

// handleFragProgress отдаёт партиал живого прогресса карточки.
//
// Потребителя в новой разметке у маршрута нет: блок прогресса едет с тиком
// карточки. Маршрут оставлен гасителем вкладок, отрисованных прошлой версией:
// htmx не свопит 4xx/5xx и не снимает hx-trigger, поэтому удалённый маршрут
// заставил бы старую вкладку стучать бесконечно, а партиал без поллинга гасит
// её первым же тиком. Убирается отдельной уборкой после деплоя.
func (s *server) handleFragProgress(w http.ResponseWriter, r *http.Request) {
	id, err := pathID(r)
	if err != nil {
		http.Error(w, "не найдено", http.StatusNotFound)
		return
	}
	d, err := s.deps.Reader.GetDownload(r.Context(), id)
	if err != nil {
		s.fragTickErr(w, err, id, "dl-live-"+id)
		return
	}
	active := d.State == store.StateDownloading
	l, ok := s.liveFor(*d)
	s.render(w, "progress", buildProgress(id, active, l, ok))
}

// handleFragCard отдаёт карточку списка целиком — это тик её самообновления.
// Пока задача наблюдаема (State.IsObservable), карточка опрашивает себя и на
// каждом тике приносит текущее состояние целиком: бейдж, заголовок, набор
// действий и живые цифры. Перестала быть наблюдаемой — свежая карточка уже не
// несёт самополлинга, и цикл завершается сам.
func (s *server) handleFragCard(w http.ResponseWriter, r *http.Request) {
	id, err := pathID(r)
	if err != nil {
		http.Error(w, "не найдено", http.StatusNotFound)
		return
	}
	d, err := s.deps.Reader.GetDownload(r.Context(), id)
	if err != nil {
		s.fragTickErr(w, err, id, "card-"+id)
		return
	}
	// Размер читаем так же, как своповый путь действия: самообновление
	// обслуживает и состояния с разложенными файлами, и подмена известного
	// размера прочерком была бы потерей поля полного рендера.
	sizes, err := s.deps.Reader.LayoutSizeByDownload(r.Context(), []string{id})
	if err != nil {
		// WARN, а не ERROR: тик повторится сам (docs/conventions/logging.md).
		s.deps.Logger.Warn("layout sizes", "download_id", id, "error", err)
		sizes = nil // деградируем: размер уедет в фолбэк, тик не падает
	}
	s.render(w, "card", s.buildCardView(*d, store.Now(), sizes[id]))
}

// handleFragSeeding отдаёт партиал секции «Раздача».
//
// Как и у прогресса, потребителя в новой разметке нет: секция едет с тиком
// страницы. Маршрут оставлен гасителем старых вкладок — см. handleFragProgress.
func (s *server) handleFragSeeding(w http.ResponseWriter, r *http.Request) {
	id, err := pathID(r)
	if err != nil {
		http.Error(w, "не найдено", http.StatusNotFound)
		return
	}
	d, err := s.deps.Reader.GetDownload(r.Context(), id)
	if err != nil {
		s.fragTickErr(w, err, id, "seeding-"+id)
		return
	}
	l, ok := s.liveFor(*d)
	s.render(w, "seeding", buildSeeding(id, l, ok))
}

// fragTickErr — отказ чтения на повторяющемся тике самообновления: 200 и
// фрагмент, который объясняет положение дел и НЕ несёт самообновления.
//
// Статусом ошибки отвечать нельзя: htmx не свопит DOM на 4xx/5xx, поэтому
// поверхность осталась бы прежней навсегда (человек не отличит «ничего не
// изменилось» от «сервер не отвечает»), а её опрос продолжался бы бесконечно —
// при затяжном отказе хранилища это поток записей в журнал с каждой открытой
// вкладки. Фрагмент без hx-* завершает цикл сам (web-ui, «Самообновление живой
// задачи»).
//
// Уровень WARN, а не ERROR: у тика есть штатный ретрай — следующий тик повторит
// (docs/conventions/logging.md, «Ошибки»).
func (s *server) fragTickErr(w http.ResponseWriter, err error, id, rootID string) {
	s.fragNote(w, err, id, rootID, false)
}

// fragActionErr — отказ чтения на разовом действии человека: тот же
// самозавершающийся фрагмент, но ERROR: ретрая у действия нет.
func (s *server) fragActionErr(w http.ResponseWriter, err error, id, rootID string) {
	s.fragNote(w, err, id, rootID, true)
}

// fragNote отдаёт фрагмент отказа с корнем rootID. Корень обязателен и
// приходит от вызывающего: htmx свопит outerHTML, и фрагмент без целевого id
// снёс бы узел вместе с якорем — следующее действие и поллер цели не нашли бы
// (docs/conventions/web-ui.md, «Единый источник разметки»).
func (s *server) fragNote(w http.ResponseWriter, err error, id, rootID string, oneShot bool) {
	text := "задача не найдена — обновите страницу"
	if !errors.Is(err, store.ErrNotFound) {
		if oneShot {
			s.deps.Logger.Error("live fragment", "download_id", id, "error", err)
		} else {
			s.deps.Logger.Warn("live fragment", "download_id", id, "error", err)
		}
		text = "не удалось обновить — обновите страницу"
	}
	s.render(w, "frag_note", fragNoteView{RootID: rootID, Text: text})
}

// fragNoteView — самозавершающийся фрагмент отказа (см. fragNote). RootID —
// id узла, который фрагмент собой заменяет.
type fragNoteView struct {
	RootID string
	Text   string
}

// --- форматирование телеметрии ---

// etaInfinity — sentinel qBittorrent для неизвестного/бесконечного ETA.
const etaInfinity = 8640000

func pct(progress float64) int {
	if progress < 0 {
		return 0
	}
	if progress > 1 {
		return 100
	}
	return int(progress*100 + 0.5)
}

// fmtBytes переводит байты в человекочитаемые единицы (двоичные, IEC).
func fmtBytes(n int64) string {
	if n < 1024 {
		return fmt.Sprintf("%d Б", n)
	}
	const unit = 1024
	div, exp := int64(unit), 0
	units := []string{"КиБ", "МиБ", "ГиБ", "ТиБ", "ПиБ", "ЭиБ"}
	for v := n / unit; v >= unit && exp < len(units)-1; v /= unit {
		div *= unit
		exp++
	}
	return fmt.Sprintf("%.1f %s", float64(n)/float64(div), units[exp])
}

// fmtSpeed форматирует скорость (байт/с).
func fmtSpeed(n int64) string {
	if n <= 0 {
		return "0 Б/с"
	}
	return fmtBytes(n) + "/с"
}

// fmtETA форматирует оценку времени; sentinel/отрицательное → «—».
func fmtETA(sec int64) string {
	if sec < 0 || sec >= etaInfinity {
		return "—"
	}
	switch {
	case sec < 60:
		return fmt.Sprintf("%d с", sec)
	case sec < 3600:
		return fmt.Sprintf("%d мин", sec/60)
	case sec < 86400:
		return fmt.Sprintf("%d ч %d мин", sec/3600, (sec%3600)/60)
	default:
		return fmt.Sprintf("%d дн", sec/86400)
	}
}

// fmtRatio форматирует рейтинг отдачи; отрицательный (sentinel) → «—».
func fmtRatio(r float64) string {
	if r < 0 {
		return "—"
	}
	return fmt.Sprintf("%.2f", r)
}

// fmtDate — абсолютная дата добавления для карточки в таймзоне отображения
// (general.timezone; хранение всегда UTC). loc не бывает nil — NewRouter
// подставляет UTC по умолчанию.
func fmtDate(t time.Time, loc *time.Location) string {
	return t.In(loc).Format("2006-01-02")
}

// humanizeAge — относительная давность («5 дней назад») от now до t. Будущее
// (рассинхрон часов) схлопывается в «только что». Единицы огрубляются к
// минутам/часам/дням/месяцам/годам — для обзора возраста этого достаточно.
func humanizeAge(t, now time.Time) string {
	d := now.Sub(t)
	if d < time.Minute {
		return "только что"
	}
	switch {
	case d < time.Hour:
		n := int(d / time.Minute)
		return fmt.Sprintf("%d %s назад", n, plural(n, "минуту", "минуты", "минут"))
	case d < 24*time.Hour:
		n := int(d / time.Hour)
		return fmt.Sprintf("%d %s назад", n, plural(n, "час", "часа", "часов"))
	case d < 30*24*time.Hour:
		n := int(d / (24 * time.Hour))
		return fmt.Sprintf("%d %s назад", n, plural(n, "день", "дня", "дней"))
	case d < 365*24*time.Hour:
		n := int(d / (30 * 24 * time.Hour))
		return fmt.Sprintf("%d %s назад", n, plural(n, "месяц", "месяца", "месяцев"))
	default:
		n := int(d / (365 * 24 * time.Hour))
		return fmt.Sprintf("%d %s назад", n, plural(n, "год", "года", "лет"))
	}
}

// plural выбирает русскую форму по числу (1 файл / 2 файла / 5 файлов).
func plural(n int, one, few, many string) string {
	if n < 0 {
		n = -n
	}
	if m := n % 100; m >= 11 && m <= 14 {
		return many
	}
	switch n % 10 {
	case 1:
		return one
	case 2, 3, 4:
		return few
	default:
		return many
	}
}

// sizeText — размер раздачи для карточки: живой общий размер из снимка, иначе
// суммарный размер разложенных файлов (фолбэк для orphaned), иначе «—».
func sizeText(l worker.Live, ok bool, layoutSize int64) string {
	if ok && l.TotalSize > 0 {
		return fmtBytes(l.TotalSize)
	}
	if layoutSize > 0 {
		return fmtBytes(layoutSize)
	}
	return "—"
}

// ratioText — рейтинг отдачи для карточки: из живого снимка, иначе «—» (торрента
// нет в qBittorrent).
func ratioText(l worker.Live, ok bool) string {
	if !ok {
		return "—"
	}
	return fmtRatio(l.Ratio)
}
