// Package magnet разбирает magnet-ссылки: извлекает infohash и метаданные.
//
// infohash нормализуется к нижнему hex — в этом же виде его отдаёт
// qBittorrent (поля hash/infohash_v1/infohash_v2), что позволяет
// сопоставлять задачи с торрентами при поллинге.
package magnet

import (
	"encoding/base32"
	"encoding/hex"
	"errors"
	"fmt"
	"net/url"
	"regexp"
	"strconv"
	"strings"
)

// Info — разобранная magnet-ссылка.
type Info struct {
	Infohash    string   // первичный хеш: v1 приоритетно (нижний hex, 40 для v1, 64 для v2)
	Infohashes  []string // все хеши ссылки (гибридный magnet несёт btih и btmh); v1 раньше v2
	DisplayName string   // dn — человекочитаемое имя, если задано
	Trackers    []string // tr — трекеры
	Sources     []string // xs — exact source (ссылки/источники)
	Keywords    []string // kt — ключевые слова
	ExactLength int64    // xl — размер в байтах; 0, если не задан/некорректен
}

// ErrNotMagnet возвращается, если строка не является magnet-ссылкой.
var ErrNotMagnet = errors.New("not a magnet link")

// URN-префиксы xt-топиков. По RFC 2141 namespace-идентификатор URN
// регистронезависим, поэтому сверяем их EqualFold (magnet:?xt=URN:BTIH:… —
// валиден). Сам хеш нормализует по регистру normalizeBTIH/normalizeBTMH.
const (
	prefixBTIH = "urn:btih:"
	prefixBTMH = "urn:btmh:"
)

// hasURNPrefix сообщает, начинается ли xt с URN-префикса регистронезависимо.
func hasURNPrefix(xt, prefix string) bool {
	return len(xt) >= len(prefix) && strings.EqualFold(xt[:len(prefix)], prefix)
}

// Parse разбирает magnet-ссылку. Поддерживаются btih (v1: 40-hex или
// 32-символьный base32) и btmh (v2: sha256-multihash). При нескольких xt
// предпочитается v1.
func Parse(raw string) (Info, error) {
	u, ok := magnetURL(strings.TrimSpace(raw))
	if !ok {
		return Info{}, ErrNotMagnet
	}
	vals := u.Query()

	var v1, v2 string
	for _, xt := range vals["xt"] {
		switch {
		case hasURNPrefix(xt, prefixBTIH):
			if h, err := normalizeBTIH(xt[len(prefixBTIH):]); err == nil && v1 == "" {
				v1 = h
			}
		case hasURNPrefix(xt, prefixBTMH):
			if h, err := normalizeBTMH(xt[len(prefixBTMH):]); err == nil && v2 == "" {
				v2 = h
			}
		}
	}

	var hashes []string
	for _, h := range []string{v1, v2} {
		if h != "" {
			hashes = append(hashes, h)
		}
	}
	if len(hashes) == 0 {
		return Info{}, fmt.Errorf("magnet without a usable infohash (xt)")
	}

	var xl int64
	if s := strings.TrimSpace(vals.Get("xl")); s != "" {
		if n, err := strconv.ParseInt(s, 10, 64); err == nil && n > 0 {
			xl = n
		}
	}

	return Info{
		Infohash:    hashes[0],
		Infohashes:  hashes,
		DisplayName: vals.Get("dn"),
		Trackers:    vals["tr"],
		Sources:     vals["xs"],
		Keywords:    vals["kt"],
		ExactLength: xl,
	}, nil
}

// magnetURL разбирает строку как magnet-ссылку. Если ссылку скопировали
// целиком в процент-кодировке (magnet%3A%3Fxt%3D…, например вытащили из
// другого URL или из HTML-атрибута), пробует раскодировать её один раз и
// разобрать снова. Разовый unescape применяется ТОЛЬКО когда прямой разбор не
// дал magnet — обычную ссылку (где «+» в query значим) он не затрагивает.
func magnetURL(raw string) (*url.URL, bool) {
	if u, err := url.Parse(raw); err == nil && strings.EqualFold(u.Scheme, "magnet") {
		return u, true
	}
	if dec, err := url.QueryUnescape(raw); err == nil && dec != raw {
		if u, err := url.Parse(strings.TrimSpace(dec)); err == nil && strings.EqualFold(u.Scheme, "magnet") {
			return u, true
		}
	}
	return nil, false
}

// normalizeBTIH нормализует v1-infohash (SHA-1, 20 байт) к нижнему hex.
func normalizeBTIH(h string) (string, error) {
	switch len(h) {
	case 40: // hex
		if _, err := hex.DecodeString(h); err != nil {
			return "", fmt.Errorf("btih hex: %w", err)
		}
		return strings.ToLower(h), nil
	case 32: // base32 (RFC 4648, без паддинга)
		b, err := base32.StdEncoding.DecodeString(strings.ToUpper(h))
		if err != nil {
			return "", fmt.Errorf("btih base32: %w", err)
		}
		if len(b) != 20 {
			return "", fmt.Errorf("btih base32: got %d bytes, want 20", len(b))
		}
		return hex.EncodeToString(b), nil
	default:
		return "", fmt.Errorf("btih: unexpected length %d", len(h))
	}
}

// topicStubRe распознаёт dn-заглушку вида «rutracker-topic-6514485» —
// идентификатор темы трекера, а не название релиза (частый случай для голых
// magnet). Такое имя как строку-название в контекст не берём.
var topicStubRe = regexp.MustCompile(`(?i)^[\w.-]+-topic-\w+$`)

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

// Context синтезирует человекочитаемый контекст распознавания из полей самой
// ссылки (без сети): название релиза (dn, если это не заглушка-идентификатор),
// размер (xl), происхождение по домену трекера/источника (tr/xs) и ключевые
// слова (kt). Возвращает строки-факты, склеенные через "\n"; пустую строку —
// если пригодных полей нет. Результат предназначен для download.Context
// (recognition, веб-UI), но НЕ для вывода отображаемого имени.
func (i Info) Context() string {
	var lines []string

	if name := strings.TrimSpace(i.DisplayName); name != "" && !topicStubRe.MatchString(name) {
		lines = append(lines, name)
	}
	if i.ExactLength > 0 {
		lines = append(lines, "Размер: "+humanSize(i.ExactLength))
	}
	if d := originDomains(append(append([]string{}, i.Trackers...), i.Sources...)); d != "" {
		lines = append(lines, "Трекер: "+d)
	}
	if kw := joinKeywords(i.Keywords); kw != "" {
		lines = append(lines, "Ключевые слова: "+kw)
	}

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

// 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 извлекает уникальные домены происхождения из tr/xs (только
// http(s)/udp-ссылки с хостом), убирая служебные поддомены анонса. Порядок
// сохраняется; результат — через ", ".
func originDomains(refs []string) string {
	var out []string
	seen := map[string]bool{}
	for _, ref := range refs {
		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
		}
		// Срезаем служебный поддомен анонса, но лишь пока остаётся хотя бы две
		// метки — иначе для хоста вида «tracker.org» получили бы голый TLD.
		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, ", ")
}

// joinKeywords нормализует kt: отдельные ключевые слова без пустых и дублей.
// В magnet kt разделяются «+», но url.Query() декодирует «+» в пробел ещё при
// разборе, поэтому режем по пробельным символам (strings.Fields).
func joinKeywords(kt []string) string {
	var out []string
	seen := map[string]bool{}
	for _, v := range kt {
		for kw := range strings.FieldsSeq(v) {
			if seen[kw] {
				continue
			}
			seen[kw] = true
			out = append(out, kw)
		}
	}
	return strings.Join(out, ", ")
}

// normalizeBTMH нормализует v2-infohash. Multihash sha256 имеет вид
// 1220<64-hex>; возвращаем сами 64-hex (так его отдаёт qBittorrent в
// infohash_v2).
func normalizeBTMH(h string) (string, error) {
	h = strings.ToLower(h)
	if _, err := hex.DecodeString(h); err != nil {
		return "", fmt.Errorf("btmh hex: %w", err)
	}
	if len(h) != 68 || !strings.HasPrefix(h, "1220") {
		return "", fmt.Errorf("btmh: unsupported multihash %q", h)
	}
	return h[4:], nil
}
