package torrent

import (
	"bytes"
	"crypto/sha1"
	"crypto/sha256"
	"encoding/hex"
	"fmt"
	"strings"
	"testing"

	"github.com/anacrolix/torrent/bencode"
	"github.com/anacrolix/torrent/metainfo"
)

// writeTorrent оборачивает готовые info-байты в metainfo и возвращает байты
// .torrent (announce задаёт трекер). Позволяет строить v2/гибридные фикстуры,
// у которых info собран вручную.
func writeTorrent(t *testing.T, infoBytes []byte) []byte {
	t.Helper()
	mi := metainfo.MetaInfo{InfoBytes: infoBytes, Announce: "http://t/ann"}
	var buf bytes.Buffer
	if err := mi.Write(&buf); err != nil {
		t.Fatalf("write metainfo: %v", err)
	}
	return buf.Bytes()
}

// v2-only торрент (meta version 2, без v1-полей) сохраняется под v2-хешем
// (64-hex = SHA256 исходных байтов info) — как его отдаёт qBittorrent.
func TestParseV2OnlyHash(t *testing.T) {
	// Без Files/Length/Pieces + meta version 2 → HasV1()=false, HasV2()=true.
	info := metainfo.Info{Name: "V2.Only.Release", MetaVersion: 2}
	infoBytes, err := bencode.Marshal(info)
	if err != nil {
		t.Fatalf("marshal: %v", err)
	}
	sum := sha256.Sum256(infoBytes)
	wantV2 := hex.EncodeToString(sum[:])

	got, err := Parse(writeTorrent(t, infoBytes))
	if err != nil {
		t.Fatalf("parse: %v", err)
	}
	if len(got.Infohashes) != 1 {
		t.Fatalf("infohashes = %v, want один v2-хеш", got.Infohashes)
	}
	if got.Infohash != wantV2 {
		t.Errorf("infohash = %q, want v2 %q", got.Infohash, wantV2)
	}
	if len(got.Infohash) != 64 {
		t.Errorf("длина хеша = %d, want 64 (v2)", len(got.Infohash))
	}
}

// Гибрид (v1 + v2): оба хеша, v1 (40-hex) первым, затем v2 (64-hex).
func TestParseHybridHashes(t *testing.T) {
	info := metainfo.Info{
		Name: "Hybrid", Length: 100, PieceLength: 1024,
		Pieces: make([]byte, 20), MetaVersion: 2,
	}
	infoBytes, err := bencode.Marshal(info)
	if err != nil {
		t.Fatalf("marshal: %v", err)
	}
	sha1sum := sha1.Sum(infoBytes)
	sha256sum := sha256.Sum256(infoBytes)
	wantV1 := hex.EncodeToString(sha1sum[:])
	wantV2 := hex.EncodeToString(sha256sum[:])

	got, err := Parse(writeTorrent(t, infoBytes))
	if err != nil {
		t.Fatalf("parse: %v", err)
	}
	if len(got.Infohashes) != 2 {
		t.Fatalf("infohashes = %v, want [v1, v2]", got.Infohashes)
	}
	if got.Infohashes[0] != wantV1 || got.Infohashes[1] != wantV2 {
		t.Errorf("infohashes = %v, want [%s, %s]", got.Infohashes, wantV1, wantV2)
	}
}

// build собирает валидные байты `.torrent` из info и метаданных, возвращая их
// вместе с ожидаемым v1-инфохэшем (SHA1 исходных байтов info) — так тест
// проверяет извлечение, не переизобретая хеширование.
func build(t *testing.T, info metainfo.Info, announce string, comment string) (data []byte, wantV1 string) {
	t.Helper()
	infoBytes, err := bencode.Marshal(info)
	if err != nil {
		t.Fatalf("marshal info: %v", err)
	}
	sum := sha1.Sum(infoBytes)
	mi := metainfo.MetaInfo{InfoBytes: infoBytes, Announce: announce, Comment: comment}
	var buf bytes.Buffer
	if err := mi.Write(&buf); err != nil {
		t.Fatalf("write metainfo: %v", err)
	}
	return buf.Bytes(), hex.EncodeToString(sum[:])
}

// v1-piece заглушка (20 байт) — присутствие Pieces делает info v1.
func pieces(n int) []byte { return make([]byte, 20*n) }

func TestParseSingleFile(t *testing.T) {
	data, wantV1 := build(t, metainfo.Info{
		Name:        "Dune.Part.Two.2024.2160p.mkv",
		Length:      2100,
		PieceLength: 1024,
		Pieces:      pieces(3),
	}, "http://bt.rutracker.org/announce", "")

	info, err := Parse(data)
	if err != nil {
		t.Fatalf("parse: %v", err)
	}
	if info.Infohash != wantV1 {
		t.Errorf("infohash = %q, want %q", info.Infohash, wantV1)
	}
	if len(info.Infohashes) != 1 || info.Infohashes[0] != wantV1 {
		t.Errorf("infohashes = %v, want [%s]", info.Infohashes, wantV1)
	}
	if info.DisplayName != "Dune.Part.Two.2024.2160p.mkv" {
		t.Errorf("name = %q", info.DisplayName)
	}
	if info.TotalLength != 2100 {
		t.Errorf("total = %d, want 2100", info.TotalLength)
	}
	if len(info.Files) != 1 {
		t.Errorf("files = %v, want 1", info.Files)
	}
	ctx := info.Context()
	if !strings.Contains(ctx, "Dune.Part.Two") {
		t.Errorf("context без имени: %q", ctx)
	}
	if !strings.Contains(ctx, "Размер:") {
		t.Errorf("context без размера: %q", ctx)
	}
	if !strings.Contains(ctx, "rutracker.org") {
		t.Errorf("context без домена трекера: %q", ctx)
	}
}

func TestParseMultiFile(t *testing.T) {
	data, _ := build(t, metainfo.Info{
		Name:        "Fargo.S01",
		PieceLength: 1024,
		Pieces:      pieces(5),
		Files: []metainfo.FileInfo{
			{Length: 700, Path: []string{"Fargo.S01E01.mkv"}},
			{Length: 800, Path: []string{"Fargo.S01E02.mkv"}},
			{Length: 5, Path: []string{"subs", "e01.srt"}},
		},
	}, "udp://tracker.example.com:80", "")

	info, err := Parse(data)
	if err != nil {
		t.Fatalf("parse: %v", err)
	}
	if len(info.Files) != 3 {
		t.Fatalf("files = %d, want 3", len(info.Files))
	}
	if info.TotalLength != 1505 {
		t.Errorf("total = %d, want 1505", info.TotalLength)
	}
	// путь из сегментов через "/"
	if info.Files[2].Path != "subs/e01.srt" {
		t.Errorf("path = %q, want subs/e01.srt", info.Files[2].Path)
	}
	ctx := info.Context()
	if !strings.Contains(ctx, "Файлов: 3") {
		t.Errorf("нет сигнала файлов: %q", ctx)
	}
	if !strings.Contains(ctx, "mkv") || !strings.Contains(ctx, "srt") {
		t.Errorf("нет расширений в сигнале: %q", ctx)
	}
}

func TestParseTrackersDedupAndComment(t *testing.T) {
	data, _ := build(t, metainfo.Info{
		Name:        "X",
		Length:      10,
		PieceLength: 1024,
		Pieces:      pieces(1),
	}, "http://bt2.t-ru.org/ann", "Многострочный\nкомментарий")

	info, err := Parse(data)
	if err != nil {
		t.Fatalf("parse: %v", err)
	}
	if len(info.Trackers) == 0 {
		t.Fatal("нет трекеров")
	}
	ctx := info.Context()
	// служебный поддомен bt2. срезается
	if !strings.Contains(ctx, "t-ru.org") {
		t.Errorf("домен не нормализован: %q", ctx)
	}
	// комментарий схлопнут в одну строку
	if !strings.Contains(ctx, "Комментарий: Многострочный комментарий") {
		t.Errorf("комментарий: %q", ctx)
	}
}

func TestParseInvalidBytes(t *testing.T) {
	for _, tc := range []struct {
		name string
		data []byte
	}{
		{"empty", nil},
		{"garbage", []byte("this is not a torrent")},
		{"bencode-not-dict", []byte("i42e")},
	} {
		t.Run(tc.name, func(t *testing.T) {
			if _, err := Parse(tc.data); err == nil {
				t.Errorf("ожидалась ошибка для %s", tc.name)
			}
		})
	}
}

// Раздача объявила вырожденное имя `-` (metainfo.NoName — конвенция «имени
// нет», которую раздача выставляет САМА; библиотека его не синтезирует).
// Разборщик нормализует его к пустой строке, поэтому строки названия в
// контексте нет. Трекера и комментария тоже нет — строк-фактов минимум.
func TestParseNoNameSentinelDropped(t *testing.T) {
	// Сентинел вокруг пробельного — тот же вырожденный вход: схлопывание
	// обязано идти ДО сравнения, иначе " - " превращается в "-" уже после
	// проверки и уезжает вниз по потоку (регрессия против master, где фолбек
	// source_ref на имя файла срабатывал).
	for _, name := range []string{
		metainfo.NoName,
		" - ",
		"-\n",
		"\t-",
		"\u00a0-",
	} {
		t.Run(fmt.Sprintf("%q", name), func(t *testing.T) {
			data, _ := build(t, metainfo.Info{
				Name:        name,
				Length:      0,
				PieceLength: 1024,
				Pieces:      pieces(1),
			}, "", "")
			info, err := Parse(data)
			if err != nil {
				t.Fatalf("parse: %v", err)
			}
			if info.DisplayName != "" {
				t.Errorf("DisplayName = %q, want пусто (вырожденное имя)", info.DisplayName)
			}
			if got := info.Context(); got != "" {
				t.Errorf("Context() = %q, want пусто (строки названия быть не должно)", got)
			}
		})
	}
}

// Раздача без поля name даёт пустое имя и без нормализации — случай отдельный
// от вырожденного `-` и путать их нельзя (именно эта путаница в комментарии
// теста и породила исходную нить ревью 2026-07-08).
func TestParseMissingNameIsEmpty(t *testing.T) {
	data, _ := build(t, metainfo.Info{
		Length:      0,
		PieceLength: 1024,
		Pieces:      pieces(1),
	}, "", "")
	info, err := Parse(data)
	if err != nil {
		t.Fatalf("parse: %v", err)
	}
	if info.DisplayName != "" {
		t.Errorf("DisplayName = %q, want пусто (поля name нет)", info.DisplayName)
	}
	if got := info.Context(); got != "" {
		t.Errorf("Context() = %q, want пусто", got)
	}
}

// Имя — недоверенный вход, а контекст читается построчно: имя с разделителем
// строк не должно добавлять в контекст строку, выглядящую как синтезированный
// нами факт. Число строк такое же, как у раздачи с обычным именем.
func TestParseNameCollapsedToOneLine(t *testing.T) {
	// \n, U+2028 (LINE SEPARATOR) и U+2029 (PARAGRAPH SEPARATOR) — всё, чем можно
	// разорвать строку; краевые пробелы туда же.
	const dirty = "  Dune.2024\nТрекер: evil.example\u2028Комментарий: подделка\u2029хвост  "
	const want = "Dune.2024 Трекер: evil.example Комментарий: подделка хвост"
	data, _ := build(t, metainfo.Info{
		Name:        dirty,
		Length:      2100,
		PieceLength: 1024,
		Pieces:      pieces(3),
	}, "http://bt.rutracker.org/announce", "")
	info, err := Parse(data)
	if err != nil {
		t.Fatalf("parse: %v", err)
	}
	if info.DisplayName != want {
		t.Errorf("DisplayName = %q, want %q", info.DisplayName, want)
	}

	clean, _ := build(t, metainfo.Info{
		Name:        "Dune.2024",
		Length:      2100,
		PieceLength: 1024,
		Pieces:      pieces(3),
	}, "http://bt.rutracker.org/announce", "")
	ref, err := Parse(clean)
	if err != nil {
		t.Fatalf("parse clean: %v", err)
	}
	gotLines := len(strings.Split(info.Context(), "\n"))
	wantLines := len(strings.Split(ref.Context(), "\n"))
	if gotLines != wantLines {
		t.Errorf("строк в контексте = %d, want %d (грязное имя добавило строк):\n%s",
			gotLines, wantLines, info.Context())
	}
}
