package httpapi

import (
	"context"
	"errors"
	"net/http"
	"strconv"
	"time"

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

// maxBulkDelete — верхний предел числа загрузок в одной пачке. Подтверждение,
// перечисляющее больше, человек не читает — то есть перестаёт быть
// подтверждением; плюс один синхронный запрос упирается в столько же
// последовательных вызовов qBittorrent. Число названо на самой странице выбора:
// предел, о котором узнают только из отказа, отнимает уже сделанную работу.
const maxBulkDelete = 20

// bulkFailThreshold — сколько подряд идущих отказов внешнего сервиса
// прекращают проход. Удаление снимает библиотечные ссылки раньше, чем сносит
// раздачу: при лежащем qBittorrent каждая единица успевает выполнить
// необратимый локальный шаг и упасть на внешнем, оставив тайтл без раскладки и
// не освободив места. Счётчик сбрасывается на успехе — одиночная сетевая
// ошибка пачку не рвёт.
const bulkFailThreshold = 3

// bulkBudget — потолок времени на один проход пачки. Удаление держит общий
// замок воркера на всё время обращения к qBittorrent, поэтому медленно, но
// успешно отвечающий сосед останавливает фоновую работу целиком, а порог
// отказов такого не ловит — он считает только ошибки. Проверяется МЕЖДУ
// единицами, а не отменой контекста: начатое удаление обрывать нельзя, иначе
// оно встанет между снятием библиотечных ссылок и сносом раздачи.
//
// Переменная, а не константа, ровно по одной причине: тест укорачивает её —
// иначе проверка потолка стоила бы двух минут прогона.
var bulkBudget = 2 * time.Minute

// Отказы разбора пачки. Текст — публичного канала: он показывается человеку
// как есть, как у прочих sentinel'ов транспорта. Трансляция в статус и
// сообщение живёт в единой точке `classifyErr`, а не рядом.
var (
	errBatchEmpty    = errors.New("ни одна загрузка не выбрана")
	errBatchTooLarge = errors.New("за один раз можно удалить не больше " +
		strconv.Itoa(maxBulkDelete) + " загрузок")
	errBatchBadID = errors.New("некорректный идентификатор загрузки — запрос отклонён целиком")
	errBatchForm  = errors.New("форма запроса не разобрана — запрос отклонён целиком")
)

// bulkRow — строка загрузки на любом из трёх экранов группового удаления.
type bulkRow struct {
	ID       string
	Title    string
	State    string
	Selected bool   // отметка сохранена при возврате отказа
	LastCopy bool   // orphaned: библиотечная ссылка осталась последней копией
	Missing  bool   // записи в хранилище нет
	Unread   bool   // состояние прочитать не удалось (отказ хранилища)
	Reason   string // причина отказа (только на экране результата)
}

// bulkSelectView — страница выбора (`GET /delete`) и она же ответ на отказ
// разбора: отметки при этом сохраняются, иначе проверка стирает всю работу.
type bulkSelectView struct {
	Error string
	Max   int
	Rows  []bulkRow
}

// bulkConfirmView — страница подтверждения: выбранные названы поимённо.
type bulkConfirmView struct {
	Rows []bulkRow
}

// bulkResultView — отчёт: обе половины исхода поимённо плюс остаток, если
// проход остановлен системным отказом.
type bulkResultView struct {
	Deleted    []bulkRow
	Failed     []bulkRow
	Skipped    []bulkRow
	StopReason string
}

// handleBulkDeletePage — страница выбора. Самообновления не несёт сознательно:
// своп разметки унёс бы отметки, и человек подтвердил бы необратимое удаление
// по выбору, которого уже не видит (см. openspec/specs/web-ui).
func (s *server) handleBulkDeletePage(w http.ResponseWriter, r *http.Request) {
	s.renderBulkSelect(w, r, "", nil)
}

// renderBulkSelect отрисовывает страницу выбора, помечая отмеченными те строки,
// чьи идентификаторы человек уже выбрал (selected). Общий путь для чистого
// открытия страницы и для любого отказа разбора.
func (s *server) renderBulkSelect(w http.ResponseWriter, r *http.Request, msg string, selected []string) {
	ds, err := s.deps.Reader.ListDeletableDownloads(r.Context())
	if err != nil {
		s.deps.Logger.Error("list deletable downloads", "error", err)
		http.Error(w, "внутренняя ошибка", http.StatusInternalServerError)
		return
	}
	mark := make(map[string]bool, len(selected))
	for _, id := range selected {
		mark[id] = true
	}
	view := bulkSelectView{Error: msg, Max: maxBulkDelete}
	for _, d := range ds {
		view.Rows = append(view.Rows, bulkRow{
			ID:       d.ID,
			Title:    downloadTitle(d),
			State:    string(d.State),
			Selected: mark[d.ID],
			LastCopy: d.State == store.StateOrphaned,
		})
	}
	s.render(w, "delete.html", view)
}

// handleBulkDeleteConfirm — экран подтверждения. Ничего не меняет: разбирает
// вход, читает выбранные загрузки и называет каждую поимённо.
func (s *server) handleBulkDeleteConfirm(w http.ResponseWriter, r *http.Request) {
	ids, err := s.parseBulkBatch(r)
	if err != nil {
		s.renderBulkSelect(w, r, bulkErrMsg(err), ids)
		return
	}
	s.render(w, "delete_confirm.html", bulkConfirmView{Rows: s.bulkRows(r.Context(), ids)})
}

// handleBulkDelete — исполнение пачки. Признак подтверждения проверяется ДО
// разбора и до единого вызова удаления: подтверждение — условие операции, а не
// украшение экрана.
func (s *server) handleBulkDelete(w http.ResponseWriter, r *http.Request) {
	if err := r.ParseForm(); err != nil || r.PostForm.Get("confirm") != "1" {
		s.renderBulkSelect(w, r, "Удаление уходит только со страницы подтверждения.", nil)
		return
	}
	// Исполняющий запрос — самостоятельная входная граница: идентификаторы
	// приходят формой заново, состояния между шагами сервис не хранит.
	ids, err := s.parseBulkBatch(r)
	if err != nil {
		s.renderBulkSelect(w, r, bulkErrMsg(err), ids)
		return
	}

	// Контекст исполнения отвязан от запроса: обрыв связи не вправе оборвать
	// необратимую операцию на середине — в том числе внутри одной загрузки,
	// между снятием библиотечных ссылок и сносом раздачи. Строки отчёта читаются
	// тем же контекстом: собранные отменённым, они превратили бы весь отчёт в
	// «загрузка не найдена» ровно там, где удаление идёт штатно.
	ctx := context.WithoutCancel(r.Context())
	rows := s.bulkRows(ctx, ids)

	var res bulkResultView
	streak := 0
	deadline := store.Now().Add(bulkBudget)
	for i, row := range rows {
		if res.StopReason != "" {
			res.Skipped = append(res.Skipped, rows[i])
			continue
		}
		if store.Now().After(deadline) {
			res.StopReason = "Проход занял дольше отведённого времени и остановлен: " +
				"пока идёт пачка, остальная работа сервиса ждёт."
			res.Skipped = append(res.Skipped, rows[i])
			continue
		}
		err := s.deps.Reviewer.Delete(ctx, row.ID)
		if err == nil {
			streak = 0
			res.Deleted = append(res.Deleted, row)
			continue
		}
		row.Reason = userErr(r, err, row.ID)
		res.Failed = append(res.Failed, row)
		// Конфликт состояния и отсутствие записи — про саму задачу, а не про
		// доступность соседа: счётчик системных отказов они не двигают.
		if errors.Is(err, worker.ErrConflict) || errors.Is(err, store.ErrNotFound) {
			continue
		}
		streak++
		if streak >= bulkFailThreshold {
			res.StopReason = "Внешний сервис отказывает подряд — проход остановлен, " +
				"чтобы не снимать раскладку у остальных без освобождения места."
		}
	}
	// Исход каждой единицы поимённо: ответ мог не дойти (вкладку закрыли), и
	// журнал — единственное, по чему потом видно, что снесено, что отказало и до
	// чего проход не дошёл. На воркер полагаться нельзя: отказы по конфликту и
	// отсутствию записи он пишет на DEBUG.
	for _, row := range res.Deleted {
		s.deps.Logger.Info("bulk delete item", "download_id", row.ID, "outcome", "deleted")
	}
	for _, row := range res.Failed {
		s.deps.Logger.Info("bulk delete item", "download_id", row.ID, "outcome", "failed",
			"reason", row.Reason)
	}
	for _, row := range res.Skipped {
		s.deps.Logger.Info("bulk delete item", "download_id", row.ID, "outcome", "skipped")
	}
	s.deps.Logger.Info("bulk delete finished",
		"requested", len(rows), "deleted", len(res.Deleted),
		"failed", len(res.Failed), "skipped", len(res.Skipped),
		"stopped", res.StopReason != "")

	s.render(w, "delete_result.html", res)
}

// bulkRows читает выбранные загрузки для показа поимённо. Идентификатор без
// записи в хранилище не выбрасывается молча — он идёт своей строкой: человек
// подтверждает пачку, и она обязана совпадать с тем, что он выбрал.
func (s *server) bulkRows(ctx context.Context, ids []string) []bulkRow {
	rows := make([]bulkRow, 0, len(ids))
	for _, id := range ids {
		d, err := s.deps.Reader.GetDownload(ctx, id)
		switch {
		case errors.Is(err, store.ErrNotFound):
			rows = append(rows, bulkRow{ID: id, Missing: true, Title: "загрузка не найдена"})
			continue
		case err != nil || d == nil:
			// Отказ хранилища — это НЕ «записи нет». Выдав одно за другое, экран
			// сказал бы «удалять нечего» о загрузке, которую пачка снесёт
			// по-настоящему, и для orphaned унёс бы отметку последней копии —
			// единственный оставшийся предохранитель. Приватный канал: пишем
			// здесь, потому что выше эта ошибка не всплывает.
			s.deps.Logger.Error("bulk delete: read download", "download_id", id, "error", err)
			rows = append(rows, bulkRow{ID: id, Unread: true, Title: "состояние прочитать не удалось"})
			continue
		}
		rows = append(rows, bulkRow{
			ID:       d.ID,
			Title:    downloadTitle(*d),
			State:    string(d.State),
			LastCopy: d.State == store.StateOrphaned,
		})
	}
	return rows
}

// parseBulkBatch разбирает пачку идентификаторов с формы. Проверки одинаковы на
// обеих границах — подтверждения и исполнения. Возвращает разобранные
// идентификаторы даже вместе с отказом: страница выбора возвращает по ним
// отметки, чтобы отказ не стирал проделанную работу.
func (s *server) parseBulkBatch(r *http.Request) ([]string, error) {
	if err := r.ParseForm(); err != nil {
		// Приватный канал: выше эта ошибка не всплывает, а человеку про
		// идентификаторы говорить нечего — тело не прочиталось целиком.
		s.deps.Logger.Error("bulk delete: parse form", "error", err)
		return nil, errBatchForm
	}
	// Только тело: признак подтверждения и пачка приходят формой, и принимать
	// их из строки запроса значит принимать подтверждение оттуда, откуда
	// требование его не заказывало.
	raw := r.PostForm["id"]
	seen := make(map[string]bool, len(raw))
	ids := make([]string, 0, len(raw))
	for _, v := range raw {
		id, err := ident.Parse(v)
		if err != nil {
			// Молча пропустить нельзя: человек подтвердил удаление поимённо, и
			// выброшенный идентификатор развёл бы подтверждённое с исполненным.
			return ids, errBatchBadID
		}
		if seen[id] {
			continue
		}
		seen[id] = true
		ids = append(ids, id)
	}
	if len(ids) == 0 {
		return nil, errBatchEmpty
	}
	if len(ids) > maxBulkDelete {
		return ids, errBatchTooLarge
	}
	return ids, nil
}

// bulkErrMsg — сообщение человеку об отказе разбора. Сам текст берётся из
// единой точки трансляции (`classifyErr`); здесь добавляется только подсказка,
// что делать дальше, — она осмысленна ровно на этой странице.
func bulkErrMsg(err error) string {
	_, msg := classifyErr(err)
	if errors.Is(err, errBatchTooLarge) {
		return msg + ". Отметки сохранены — сними лишние."
	}
	return msg + "."
}
