package httpapi

import (
	"context"
	"errors"
	"net/http"
	"net/url"
	"strconv"
	"strings"

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

// Reviewer — операции ревью и раскладки (worker.Worker).
type Reviewer interface {
	ReviewData(ctx context.Context, id string) (*worker.ReviewData, error)
	Apply(ctx context.Context, id string) error
	Refine(ctx context.Context, id string, hint string) error
	IgnoreFile(ctx context.Context, id string, src string) error
	Defer(ctx context.Context, id string) error
	Undo(ctx context.Context, id string) error
	Delete(ctx context.Context, id string) error
	Dismiss(ctx context.Context, id string) error
	Relink(ctx context.Context, id string) error
	Rerecognize(ctx context.Context, id string) error
	ChooseCandidate(ctx context.Context, id, candidateID string) error
	SetProviderID(ctx context.Context, id string, provider, providerID string) error
	AddManualSource(ctx context.Context, id, provider, providerID string) error
	ClearProvider(ctx context.Context, id string) error
	RefreshDisplayName(ctx context.Context, id string) error
}

// --- Представление страницы ревью ---

type reviewView struct {
	ID            string
	Source        string
	Context       string
	State         string
	Error         string // из ?err=
	StateError    string // error_msg загрузки (напр. причина коллизии)
	PreviewError  string // почему предпросмотр не построился, посчитано на показе
	MediaType     string
	IsSeries      bool
	Title         string
	OriginalTitle string
	Director      string // режиссёр эффективного источника (пусто — неизвестен)
	Year          int
	SeasonSummary string // сводка сезонов для сериала (пусто для фильма)
	Provider      string
	ProviderID    string
	MatchURL      string // ссылка на подтверждённую запись метабазы (пусто — текстом)
	Confidence    string
	Reasons       []string
	Hints         []string
	Files         []fileRow
	HasPlan       bool
	HasLinks      bool         // есть хотя бы один целевой путь → можно применять
	NoBase        bool         // выбрано «без базы»
	Sources       []sourceView // единый список источников совпадения
	BlockError    string       // ошибка выбора внутри блока (htmx); не путать с Error (?err=)
	ActionBarOOB  bool         // рендерить панель действий как oob-фрагмент (своп источника)
}

// sourceView — строка единого списка источников на экране ревью: нейронка или
// кандидат базы. Инфо и предпросмотр раскладки показываются для активного
// источника из верхнеуровневых полей reviewView, поэтому per-source превью
// строка не несёт.
type sourceView struct {
	Kind        string // "neural" | "candidate"
	CandidateID string
	Provider    string
	ProviderID  string
	Title       string
	Year        int
	MatchURL    string
	Active      bool
}

func (s *server) handleReview(w http.ResponseWriter, r *http.Request) {
	id, err := pathID(r)
	if err != nil {
		// Невалидный id = несуществующая сущность; в БД не ходим.
		http.Error(w, "задача не найдена", http.StatusNotFound)
		return
	}
	rd, err := s.deps.Reviewer.ReviewData(r.Context(), id)
	if err != nil {
		if errors.Is(err, store.ErrNotFound) {
			http.Error(w, "задача не найдена", http.StatusNotFound)
			return
		}
		s.deps.Logger.Error("review data failed", "download_id", id, "error", err)
		http.Error(w, "внутренняя ошибка", http.StatusInternalServerError)
		return
	}

	s.render(w, "review.html", buildReviewView(id, rd, r.URL.Query().Get("err")))
}

// buildReviewView собирает представление страницы ревью из доменных данных.
// Общий для полной страницы (handleReview) и htmx-свопа блока источника
// (reviewBlockAction); errMsg — верхний баннер из ?err= (пусто на htmx-пути,
// там ошибка идёт в BlockError).
func buildReviewView(id string, rd *worker.ReviewData, errMsg string) reviewView {
	view := reviewView{
		ID:         id,
		Source:     shorten(rd.Download.SourceRef, 80),
		Context:    rd.Download.Context,
		State:      string(rd.Download.State),
		Error:      errMsg,
		StateError: rd.Download.ErrorMsg.String,
		// Причина пустого предпросмотра считается на показе и потому всегда про
		// текущий план; error_msg остался от последнего перехода и после смены
		// источника уже не про него.
		PreviewError: rd.PreviewError,
		Hints:        rd.Hints,
	}
	if rec := rd.Recognition; rec != nil {
		view.MediaType = string(rd.Plan.Type)
		view.IsSeries = rd.Plan.Type == "series"
		view.Title = rd.Plan.Title
		view.OriginalTitle = rd.Plan.OriginalTitle
		// Режиссёр — из слоистого разрешения (override → распознавание+матч →
		// контекст), а не только из плана: иначе слой контекста (режиссёр без
		// матча) терялся бы, как на странице загрузки.
		view.Director = naming.EffectiveFields(rd.Download.ParsedContext, rd.Plan).Director
		view.Year = rd.Plan.Year
		if view.IsSeries {
			view.SeasonSummary = recognize.SeasonSummary(rd.Plan)
		}
		view.Reasons = rec.ReasonList()
		switch rd.Provider {
		case "", "none":
			view.NoBase = rd.Provider == "none"
		default:
			view.Provider = rd.Provider
			view.ProviderID = rd.ProviderID
			view.MatchURL = rd.MatchURL()
		}
		if rec.Confidence.Valid {
			view.Confidence = strconv.FormatFloat(rec.Confidence.Float64, 'f', 2, 64)
		}
		view.Files = buildFileRows(rd.Plan, rd.Preview)
		view.HasPlan = len(rd.Plan.Files) > 0
		view.HasLinks = len(rd.Preview) > 0
		for _, src := range rd.Sources {
			sv := sourceView{
				Kind:        string(src.Kind),
				CandidateID: src.CandidateID,
				Provider:    src.Provider,
				ProviderID:  src.ProviderID,
				Title:       src.Title,
				Year:        src.Year,
				Active:      src.Active,
			}
			if src.Kind == worker.SourceCandidate {
				sv.MatchURL = sourceMatchURL(src)
			}
			view.Sources = append(view.Sources, sv)
		}
	}
	return view
}

// --- Действия ревью (POST → redirect) ---

func (s *server) handleApply(w http.ResponseWriter, r *http.Request) {
	id, err := pathID(r)
	if err != nil {
		redirectErr(w, r, "некорректный id")
		return
	}
	if err := s.deps.Reviewer.Apply(r.Context(), id); err != nil {
		// Тонкий транспорт: ошибку воркера переводим в ответ, не логируя
		// повторно (доменный слой уже залогировал реальный сбой).
		redirectReview(w, r, id, userErr(r, err, id))
		return
	}
	http.Redirect(w, r, "/", http.StatusSeeOther)
}

func (s *server) handleRefine(w http.ResponseWriter, r *http.Request) {
	s.reviewAction(w, r, func(ctx context.Context, id string) error {
		_ = r.ParseForm()
		return s.deps.Reviewer.Refine(ctx, id, r.PostForm.Get("hint"))
	})
}

func (s *server) handleRerecognize(w http.ResponseWriter, r *http.Request) {
	s.reviewAction(w, r, func(ctx context.Context, id string) error {
		return s.deps.Reviewer.Rerecognize(ctx, id)
	})
}

func (s *server) handleIgnore(w http.ResponseWriter, r *http.Request) {
	s.reviewAction(w, r, func(ctx context.Context, id string) error {
		_ = r.ParseForm()
		return s.deps.Reviewer.IgnoreFile(ctx, id, r.PostForm.Get("src"))
	})
}

func (s *server) handleChooseCandidate(w http.ResponseWriter, r *http.Request) {
	s.reviewBlockAction(w, r, func(ctx context.Context, id string) error {
		_ = r.ParseForm()
		// Входная граница: id кандидата из формы валидируется как ULID.
		candidateID, err := ident.Parse(r.PostForm.Get("candidate_id"))
		if err != nil {
			return errInvalidCandidate
		}
		return s.deps.Reviewer.ChooseCandidate(ctx, id, candidateID)
	})
}

func (s *server) handleSetProvider(w http.ResponseWriter, r *http.Request) {
	// Смена provider/id — операция над выбранным источником, как candidate/nobase/
	// source: свопит блок источника (#source-block), а не всё тело ревью.
	s.reviewBlockAction(w, r, func(ctx context.Context, id string) error {
		_ = r.ParseForm()
		return s.deps.Reviewer.SetProviderID(ctx, id, r.PostForm.Get("provider"), r.PostForm.Get("provider_id"))
	})
}

func (s *server) handleNoBase(w http.ResponseWriter, r *http.Request) {
	s.reviewBlockAction(w, r, func(ctx context.Context, id string) error {
		return s.deps.Reviewer.ClearProvider(ctx, id)
	})
}

// handleAddSource добавляет источник вручную по id или URL записи метабазы и
// выбирает его. Разбор ввода — на входной границе транспорта.
func (s *server) handleAddSource(w http.ResponseWriter, r *http.Request) {
	s.reviewBlockAction(w, r, func(ctx context.Context, id string) error {
		_ = r.ParseForm()
		provider, providerID, err := parseManualSource(r.PostForm.Get("provider"), r.PostForm.Get("provider_id"))
		if err != nil {
			return err
		}
		return s.deps.Reviewer.AddManualSource(ctx, id, provider, providerID)
	})
}

// handleRefreshName переливает распознанное каноническое имя в display_name и в
// ярлык раздачи (ручная кнопка на странице загрузки). Свопит главную область
// (#download-main) с обновлённым заголовком; без htmx — PRG на страницу загрузки
// (в отличие от surfaceAction, уводящего на список: смысл действия — увидеть
// новое имя здесь же). Ошибку на htmx-пути показываем 200 + фрагментом.
func (s *server) handleRefreshName(w http.ResponseWriter, r *http.Request) {
	id, err := pathID(r)
	if err != nil {
		redirectErr(w, r, "некорректный id")
		return
	}
	actionErr := s.deps.Reviewer.RefreshDisplayName(r.Context(), id)
	if !isHTMX(r) {
		if actionErr != nil {
			redirectErr(w, r, userErr(r, actionErr, id))
			return
		}
		http.Redirect(w, r, "/download/"+id, http.StatusSeeOther)
		return
	}
	s.renderDownloadFragment(w, r, id, actionErr)
}

var errInvalidCandidate = errors.New("некорректный id кандидата")

var errManualSource = errors.New("не удалось разобрать id или URL записи (для TVDB — числовой id)")

// parseManualSource разбирает ручной ввод источника: либо id (с выбранным в
// форме провайдером), либо URL записи метабазы (провайдер и id из URL).
func parseManualSource(provider, raw string) (string, string, error) {
	raw = strings.TrimSpace(raw)
	if raw == "" {
		return "", "", errManualSource
	}
	if looksLikeURL(raw) {
		// URL без схемы (`themoviedb.org/tv/1`) url.Parse кладёт в Path, а не в
		// Host → достраиваем схему, иначе валидный копипаст отвергнется.
		u := raw
		if !strings.Contains(u, "://") {
			u = "https://" + u
		}
		p, id, ok := parseProviderURL(u)
		if !ok {
			return "", "", errManualSource
		}
		return p, id, nil
	}
	return strings.ToLower(strings.TrimSpace(provider)), raw, nil
}

func looksLikeURL(s string) bool {
	return strings.Contains(s, "://") || strings.HasPrefix(s, "www.") ||
		strings.Contains(s, ".org/") || strings.Contains(s, ".com/")
}

// parseProviderURL — обратная к worker.ProviderURL: URL записи → (provider, id).
// TMDB/IMDb извлекаются из URL; TVDB — только dereferrer с числовым id (URL
// сайта thetvdb.com/series/{slug} числового id не содержит → не распознаём).
func parseProviderURL(raw string) (provider, id string, ok bool) {
	u, err := url.Parse(raw)
	if err != nil || u.Host == "" {
		return "", "", false
	}
	host := strings.ToLower(u.Host)
	parts := strings.Split(strings.Trim(u.Path, "/"), "/")
	switch {
	case strings.Contains(host, "themoviedb.org"):
		if len(parts) >= 2 && (parts[0] == "movie" || parts[0] == "tv") {
			if d := leadingDigits(parts[1]); d != "" {
				return "tmdb", d, true
			}
		}
	case strings.Contains(host, "imdb.com"):
		if len(parts) >= 2 && parts[0] == "title" && strings.HasPrefix(parts[1], "tt") {
			return "imdb", parts[1], true
		}
	case strings.Contains(host, "thetvdb.com"):
		if len(parts) >= 3 && parts[0] == "dereferrer" {
			if d := leadingDigits(parts[2]); d != "" {
				return "tvdb", d, true
			}
		}
	}
	return "", "", false
}

// leadingDigits возвращает ведущие цифры строки (TMDB-URL вида
// `/movie/60622-fargo` → «60622»).
func leadingDigits(s string) string {
	i := 0
	for i < len(s) && s[i] >= '0' && s[i] <= '9' {
		i++
	}
	return s[:i]
}

// sourceMatchURL — ссылка на запись источника-кандидата: URL кандидата, если
// есть, иначе канонический URL из provider/id (обратный порядок к MatchURL).
func sourceMatchURL(src worker.SourceOption) string {
	if src.URL != "" {
		return src.URL
	}
	return worker.ProviderURL(src.Provider, src.ProviderID, src.Type)
}

func (s *server) handleDefer(w http.ResponseWriter, r *http.Request) {
	id, err := pathID(r)
	if err != nil {
		redirectErr(w, r, "некорректный id")
		return
	}
	if err := s.deps.Reviewer.Defer(r.Context(), id); err != nil {
		redirectReview(w, r, id, userErr(r, err, id))
		return
	}
	http.Redirect(w, r, "/", http.StatusSeeOther)
}

func (s *server) handleUndo(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.Reviewer.Undo(r.Context(), id))
}

// handleDelete — полное удаление загрузки (снять хардлинки + снести раздачу с
// файлами из qBittorrent → deleted). Осознанное необратимое действие: транспорт
// подтверждает его перед POST (hx-confirm + отдельная danger-секция). Ошибку
// qBittorrent surfaceAction покажет как отказ (не тихий успех).
func (s *server) handleDelete(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.Reviewer.Delete(r.Context(), id))
}

// handleDismiss — универсальный стоп-кран: перевод задачи в cancelled только
// сменой статуса (файлы/раздачу не трогает), из danger-секции с подтверждением.
func (s *server) handleDismiss(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.Reviewer.Dismiss(r.Context(), id))
}

// handleRelink повторно привязывает откатанную задачу: перезапускает
// распознавание, задача пройдёт recognizing → review для подтверждения.
func (s *server) handleRelink(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.Reviewer.Relink(r.Context(), id))
}

// reviewAction — общий помощник петлевых действий ревью (уточнить/распознать
// заново/…): выполнить действие и обновить экран ревью. На htmx перечитывает
// состояние и рендерит партиал `review_main` на месте (при ошибке — сообщение в
// баннере и HTTP 200, иначе htmx не свопит DOM); без htmx деградирует до
// PRG-редиректа на `/review/{id}`. Действия асинхронны — своп отдаёт актуальное
// состояние (обычно `recognizing`), которое дальше само допалливается фрагментом
// (см. handleFragReview).
func (s *server) reviewAction(w http.ResponseWriter, r *http.Request, fn func(context.Context, string) error) {
	id, err := pathID(r)
	if err != nil {
		redirectErr(w, r, "некорректный id")
		return
	}
	actionErr := fn(r.Context(), id)

	if !isHTMX(r) {
		msg := ""
		if actionErr != nil {
			msg = userErr(r, actionErr, id)
		}
		redirectReview(w, r, id, msg)
		return
	}

	rd, err := s.deps.Reviewer.ReviewData(r.Context(), id)
	if err != nil {
		s.deps.Logger.Error("review data failed", "download_id", id, "error", err)
		http.Error(w, "внутренняя ошибка", http.StatusInternalServerError)
		return
	}
	msg := ""
	if actionErr != nil {
		msg = userErr(r, actionErr, id)
	}
	s.render(w, "review_main", buildReviewView(id, rd, msg))
}

// handleFragReview отдаёт партиал тела ревью (htmx-поллинг recognizing). Пока
// загрузка в `recognizing`, `review_main` несёт поллер и экран сам обновляется;
// как только состояние стало `review`, фрагмент возвращается без поллера — опрос
// прекращается.
func (s *server) handleFragReview(w http.ResponseWriter, r *http.Request) {
	id, err := pathID(r)
	if err != nil {
		http.Error(w, "не найдено", http.StatusNotFound)
		return
	}
	rd, err := s.deps.Reviewer.ReviewData(r.Context(), id)
	if err != nil {
		s.fragTickErr(w, err, id, "review-main")
		return
	}
	s.render(w, "review_main", buildReviewView(id, rd, ""))
}

// isHTMX — запрос инициирован htmx (ждёт партиал, а не полную страницу).
func isHTMX(r *http.Request) bool {
	return r.Header.Get("HX-Request") == "true"
}

// reviewBlockAction — помощник для действий выбора источника: выполнить
// операцию и вернуть свежий блок источника. На htmx-запрос перечитывает
// состояние и рендерит `review_source_swap` — партиал `review_source_block`
// плюс oob-обновление панели действий `review_action_bar` (кнопка «Применить»
// зависит от HasLinks, а лежит вне блока), ошибку кладёт в BlockError, активный
// источник не меняется; без htmx деградирует до PRG-редиректа, как reviewAction.
func (s *server) reviewBlockAction(w http.ResponseWriter, r *http.Request, fn func(context.Context, string) error) {
	id, err := pathID(r)
	if err != nil {
		redirectErr(w, r, "некорректный id")
		return
	}
	actionErr := fn(r.Context(), id)

	if !isHTMX(r) {
		msg := ""
		if actionErr != nil {
			msg = userErr(r, actionErr, id)
		}
		redirectReview(w, r, id, msg)
		return
	}

	// htmx: перечитываем состояние (уже с новым активным источником при успехе)
	// и рендерим свежий партиал блока.
	rd, err := s.deps.Reviewer.ReviewData(r.Context(), id)
	if err != nil {
		s.deps.Logger.Error("review data failed", "download_id", id, "error", err)
		http.Error(w, "внутренняя ошибка", http.StatusInternalServerError)
		return
	}
	view := buildReviewView(id, rd, "")
	if actionErr != nil {
		view.BlockError = userErr(r, actionErr, id)
	}
	// Панель действий («Применить» завязана на HasLinks) лежит вне #source-block,
	// поэтому обновляем её тем же ответом через hx-swap-oob — иначе кнопка
	// рассинхронилась бы с превью до полной перезагрузки.
	view.ActionBarOOB = true
	s.render(w, "review_source_swap", view)
}

func redirectReview(w http.ResponseWriter, r *http.Request, id string, msg string) {
	u := "/review/" + id
	if msg != "" {
		u += "?err=" + url.QueryEscape(msg)
	}
	http.Redirect(w, r, u, http.StatusSeeOther)
}
