// Package torrent разбирает `.torrent`-файлы (bencode): извлекает инфохэш(и) и
// метаданные раздачи для приёма загрузки байтами.
//
// Инфохэши нормализуются к нижнему hex — в этом же виде их отдаёт qBittorrent
// (поля hash/infohash_v1/infohash_v2), что позволяет сопоставлять задачи с
// раздачами при поллинге. v1-хеш берётся как SHA1 ИСХОДНЫХ байтов info-словаря
// (библиотека хранит их и не переэнкодит), иначе хеш разошёлся бы с qBittorrent.
package torrent

import (
	"bytes"
	"errors"
	"fmt"
	"net/url"
	"path"
	"regexp"
	"strings"

	"github.com/anacrolix/torrent/metainfo"
	infohashv2 "github.com/anacrolix/torrent/types/infohash-v2"
)

// ErrNotTorrent возвращается, если байты не разбираются как `.torrent`.
var ErrNotTorrent = errors.New("not a torrent file")

// File — один файл раздачи.
type File struct {
	Path   string // относительный путь ("dir/file.mkv")
	Length int64  // размер в байтах
}

// Info — разобранный `.torrent`.
type Info struct {
	Infohash    string   // первичный хеш: v1 приоритетно (нижний hex, 40 для v1, 64 для v2)
	Infohashes  []string // все хеши файла (гибрид несёт v1 и v2); v1 раньше v2
	DisplayName string   // имя раздачи (info.name), нормализованное displayName; пусто, если имени нет
	Files       []File   // файлы раздачи (для одиночного — один элемент)
	TotalLength int64    // суммарный размер
	Trackers    []string // announce + announce-list (плоско, без дублей)
	Comment     string   // comment, если задан
}

// Parse разбирает байты `.torrent`. Извлекает те инфохэши, которые файл реально
// несёт: v1 (для v1/гибрида) и v2 (для v2/гибрида, BEP52) — каждый в том виде,
// в каком его сообщает qBittorrent. Байты info не переэнкодятся.
func Parse(data []byte) (Info, error) {
	mi, err := metainfo.Load(bytes.NewReader(data))
	if err != nil {
		return Info{}, fmt.Errorf("%w: %v", ErrNotTorrent, err)
	}
	meta, err := mi.UnmarshalInfo()
	if err != nil {
		return Info{}, fmt.Errorf("%w: info: %v", ErrNotTorrent, err)
	}

	var hashes []string
	if meta.HasV1() {
		hashes = append(hashes, strings.ToLower(mi.HashInfoBytes().HexString()))
	}
	if meta.HasV2() {
		v2 := infohashv2.HashBytes(mi.InfoBytes)
		hashes = append(hashes, strings.ToLower(v2.HexString()))
	}
	if len(hashes) == 0 {
		return Info{}, fmt.Errorf("%w: no usable infohash", ErrNotTorrent)
	}

	fs, total := files(&meta)
	return Info{
		Infohash:    hashes[0],
		Infohashes:  hashes,
		DisplayName: displayName(meta.BestName()),
		Files:       fs,
		TotalLength: total,
		Trackers:    mi.UpvertedAnnounceList().DistinctValues(),
		Comment:     mi.Comment,
	}, nil
}

// displayName нормализует имя раздачи на границе разбора, чтобы ниже по потоку
// (строка названия контекста, source_ref приёма, подсказка вывода отображаемого
// имени) вырожденных значений не встречалось.
//
// Пробельное схлопываем тем же oneLine, что и комментарий: имя — недоверенный
// вход, а контекст распознавания читается построчно, и имя с переводом строки
// добавило бы в него строку, выглядящую как синтезированный нами факт. Защитой
// от инъекции в промпт это НЕ является — пользовательский текст контекста
// многострочен по замыслу; правило лишь снимает несогласованность между именем
// и комментарием.
//
// Затем отбрасываем `metainfo.NoName` — раздача объявляет это значение сама,
// полем name, по конвенции «имени нет» (библиотека экспортирует константу
// именно затем, чтобы на неё ссылались). Раздача без поля name даёт пустое имя
// и так.
//
// Порядок здесь существенный, и обратный — дефект: имя `" - "` сравнение с
// сентинелом не проходит, а схлопывание превращает его ровно в сентинел, и тот
// уезжает вниз по потоку всеми тремя путями.
func displayName(name string) string {
	name = oneLine(name)
	if name == metainfo.NoName {
		return ""
	}
	return name
}

// files собирает файлы раздачи и суммарный размер из info (одиночный файл или
// дерево). Байты .torrent — недоверенный вход: на кривых/вырожденных раздачах
// библиотека (UpvertedFiles/FileTree) способна паниковать (напр. делением на
// ноль при piece length 0). Дерево файлов — лишь контекст-сигнал, не критично,
// поэтому паники гасим: инфохэш и имя уже извлечены, приём не валим.
func files(meta *metainfo.Info) (out []File, total int64) {
	defer func() { _ = recover() }()
	for _, fi := range meta.UpvertedFiles() {
		seg := fi.Path
		if len(seg) == 0 {
			seg = fi.PathUtf8
		}
		p := path.Join(seg...)
		if p == "" {
			p = meta.BestName()
		}
		out = append(out, File{Path: p, Length: fi.Length})
		total += fi.Length
	}
	return out, total
}

// announceLabelRe — служебные поддомены анонса трекера, которые убираем из
// домена происхождения ради чистого сигнала (bt.t-ru.org → t-ru.org).
var announceLabelRe = regexp.MustCompile(`^(?:bt\d*|www|announce|tracker|open)\.`)

// Context синтезирует человекочитаемый контекст распознавания из полей файла
// (без сети): имя раздачи, суммарный размер, сигнал по дереву файлов, домен
// трекера, комментарий. Строки-факты через "\n"; пустая строка — если полей
// нет. Предназначен для download.Context (recognition, веб-UI), но НЕ для
// вывода отображаемого имени.
func (i Info) Context() string {
	var lines []string

	// Имя уже нормализовано разборщиком (displayName): вырожденное значение и
	// разделители строк сюда не доходят. Проверка на пустоту остаётся — имени
	// у раздачи может не быть вовсе, и тогда строки названия просто нет.
	if i.DisplayName != "" {
		lines = append(lines, i.DisplayName)
	}
	if i.TotalLength > 0 {
		lines = append(lines, "Размер: "+humanSize(i.TotalLength))
	}
	if s := filesSignal(i.Files); s != "" {
		lines = append(lines, s)
	}
	if d := originDomains(i.Trackers); d != "" {
		lines = append(lines, "Трекер: "+d)
	}
	if c := strings.TrimSpace(i.Comment); c != "" {
		lines = append(lines, "Комментарий: "+oneLine(c))
	}

	return strings.Join(lines, "\n")
}

// filesSignal — сигнал по дереву файлов для recognition: число файлов и
// характерные расширения (много видеофайлов → вероятно сериал). Для одиночного
// файла ничего не добавляет (имя раздачи уже есть строкой названия).
func filesSignal(files []File) string {
	if len(files) <= 1 {
		return ""
	}
	seen := map[string]bool{}
	var exts []string
	for _, f := range files {
		ext := strings.ToLower(strings.TrimPrefix(path.Ext(f.Path), "."))
		if ext == "" || seen[ext] {
			continue
		}
		seen[ext] = true
		if len(exts) < 5 {
			exts = append(exts, ext)
		}
	}
	s := fmt.Sprintf("Файлов: %d", len(files))
	if len(exts) > 0 {
		s += " (" + strings.Join(exts, ", ") + ")"
	}
	return s
}

// humanSize форматирует размер в байтах человекочитаемо (≈ 2.1 GiB).
func humanSize(n int64) string {
	const unit = 1024
	if n < unit {
		return fmt.Sprintf("%d B", n)
	}
	f := float64(n)
	i := -1
	for _, u := range []string{"KiB", "MiB", "GiB", "TiB", "PiB"} {
		f /= unit
		i++
		if f < unit {
			return fmt.Sprintf("≈ %.1f %s", f, u)
		}
	}
	return fmt.Sprintf("≈ %.1f PiB", f)
}

// originDomains извлекает уникальные домены происхождения из URL трекеров
// (http(s)/udp с хостом), убирая служебные поддомены анонса. Порядок
// сохраняется; результат — через ", ".
func originDomains(trackers []string) string {
	var out []string
	seen := map[string]bool{}
	for _, ref := range trackers {
		u, err := url.Parse(strings.TrimSpace(ref))
		if err != nil {
			continue
		}
		switch strings.ToLower(u.Scheme) {
		case "http", "https", "udp":
		default:
			continue
		}
		host := strings.ToLower(u.Hostname())
		if host == "" {
			continue
		}
		if s := announceLabelRe.ReplaceAllString(host, ""); strings.Contains(s, ".") {
			host = s
		}
		if seen[host] {
			continue
		}
		seen[host] = true
		out = append(out, host)
	}
	return strings.Join(out, ", ")
}

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