package tgbot

import (
	"fmt"
	"path/filepath"
	"strings"

	tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"

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

// esc экранирует внешний/недоверенный текст для HTML parse mode Telegram (его
// включает send(); см. bot.go). Применяем к КАЖДОМУ фрагменту, пришедшему извне
// (display name, названия, пути, причины, provider, текст/код ошибки, источник):
// иначе `<`/`>`/`&` в них сломают разметку или инъектируют её (инвариант «выход
// LLM недоверенный»). ВАЖНО: экранируем ПОСЛЕДНИМ шагом — над уже усечённым
// текстом (после shorten/tailPath/firstLine), чтобы обрез не разрубил сущность
// `&lt;` на битую разметку.
func esc(s string) string { return tgbotapi.EscapeText(tgbotapi.ModeHTML, s) }

// escHref экранирует URL для вставки в значение атрибута `href`. Контекст
// атрибута строже текстового: esc (EscapeText) закрывает `<`/`>`/`&`, но НЕ
// трогает `"`, а недоверенный id уходит в URL сырым (см. worker.ProviderURL) —
// кавычка в id разорвала бы атрибут и Telegram отклонил бы сообщение (parse
// error → уведомление не доставится). Поэтому поверх esc заменяем `"` на
// `&quot;`; порядок безопасен: esc уже перевёл `&` в `&amp;`, повторно `&` в
// `&quot;` не удвоится.
func escHref(url string) string { return strings.ReplaceAll(esc(url), `"`, "&quot;") }

// idCode оборачивает download id в моноширинный <code> — в клиентах Telegram по
// нему работает tap-to-copy (скопировать id для /download/{id} или диагностики).
// Визуальный префикс (`#` / `download_id=`) держим ВНЕ code, чтобы копировался
// чистый id.
func idCode(id string) string { return "<code>" + esc(id) + "</code>" }

// renderCard строит текст и клавиатуру карточки по состоянию задачи.
func (b *Bot) renderCard(rd *worker.ReviewData) (string, *tgbotapi.InlineKeyboardMarkup) {
	id := rd.Download.ID
	state := rd.Download.State

	switch state {
	case store.StateReview, store.StateDeferred:
		return b.reviewCard(rd)
	case store.StateRecognizing:
		return "⏳ Распознаю #" + idCode(id) + "…", b.webOnly(id)
	case store.StateLinking:
		return "⏳ Раскладываю #" + idCode(id) + "…", nil
	case store.StateDone:
		return b.renderDone(rd), b.stateKeyboard(id, state)
	default:
		// state — внутренний enum состояния (не внешний ввод), экранировать не нужно.
		text := fmt.Sprintf("Задача #%s — %s.", idCode(id), state)
		if msg := rd.Download.ErrorMsg.String; msg != "" {
			text += "\n" + esc(msg)
		}
		if state == store.StateFailed || state == store.StateStuck {
			// failed/stuck — даём кнопку повтора.
			return text, b.retryKeyboard(id)
		}
		// Рассинхрон (orphaned/target_missing) — можно подчистить остатки полным
		// удалением; какие именно состояния это допускают, знает домен.
		return text, b.stateKeyboard(id, state)
	}
}

func (b *Bot) reviewCard(rd *worker.ReviewData) (string, *tgbotapi.InlineKeyboardMarkup) {
	id := rd.Download.ID
	var sb strings.Builder

	fmt.Fprintf(&sb, "🟡 Нужно подтверждение #%s\n", idCode(id))
	if src := contextOrSource(rd); src != "" {
		fmt.Fprintf(&sb, "Источник: %s\n", esc(shorten(src, 80)))
	}
	// guessLine/baseLine возвращают уже экранированный текст (внешние title/provider
	// внутри) — повторно не экранируем.
	fmt.Fprintf(&sb, "Похоже на: %s\n", guessLine(rd))
	// В ревью показываем «нет матча» явно — полезно видеть, что база не выбрана.
	if base := baseLine(rd); base != "" {
		fmt.Fprintf(&sb, "База: %s\n", base)
	} else {
		sb.WriteString("База: нет матча\n")
	}
	if reasons := rd.Recognition.ReasonList(); len(reasons) > 0 {
		fmt.Fprintf(&sb, "Причины: %s\n", esc(strings.Join(reasons, " · ")))
	}
	if n := len(rd.Preview); n > 0 {
		fmt.Fprintf(&sb, "План: %d файлов → %s", n, esc(tailPath(rd.Preview[0].Dst)))
	} else if why := previewProblem(rd); why != "" {
		// Плана нет — без этой строки карточка молчит о том, почему нет и «Применить».
		// Причина показа (PreviewError) старше записанной: она про текущий план, а
		// error_msg остался от последнего перехода.
		fmt.Fprintf(&sb, "Не удаётся построить план: %s", esc(shorten(why, 120)))
	}

	return strings.TrimRight(sb.String(), "\n"), b.reviewKeyboard(rd)
}

// previewProblem — почему у задачи в review нет плана: причина, посчитанная на
// показе, либо записанная при последнем переходе. Пусто — объяснять нечего
// (источник ещё не подтверждён).
func previewProblem(rd *worker.ReviewData) string {
	if rd.PreviewError != "" {
		return rd.PreviewError
	}
	return rd.Download.ErrorMsg.String
}

func (b *Bot) reviewKeyboard(rd *worker.ReviewData) *tgbotapi.InlineKeyboardMarkup {
	id := rd.Download.ID
	sid := id

	var row1 []tgbotapi.InlineKeyboardButton
	if len(rd.Preview) > 0 {
		row1 = append(row1, tgbotapi.NewInlineKeyboardButtonData("✅ Применить", "apply:"+sid))
	}
	// Быстрый выбор базы из готового списка кандидатов (веб — точный ввод id/URL).
	if n := len(dedupCandidates(rd.Candidates)); n > 0 {
		row1 = append(row1, tgbotapi.NewInlineKeyboardButtonData(fmt.Sprintf("🗂 База (%d)", n), "sources:"+sid))
	}

	row2 := tgbotapi.NewInlineKeyboardRow(
		tgbotapi.NewInlineKeyboardButtonData("🔁 Уточнить", "refine:"+sid),
		tgbotapi.NewInlineKeyboardButtonData("🕗 Позже", "defer:"+sid),
	)

	var row3 []tgbotapi.InlineKeyboardButton
	if url := b.reviewURL(id); url != "" {
		row3 = append(row3, tgbotapi.NewInlineKeyboardButtonURL("🌐 В вебе", url))
	}
	row3 = append(row3, tgbotapi.NewInlineKeyboardButtonData("❌ Отклонить", "reject:"+sid))

	kb := tgbotapi.NewInlineKeyboardMarkup(row1, row2, row3)
	return &kb
}

// candidatesKeyboard — второй шаг выбора базы: список кандидатов метабазы (по
// кнопке на кандидата) + возврат к карточке. Текст кнопки Telegram не парсится как
// HTML, поэтому название кандидата в подписи не экранируем (инъекция разметки в
// кнопке невозможна); длинную подпись усекаем — косметика. Выбранный (chosen)
// помечаем галочкой. callback выбора — `pick:<id>:<candidateID>` (id кандидата
// валидируется как ULID на границе в handleCallback).
func (b *Bot) candidatesKeyboard(rd *worker.ReviewData) *tgbotapi.InlineKeyboardMarkup {
	sid := rd.Download.ID
	var rows [][]tgbotapi.InlineKeyboardButton
	for _, c := range dedupCandidates(rd.Candidates) {
		rows = append(rows, tgbotapi.NewInlineKeyboardRow(
			tgbotapi.NewInlineKeyboardButtonData(candidateLabel(c), "pick:"+sid+":"+c.ID),
		))
	}
	rows = append(rows, tgbotapi.NewInlineKeyboardRow(
		tgbotapi.NewInlineKeyboardButtonData("← Назад", "srcback:"+sid),
	))
	kb := tgbotapi.NewInlineKeyboardMarkup(rows...)
	return &kb
}

// dedupCandidates отсеивает повторы по provider:provider_id (стабильный порядок
// исходного списка) — как единый список источников в вебе. Потолок — сами
// кандидаты (recognize.maxCandidates=8), отдельно не режем. Если среди дублей
// одного ключа выбранный (Chosen) идёт не первым, он вытесняет ранее оставленный
// невыбранный — чтобы галочка и id кнопки указывали на выбранного.
func dedupCandidates(cands []store.MetadataCandidate) []store.MetadataCandidate {
	idx := map[string]int{}
	out := make([]store.MetadataCandidate, 0, len(cands))
	for _, c := range cands {
		key := c.Provider + ":" + c.ProviderID
		if i, ok := idx[key]; ok {
			if c.Chosen && !out[i].Chosen {
				out[i] = c
			}
			continue
		}
		idx[key] = len(out)
		out = append(out, c)
	}
	return out
}

// candidateLabel — подпись кнопки кандидата: `provider · Название (год)`, у
// выбранного — префикс «✓ ». Обычный текст (не HTML), усекаем для читаемости.
func candidateLabel(c store.MetadataCandidate) string {
	label := c.Provider
	if c.Title.Valid && c.Title.String != "" {
		label += " · " + c.Title.String
	} else if c.ProviderID != "" {
		label += " " + c.ProviderID
	}
	if c.Year.Valid && c.Year.Int64 != 0 {
		label += fmt.Sprintf(" (%d)", c.Year.Int64)
	}
	label = shorten(label, 48)
	if c.Chosen {
		label = "✓ " + label
	}
	return label
}

// displayTitle — имя раздачи для уведомлений: имя раздачи (display_name, то,
// что ушло в qBittorrent и показывается в веб-UI) → распознанное название.
// Пусто, если ни того ни другого нет. Держим заголовки консистентными с UI.
func displayTitle(rd *worker.ReviewData) string {
	if t := strings.TrimSpace(rd.Download.DisplayName); t != "" {
		return t
	}
	return rd.Plan.Title
}

// titleLabel — экранированная метка задачи для уведомлений: «display name» (в
// кавычках) либо моноширинный #id как фолбек. Готова к вставке в HTML-сообщение:
// внешний title экранируется, id оборачивается в <code> (tap-to-copy).
func titleLabel(rd *worker.ReviewData) string {
	if t := displayTitle(rd); t != "" {
		return "«" + esc(t) + "»"
	}
	return "#" + idCode(rd.Download.ID)
}

// renderDone — короткое сообщение о готовности. Дополняем строкой матча (база +
// ссылка), чтобы ошибочную привязку было видно и в финальном пинге; без матча
// строку опускаем — в готовности «нет матча» лишний шум.
func (b *Bot) renderDone(rd *worker.ReviewData) string {
	label := titleLabel(rd)
	var sb strings.Builder
	n := len(rd.Preview)
	if n == 0 {
		fmt.Fprintf(&sb, "✅ Готово: %s разложен.", label)
	} else {
		fmt.Fprintf(&sb, "✅ Готово: %s — разложено файлов: %d.", label, n)
	}
	if base := baseLine(rd); base != "" {
		fmt.Fprintf(&sb, "\nБаза: %s", base)
	}
	return sb.String()
}

// renderDesync — уведомление о рассинхроне (источник/цель удалены вручную).
func (b *Bot) renderDesync(rd *worker.ReviewData, event worker.NotifyEvent) string {
	label := titleLabel(rd)
	switch event {
	case worker.EventTargetMissing:
		return fmt.Sprintf("⚠️ %s: файлы удалены из библиотеки, источник на месте — можно привязать заново.", label)
	case worker.EventOrphaned:
		return fmt.Sprintf("⚠️ %s: источник удалён из qBittorrent, библиотечная копия осталась последней (откат недоступен).", label)
	default:
		return fmt.Sprintf("⚠️ %s: рассинхрон состояния.", label)
	}
}

// renderFailed — уведомление об упавшей/зависшей задаче с кнопкой повтора.
func (b *Bot) renderFailed(rd *worker.ReviewData) (string, *tgbotapi.InlineKeyboardMarkup) {
	id := rd.Download.ID
	var sb strings.Builder
	verb := "не удалась"
	if rd.Download.State == store.StateStuck {
		verb = "зависла"
	}
	// Заголовок (display_name) для читаемости + #id для поиска по логам.
	if title := displayTitle(rd); title != "" {
		fmt.Fprintf(&sb, "❌ «%s» — задача #%s %s", esc(title), idCode(id), verb)
	} else {
		fmt.Fprintf(&sb, "❌ Задача #%s %s", idCode(id), verb)
	}
	if code := rd.Download.ErrorCode.String; code != "" {
		fmt.Fprintf(&sb, " (%s)", esc(code))
	}
	sb.WriteString(".")
	if msg := rd.Download.ErrorMsg.String; msg != "" {
		sb.WriteString("\n")
		sb.WriteString(esc(msg))
	}
	if src := contextOrSource(rd); src != "" {
		fmt.Fprintf(&sb, "\nИсточник: %s", esc(shorten(src, 80)))
	}
	return sb.String(), b.retryKeyboard(id)
}

// retryKeyboard — клавиатура для failed/stuck: повтор + опц. ссылка в веб.
func (b *Bot) retryKeyboard(id string) *tgbotapi.InlineKeyboardMarkup {
	row := []tgbotapi.InlineKeyboardButton{
		tgbotapi.NewInlineKeyboardButtonData("🔄 Повторить", "retry:"+id),
		// Стоп-кран: закрыть зависшую задачу, не трогая файлы/раздачу.
		tgbotapi.NewInlineKeyboardButtonData("✖️ Закрыть", "dismiss:"+id),
	}
	if url := b.reviewURL(id); url != "" {
		row = append(row, tgbotapi.NewInlineKeyboardButtonURL("🌐 В вебе", url))
	}
	kb := tgbotapi.NewInlineKeyboardMarkup(tgbotapi.NewInlineKeyboardRow(row...))
	return &kb
}

// stateKeyboard выбирает клавиатуру по допуску удаления: своего перечня
// состояний транспорт не держит, предикат живёт в домене (см.
// openspec/specs/state-reconciliation, «Полное удаление загрузки
// пользователем»). Допуск в ядре это не заменяет — worker.Delete проверяет его
// сам.
func (b *Bot) stateKeyboard(id string, state store.State) *tgbotapi.InlineKeyboardMarkup {
	if state.CanDelete() {
		return b.deletableKeyboard(id)
	}
	return b.webOnly(id)
}

// deletableKeyboard — клавиатура состояний, откуда доступно полное удаление
// (done/orphaned/target_missing): ссылка в веб (опц.) + «Закрыть» (стоп-кран, лишь
// статус) + «Удалить» (снос раздачи+файлов). Обе команды двухшаговые — кнопка
// ведёт на подтверждение.
func (b *Bot) deletableKeyboard(id string) *tgbotapi.InlineKeyboardMarkup {
	var row []tgbotapi.InlineKeyboardButton
	if url := b.reviewURL(id); url != "" {
		row = append(row, tgbotapi.NewInlineKeyboardButtonURL("🌐 В вебе", url))
	}
	row = append(row,
		tgbotapi.NewInlineKeyboardButtonData("✖️ Закрыть", "dismiss:"+id),
		tgbotapi.NewInlineKeyboardButtonData("🗑 Удалить", "delete:"+id),
	)
	kb := tgbotapi.NewInlineKeyboardMarkup(tgbotapi.NewInlineKeyboardRow(row...))
	return &kb
}

// deleteConfirmKeyboard — шаг подтверждения удаления: необратимое снятие
// раздачи+файлов и хардлинков. Явное «Да» отделено от отмены.
func (b *Bot) deleteConfirmKeyboard(id string) *tgbotapi.InlineKeyboardMarkup {
	kb := tgbotapi.NewInlineKeyboardMarkup(tgbotapi.NewInlineKeyboardRow(
		tgbotapi.NewInlineKeyboardButtonData("❗ Да, удалить", "delete_confirm:"+id),
		tgbotapi.NewInlineKeyboardButtonData("Отмена", "delete_cancel:"+id),
	))
	return &kb
}

// dismissConfirmKeyboard — шаг подтверждения закрытия (стоп-кран): перевод в
// «отменено» без действий над файлами/раздачей. Явное «Да» отделено от отмены.
func (b *Bot) dismissConfirmKeyboard(id string) *tgbotapi.InlineKeyboardMarkup {
	kb := tgbotapi.NewInlineKeyboardMarkup(tgbotapi.NewInlineKeyboardRow(
		tgbotapi.NewInlineKeyboardButtonData("✖️ Да, закрыть", "dismiss_confirm:"+id),
		tgbotapi.NewInlineKeyboardButtonData("Отмена", "dismiss_cancel:"+id),
	))
	return &kb
}

func (b *Bot) webOnly(id string) *tgbotapi.InlineKeyboardMarkup {
	url := b.reviewURL(id)
	if url == "" {
		return nil
	}
	kb := tgbotapi.NewInlineKeyboardMarkup(tgbotapi.NewInlineKeyboardRow(
		tgbotapi.NewInlineKeyboardButtonURL("🌐 Открыть в вебе", url),
	))
	return &kb
}

func (b *Bot) reviewURL(id string) string {
	if b.webBase == "" {
		return ""
	}
	return b.webBase + "/review/" + id
}

// --- мелкие хелперы ---

func guessLine(rd *worker.ReviewData) string {
	emoji, kind := "🎬", "фильм"
	if rd.Plan.Type == "series" {
		emoji, kind = "📺", "сериал"
	}
	title := rd.Plan.Title
	if title == "" {
		title = "не распознано"
	}
	// Возвращаем уже экранированный текст (title — внешний/распознанный):
	// вызывающий вставляет как есть, без повторного esc.
	s := fmt.Sprintf("%s %s «%s»", emoji, kind, esc(title))
	if rd.Plan.Year != 0 {
		s += fmt.Sprintf(" (%d)", rd.Plan.Year)
	}
	return s
}

// baseLine — запись матча метабазы для уведомлений: provider и id (эффективные,
// с учётом ручных правок — как на веб-странице загрузки), при возможности
// построить URL — ссылкой на страницу записи (тот же билдер, что и веб:
// worker.ReviewData.MatchURL). Возвращает уже экранированный HTML (provider/id —
// внешние, URL — в контексте href): вызывающий вставляет как есть. Пусто, если
// матча нет (провайдер пуст/none) — поверхность сама решает, показывать ли
// индикатор «нет матча».
func baseLine(rd *worker.ReviewData) string {
	prov := rd.Provider
	if prov == "" || prov == "none" {
		return ""
	}
	label := esc(prov)
	if rd.ProviderID != "" {
		label += " " + esc(rd.ProviderID)
	}
	if url := rd.MatchURL(); url != "" {
		return fmt.Sprintf(`<a href="%s">%s ↗</a>`, escHref(url), label)
	}
	return label
}

func contextOrSource(rd *worker.ReviewData) string {
	if c := strings.TrimSpace(rd.Download.Context); c != "" {
		return firstLine(c)
	}
	return rd.Download.SourceRef
}

func firstLine(s string) string {
	if i := strings.IndexByte(s, '\n'); i >= 0 {
		return s[:i]
	}
	return s
}

func tailPath(p string) string {
	dir, file := filepath.Split(p)
	parent := filepath.Base(strings.TrimRight(dir, "/"))
	if parent == "." || parent == "/" || parent == "" {
		return file
	}
	return parent + "/" + file
}

func shorten(s string, n int) string {
	r := []rune(s)
	if len(r) <= n {
		return s
	}
	return string(r[:n]) + "…"
}
