package scenario_archive

import (
	"archive/zip"
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"strings"
	"testing"
	"time"

	"evening_detective_server/internal/modules/storytelling"
	"evening_detective_server/internal/repos"
)

const testDomain = "http://storage.test/api/files/"

// testArchive — экземпляр модуля для тестов.
var testArchive = NewScenarioArchive()

// buildScenario собирает сценарий с историей и ссылками на изображения.
func buildScenario() *repos.Scenario {
	description := "Детективная история"
	image := "cover.png"
	return &repos.Scenario{
		ID:          1,
		Name:        "Ночной клуб",
		Description: &description,
		Image:       &image,
		Scenario: `{"places":[
			{"code":"club","name":"Клуб","text":"Тёмный зал","image":"club.png","applications":[{"name":"Билет","image":"ticket.jpg"}]},
			{"code":"parking","name":"Парковка","text":"Пусто","image":"club.png"}
		]}`,
	}
}

// memoryFiles — замена хранилища для Pack: имя → содержимое.
type memoryFiles map[string][]byte

func (m memoryFiles) get(_ context.Context, name string) ([]byte, error) {
	data, ok := m[name]
	if !ok {
		return nil, fmt.Errorf("file not found: %s", name)
	}
	return data, nil
}

func newMemoryFiles() memoryFiles {
	return memoryFiles{
		"cover.png":  []byte("cover-bytes"),
		"club.png":   []byte("club-bytes"),
		"ticket.jpg": []byte("ticket-bytes"),
	}
}

func TestPackUnpackRoundTrip(t *testing.T) {
	files := newMemoryFiles()
	data, err := testArchive.Pack(context.Background(), buildScenario(), testDomain, files.get)
	if err != nil {
		t.Fatalf("Pack: %v", err)
	}

	bundle, err := testArchive.Unpack(data)
	if err != nil {
		t.Fatalf("Unpack: %v", err)
	}

	if bundle.Scenario.Version != Version {
		t.Errorf("Version = %d, want %d", bundle.Scenario.Version, Version)
	}
	if bundle.Scenario.Name != "Ночной клуб" {
		t.Errorf("Name = %q, want %q", bundle.Scenario.Name, "Ночной клуб")
	}
	if bundle.Scenario.Description != "Детективная история" {
		t.Errorf("Description = %q", bundle.Scenario.Description)
	}
	if bundle.Scenario.Image != "images/cover.png" {
		t.Errorf("Image = %q, want %q", bundle.Scenario.Image, "images/cover.png")
	}
	if bundle.Scenario.Story == nil || len(bundle.Scenario.Story.Places) != 2 {
		t.Fatalf("Story.Places = %+v, want 2 places", bundle.Scenario.Story)
	}

	club := bundle.Scenario.Story.Places[0]
	if club.Image != "images/club.png" {
		t.Errorf("place image = %q, want %q", club.Image, "images/club.png")
	}
	if len(club.Applications) != 1 || club.Applications[0].Image != "images/ticket.jpg" {
		t.Errorf("application image = %+v, want images/ticket.jpg", club.Applications)
	}

	for _, want := range []string{"images/cover.png", "images/club.png", "images/ticket.jpg"} {
		if _, ok := bundle.Files[want]; !ok {
			t.Errorf("в архиве нет файла %q", want)
		}
	}
	// Дублирующаяся ссылка (club.png в двух точках) кладётся в архив один раз.
	if len(bundle.Files) != 3 {
		t.Errorf("Files = %v, want 3 files", bundle.Files)
	}
}

func TestPackUnpackIntroAudioRoundTrip(t *testing.T) {
	description := "desc"
	image := "cover.png"
	scenario := &repos.Scenario{
		ID:          1,
		Name:        "С введением",
		Description: &description,
		Image:       &image,
		Scenario: `{"introduction":{"text":"Введение","audio":"intro.mp3"},"places":[
			{"code":"club","name":"Клуб","text":"Тёмный зал","image":"club.png"}
		]}`,
	}
	files := memoryFiles{
		"cover.png": []byte("cover-bytes"),
		"club.png":  []byte("club-bytes"),
		"intro.mp3": []byte("intro-audio-bytes"),
	}

	data, err := testArchive.Pack(context.Background(), scenario, testDomain, files.get)
	if err != nil {
		t.Fatalf("Pack: %v", err)
	}

	bundle, err := testArchive.Unpack(data)
	if err != nil {
		t.Fatalf("Unpack: %v", err)
	}

	if bundle.Scenario.Story.Introduction == nil {
		t.Fatal("Story.Introduction = nil, want введение")
	}
	if bundle.Scenario.Story.Introduction.Text != "Введение" {
		t.Errorf("Introduction.Text = %q, want %q", bundle.Scenario.Story.Introduction.Text, "Введение")
	}
	if bundle.Scenario.Story.Introduction.Audio != "images/intro.mp3" {
		t.Errorf("Introduction.Audio = %q, want %q", bundle.Scenario.Story.Introduction.Audio, "images/intro.mp3")
	}
	if _, ok := bundle.Files["images/intro.mp3"]; !ok {
		t.Errorf("в архиве нет аудиофайла %q", "images/intro.mp3")
	}
}

func TestPackExternalURLsKeptAndNotFetched(t *testing.T) {
	description := "desc"
	scenario := &repos.Scenario{
		ID:          1,
		Name:        "Внешний",
		Description: &description,
		Image:       ptr("https://example.com/cover.png"),
		Scenario: `{"places":[
			{"code":"p","name":"P","text":"t","image":"http://other.example/x.png"}
		]}`,
	}

	files := newMemoryFiles()
	var fetched []string
	getFile := func(ctx context.Context, name string) ([]byte, error) {
		fetched = append(fetched, name)
		return files.get(ctx, name)
	}

	data, err := testArchive.Pack(context.Background(), scenario, testDomain, getFile)
	if err != nil {
		t.Fatalf("Pack: %v", err)
	}
	if len(fetched) != 0 {
		t.Errorf("getFile вызван для внешних URL: %v", fetched)
	}

	bundle, err := testArchive.Unpack(data)
	if err != nil {
		t.Fatalf("Unpack: %v", err)
	}
	if bundle.Scenario.Image != "https://example.com/cover.png" {
		t.Errorf("Image = %q, внешний URL должен остаться без изменений", bundle.Scenario.Image)
	}
	if bundle.Scenario.Story.Places[0].Image != "http://other.example/x.png" {
		t.Errorf("place image = %q, внешний URL должен остаться без изменений", bundle.Scenario.Story.Places[0].Image)
	}
}

func TestPackLegacyFullURLImage(t *testing.T) {
	// Legacy-данные: полный URL хранилища срезается до базового имени файла.
	description := "desc"
	scenario := &repos.Scenario{
		ID:          1,
		Name:        "Legacy",
		Description: &description,
		Image:       ptr(testDomain + "cover.png"),
		Scenario:    `{"places":[]}`,
	}
	data, err := testArchive.Pack(context.Background(), scenario, testDomain, newMemoryFiles().get)
	if err != nil {
		t.Fatalf("Pack: %v", err)
	}
	bundle, err := testArchive.Unpack(data)
	if err != nil {
		t.Fatalf("Unpack: %v", err)
	}
	if bundle.Scenario.Image != "images/cover.png" {
		t.Errorf("Image = %q, want %q", bundle.Scenario.Image, "images/cover.png")
	}
}

func TestPackMissingImage(t *testing.T) {
	files := newMemoryFiles()
	delete(files, "ticket.jpg")

	_, err := testArchive.Pack(context.Background(), buildScenario(), testDomain, files.get)
	if err == nil {
		t.Fatal("Pack должен вернуть ошибку при недоступном изображении")
	}
	if !strings.Contains(err.Error(), "ticket.jpg") {
		t.Errorf("ошибка должна содержать имя файла: %v", err)
	}
}

func TestPackDeterministic(t *testing.T) {
	files := newMemoryFiles()
	first, err := testArchive.Pack(context.Background(), buildScenario(), testDomain, files.get)
	if err != nil {
		t.Fatalf("Pack: %v", err)
	}
	time.Sleep(10 * time.Millisecond)
	second, err := testArchive.Pack(context.Background(), buildScenario(), testDomain, files.get)
	if err != nil {
		t.Fatalf("Pack: %v", err)
	}
	if !bytes.Equal(first, second) {
		t.Error("архив недетерминирован: повторный Pack дал другие байты")
	}
}

func TestPackNilScenario(t *testing.T) {
	_, err := testArchive.Pack(context.Background(), nil, testDomain, nil)
	if err == nil {
		t.Fatal("Pack(nil) должен вернуть ошибку")
	}
}

func TestPackImageNameCollision(t *testing.T) {
	description := "desc"
	scenario := &repos.Scenario{
		ID:          1,
		Name:        "Конфликт",
		Description: &description,
		Scenario: `{"places":[
			{"code":"a","name":"A","text":"t","image":"a.png"},
			{"code":"b","name":"B","text":"t","image":"dir/a.png"}
		]}`,
	}
	_, err := testArchive.Pack(context.Background(), scenario, testDomain, newMemoryFiles().get)
	if err == nil {
		t.Fatal("Pack должен вернуть ошибку при конфликте имён изображений")
	}
}

// writeZip собирает архив с заданными записями (для негативных тестов Unpack).
func writeZip(t *testing.T, entries map[string][]byte) []byte {
	t.Helper()
	var buf bytes.Buffer
	zw := zip.NewWriter(&buf)
	for name, content := range entries {
		w, err := zw.Create(name)
		if err != nil {
			t.Fatalf("Create(%q): %v", name, err)
		}
		if _, err := w.Write(content); err != nil {
			t.Fatalf("Write(%q): %v", name, err)
		}
	}
	if err := zw.Close(); err != nil {
		t.Fatalf("Close: %v", err)
	}
	return buf.Bytes()
}

// validScenarioJSON — минимальный корректный scenario.json.
func validScenarioJSON() []byte {
	b, _ := json.Marshal(ScenarioJSON{
		Version: Version,
		Name:    "Тест",
		Story:   &storytelling.Story{Places: []*storytelling.Place{{Code: "p", Name: "P"}}},
	})
	return b
}

func TestUnpackRejectsBadPaths(t *testing.T) {
	for _, name := range []string{
		"../evil",
		"a/../../evil",
		"/abs",
		"dir\\evil",
		"a/../b",
	} {
		t.Run(name, func(t *testing.T) {
			data := writeZip(t, map[string][]byte{
				FileName:       validScenarioJSON(),
				"images/x.png": []byte("x"),
				name:           []byte("evil"),
			})
			if _, err := testArchive.Unpack(data); err == nil {
				t.Fatalf("Unpack должен отклонить путь %q", name)
			}
		})
	}
}

func TestUnpackEmpty(t *testing.T) {
	if _, err := testArchive.Unpack(nil); err == nil {
		t.Fatal("Unpack(nil) должен вернуть ошибку")
	}
	if _, err := testArchive.Unpack([]byte{}); err == nil {
		t.Fatal("testArchive.Unpack(пусто) должен вернуть ошибку")
	}
}

func TestUnpackNotAZip(t *testing.T) {
	if _, err := testArchive.Unpack([]byte("this is not a zip")); err == nil {
		t.Fatal("Unpack должен отклонить не-zip данные")
	}
}

func TestUnpackMissingScenarioJSON(t *testing.T) {
	data := writeZip(t, map[string][]byte{"images/x.png": []byte("x")})
	if _, err := testArchive.Unpack(data); err == nil {
		t.Fatal("Unpack должен требовать scenario.json")
	}
}

func TestUnpackInvalidScenarioJSON(t *testing.T) {
	data := writeZip(t, map[string][]byte{FileName: []byte("{not json")})
	if _, err := testArchive.Unpack(data); err == nil {
		t.Fatal("Unpack должен отклонить битый scenario.json")
	}
}

func TestUnpackBadVersion(t *testing.T) {
	b, _ := json.Marshal(ScenarioJSON{Version: 99, Name: "X", Story: &storytelling.Story{}})
	data := writeZip(t, map[string][]byte{FileName: b})
	if _, err := testArchive.Unpack(data); err == nil {
		t.Fatal("Unpack должен отклонить неизвестную версию")
	}
}

func TestUnpackEmptyName(t *testing.T) {
	b, _ := json.Marshal(ScenarioJSON{Version: Version, Name: "", Story: &storytelling.Story{}})
	data := writeZip(t, map[string][]byte{FileName: b})
	if _, err := testArchive.Unpack(data); err == nil {
		t.Fatal("Unpack должен требовать название")
	}
}

func TestUnpackNilStory(t *testing.T) {
	b, _ := json.Marshal(ScenarioJSON{Version: Version, Name: "X"})
	data := writeZip(t, map[string][]byte{FileName: b})
	if _, err := testArchive.Unpack(data); err == nil {
		t.Fatal("Unpack должен требовать story")
	}
}

func TestUnpackEntrySizeLimit(t *testing.T) {
	prev := maxEntrySize
	maxEntrySize = 4
	defer func() { maxEntrySize = prev }()

	data := writeZip(t, map[string][]byte{
		FileName:         validScenarioJSON(),
		"images/big.png": bytes.Repeat([]byte("x"), 8),
	})
	if _, err := testArchive.Unpack(data); err == nil {
		t.Fatal("Unpack должен отклонить запись больше maxEntrySize")
	}
}

func TestUnpackTotalSizeLimit(t *testing.T) {
	prev := maxUnpackedSize
	maxUnpackedSize = 6
	defer func() { maxUnpackedSize = prev }()

	data := writeZip(t, map[string][]byte{
		FileName:       validScenarioJSON(),
		"images/a.png": []byte("aaa"),
		"images/b.png": []byte("bbb"),
	})
	if _, err := testArchive.Unpack(data); err == nil {
		t.Fatal("Unpack должен отклонить суммарный объём больше maxUnpackedSize")
	}
}

func TestUnpackArchiveSizeLimit(t *testing.T) {
	prev := maxArchiveSize
	maxArchiveSize = 4
	defer func() { maxArchiveSize = prev }()

	data := writeZip(t, map[string][]byte{FileName: validScenarioJSON()})
	if _, err := testArchive.Unpack(data); err == nil {
		t.Fatal("Unpack должен отклонить архив больше maxArchiveSize")
	}
}

func TestUnpackEntriesLimit(t *testing.T) {
	prev := maxEntries
	maxEntries = 2
	defer func() { maxEntries = prev }()

	data := writeZip(t, map[string][]byte{
		FileName:       validScenarioJSON(),
		"images/a.png": []byte("a"),
		"images/b.png": []byte("b"),
	})
	if _, err := testArchive.Unpack(data); err == nil {
		t.Fatal("Unpack должен отклонить архив с числом записей больше maxEntries")
	}
}

func TestUnpackRejectsDuplicateScenarioJSON(t *testing.T) {
	var buf bytes.Buffer
	zw := zip.NewWriter(&buf)
	for i := 0; i < 2; i++ {
		w, err := zw.Create(FileName)
		if err != nil {
			t.Fatalf("Create: %v", err)
		}
		if _, err := w.Write(validScenarioJSON()); err != nil {
			t.Fatalf("Write: %v", err)
		}
	}
	if err := zw.Close(); err != nil {
		t.Fatalf("Close: %v", err)
	}

	if _, err := testArchive.Unpack(buf.Bytes()); err == nil {
		t.Fatal("Unpack должен отклонить дубли scenario.json")
	}
}

func TestUnpackRejectsDuplicateFiles(t *testing.T) {
	var buf bytes.Buffer
	zw := zip.NewWriter(&buf)
	for i := 0; i < 3; i++ {
		name := FileName
		content := validScenarioJSON()
		switch i {
		case 1:
			name = "images/x.png"
			content = []byte("x")
		case 2:
			// Дубль записи images/x.png.
			name = "images/x.png"
			content = []byte("y")
		}
		w, err := zw.Create(name)
		if err != nil {
			t.Fatalf("Create: %v", err)
		}
		if _, err := w.Write(content); err != nil {
			t.Fatalf("Write: %v", err)
		}
	}
	if err := zw.Close(); err != nil {
		t.Fatalf("Close: %v", err)
	}

	if _, err := testArchive.Unpack(buf.Bytes()); err == nil {
		t.Fatal("Unpack должен отклонить дубли файлов")
	}
}

func TestPackTotalSizeLimit(t *testing.T) {
	prev := maxPackSize
	maxPackSize = 8
	defer func() { maxPackSize = prev }()

	description := "desc"
	scenario := &repos.Scenario{
		ID:          1,
		Name:        "Большой",
		Description: &description,
		Scenario: `{"places":[
			{"code":"a","name":"A","text":"t","image":"a.png"},
			{"code":"b","name":"B","text":"t","image":"b.png"}
		]}`,
	}
	files := memoryFiles{
		"a.png": bytes.Repeat([]byte("x"), 6),
		"b.png": bytes.Repeat([]byte("y"), 6),
	}
	if _, err := testArchive.Pack(context.Background(), scenario, testDomain, files.get); err == nil {
		t.Fatal("Pack должен отклонить суммарный объём больше maxPackSize")
	}
}

func TestImagePath(t *testing.T) {
	cases := []struct {
		ref    string
		domain string
		want   string
		ok     bool
	}{
		{"cover.png", "", "images/cover.png", true},
		{"dir/cover.png", "", "images/cover.png", true},
		{testDomain + "cover.png", testDomain, "images/cover.png", true},
		{"", "", "", false},
		{"http://example.com/x.png", "", "", false},
		{"https://example.com/x.png", testDomain, "", false},
	}
	for _, tc := range cases {
		got, ok := ImagePath(tc.ref, tc.domain)
		if got != tc.want || ok != tc.ok {
			t.Errorf("ImagePath(%q, %q) = (%q, %v), want (%q, %v)",
				tc.ref, tc.domain, got, ok, tc.want, tc.ok)
		}
	}
}

func ptr(s string) *string { return &s }
