package scenarios_service

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

	"evening_detective_server/internal/modules/file_storage"
	"evening_detective_server/internal/modules/scenario_archive"
	"evening_detective_server/internal/modules/storytelling"
	"evening_detective_server/internal/repos"
	"evening_detective_server/internal/repos/scenarios_repo"
)

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

// fakeStorage — in-memory реализация IFileStorage.
type fakeStorage struct {
	mu     sync.Mutex
	files  map[string]*file_storage.File
	getErr error
}

func newFakeStorage() *fakeStorage {
	return &fakeStorage{files: map[string]*file_storage.File{}}
}

func (s *fakeStorage) Put(_ context.Context, f *file_storage.File) error {
	s.mu.Lock()
	defer s.mu.Unlock()
	cp := *f
	cp.Data = append([]byte(nil), f.Data...)
	s.files[f.Name] = &cp
	return nil
}

func (s *fakeStorage) Get(_ context.Context, name string) (*file_storage.File, error) {
	s.mu.Lock()
	defer s.mu.Unlock()
	if s.getErr != nil {
		return nil, s.getErr
	}
	f, ok := s.files[name]
	if !ok {
		return nil, fmt.Errorf("file not found: %s", name)
	}
	cp := *f
	cp.Data = append([]byte(nil), f.Data...)
	return &cp, nil
}

func (s *fakeStorage) Delete(_ context.Context, name string) error {
	s.mu.Lock()
	defer s.mu.Unlock()
	delete(s.files, name)
	return nil
}

func (s *fakeStorage) MimeType(filename string) string {
	return ""
}

func (s *fakeStorage) names() []string {
	s.mu.Lock()
	defer s.mu.Unlock()
	res := make([]string, 0, len(s.files))
	for name := range s.files {
		res = append(res, name)
	}
	return res
}

// fakeScenariosRepo — минимальная in-memory реализация scenariosRepository.
type fakeScenariosRepo struct {
	mu        sync.Mutex
	byID      map[int]*repos.Scenario
	nextID    int
	addErr    error
	updateErr error
	storyErr  error
}

func newFakeScenariosRepo() *fakeScenariosRepo {
	return &fakeScenariosRepo{
		byID:   map[int]*repos.Scenario{},
		nextID: 0,
	}
}

func (r *fakeScenariosRepo) AddScenario(_ context.Context, name string, authorId int) (int, error) {
	r.mu.Lock()
	defer r.mu.Unlock()
	if r.addErr != nil {
		return 0, r.addErr
	}
	r.nextID++
	description := ""
	r.byID[r.nextID] = &repos.Scenario{
		ID:          r.nextID,
		Name:        name,
		Description: &description,
		Author:      &repos.User{ID: authorId},
		Status:      "draft",
	}
	return r.nextID, nil
}

func (r *fakeScenariosRepo) GetScenariosByAuthorID(_ context.Context, authorId int) ([]*repos.Scenario, error) {
	r.mu.Lock()
	defer r.mu.Unlock()
	var res []*repos.Scenario
	for _, s := range r.byID {
		if s.Author != nil && s.Author.ID == authorId {
			res = append(res, s)
		}
	}
	return res, nil
}

func (r *fakeScenariosRepo) GetScenariosByStatus(_ context.Context, status string) ([]*repos.Scenario, error) {
	r.mu.Lock()
	defer r.mu.Unlock()
	var res []*repos.Scenario
	for _, s := range r.byID {
		if s.Status == status {
			res = append(res, s)
		}
	}
	return res, nil
}

func (r *fakeScenariosRepo) GetScenarioByID(_ context.Context, id int) (*repos.Scenario, error) {
	r.mu.Lock()
	defer r.mu.Unlock()
	s, ok := r.byID[id]
	if !ok {
		return nil, scenarios_repo.ErrScenarioNotFound
	}
	return s, nil
}

func (r *fakeScenariosRepo) UpdateScenarioByID(_ context.Context, id int, name string, description string, image string) error {
	r.mu.Lock()
	defer r.mu.Unlock()
	if r.updateErr != nil {
		return r.updateErr
	}
	s, ok := r.byID[id]
	if !ok {
		return scenarios_repo.ErrScenarioNotFound
	}
	s.Name = name
	s.Description = &description
	s.Image = &image
	return nil
}

func (r *fakeScenariosRepo) UpdateScenarioStatusByID(_ context.Context, id int, status string, allowPublished bool) (bool, error) {
	r.mu.Lock()
	defer r.mu.Unlock()
	s, ok := r.byID[id]
	if !ok {
		return false, scenarios_repo.ErrScenarioNotFound
	}
	if !allowPublished && s.Status == "public" {
		return false, nil
	}
	s.Status = status
	return true, nil
}

func (r *fakeScenariosRepo) DeleteScenarioByID(_ context.Context, id int, allowPublished bool) (bool, error) {
	r.mu.Lock()
	defer r.mu.Unlock()
	s, ok := r.byID[id]
	if !ok {
		return false, scenarios_repo.ErrScenarioNotFound
	}
	if !allowPublished && s.Status == "public" {
		return false, nil
	}
	s.IsDeleted = true
	return true, nil
}

func (r *fakeScenariosRepo) GetStoryByScenarioID(_ context.Context, id int) (string, error) {
	r.mu.Lock()
	defer r.mu.Unlock()
	if r.storyErr != nil {
		return "", r.storyErr
	}
	s, ok := r.byID[id]
	if !ok {
		return "", scenarios_repo.ErrScenarioNotFound
	}
	return s.Scenario, nil
}

func (r *fakeScenariosRepo) UpdateStoryByScenarioID(_ context.Context, id int, story string) error {
	r.mu.Lock()
	defer r.mu.Unlock()
	s, ok := r.byID[id]
	if !ok {
		return scenarios_repo.ErrScenarioNotFound
	}
	s.Scenario = story
	return nil
}

func (r *fakeScenariosRepo) get(id int) *repos.Scenario {
	r.mu.Lock()
	defer r.mu.Unlock()
	return r.byID[id]
}

func newTestService(repo *fakeScenariosRepo, storage *fakeStorage) *ScenarioService {
	if repo == nil {
		repo = newFakeScenariosRepo()
	}
	if storage == nil {
		storage = newFakeStorage()
	}
	return NewScenarioService(repo, nil, testDomain, storage, scenario_archive.NewScenarioArchive())
}

// seedScenario кладёт в repo сценарий с историей и изображениями в storage.
func seedScenario(t *testing.T, repo *fakeScenariosRepo, storage *fakeStorage) *repos.Scenario {
	t.Helper()
	description := "Детективная история"
	image := "cover.png"
	scenario := &repos.Scenario{
		ID:          1,
		Name:        "Ночной клуб",
		Description: &description,
		Image:       &image,
		Author:      &repos.User{ID: 7},
		Status:      "draft",
		Scenario: `{"places":[
			{"code":"club","name":"Клуб","text":"Тёмный зал","image":"club.png","applications":[{"name":"Билет","image":"ticket.jpg"}]},
			{"code":"parking","name":"Парковка","text":"Пусто","image":"club.png"}
		]}`,
	}
	repo.byID[scenario.ID] = scenario
	_ = storage.Put(context.Background(), &file_storage.File{Name: "cover.png", Data: []byte("cover-bytes")})
	_ = storage.Put(context.Background(), &file_storage.File{Name: "club.png", Data: []byte("club-bytes")})
	_ = storage.Put(context.Background(), &file_storage.File{Name: "ticket.jpg", Data: []byte("ticket-bytes")})
	return scenario
}

func TestUploadArchive(t *testing.T) {
	repo := newFakeScenariosRepo()
	srcStorage := newFakeStorage()
	seedScenario(t, repo, srcStorage)

	// Собираем архив как при скачивании (Pack через storage).
	svc := newTestService(repo, srcStorage)
	archive, _, err := svc.DownloadArchive(context.Background(), 1, 7, false)
	if err != nil {
		t.Fatalf("DownloadArchive: %v", err)
	}

	// Импортируем в «чистые» repo и storage.
	importRepo := newFakeScenariosRepo()
	importStorage := newFakeStorage()
	importSvc := newTestService(importRepo, importStorage)

	id, err := importSvc.UploadArchive(context.Background(), archive, 42)
	if err != nil {
		t.Fatalf("UploadArchive: %v", err)
	}
	if id != 1 {
		t.Errorf("id = %d, want 1", id)
	}

	saved := importRepo.get(id)
	if saved == nil {
		t.Fatal("сценарий не создан")
	}
	if saved.Name != "Ночной клуб" {
		t.Errorf("Name = %q", saved.Name)
	}
	if saved.Description == nil || *saved.Description != "Детективная история" {
		t.Errorf("Description = %v", saved.Description)
	}
	if saved.Status != "draft" {
		t.Errorf("Status = %q, want draft", saved.Status)
	}
	if saved.Author == nil || saved.Author.ID != 42 {
		t.Errorf("Author = %+v, want id 42", saved.Author)
	}

	// Ссылки переписаны на новые имена без префикса images/.
	if saved.Image == nil || strings.HasPrefix(*saved.Image, "images/") || *saved.Image == "cover.png" {
		t.Errorf("Image = %v, want новое случайное имя без images/", saved.Image)
	}
	story := &storytelling.Story{}
	if err := json.Unmarshal([]byte(saved.Scenario), story); err != nil {
		t.Fatalf("story не разобрался: %v", err)
	}
	if len(story.Places) != 2 {
		t.Fatalf("places = %d, want 2", len(story.Places))
	}
	club := story.Places[0]
	if club.Image == "" || strings.HasPrefix(club.Image, "images/") || club.Image == "club.png" {
		t.Errorf("place image = %q, want новое имя", club.Image)
	}
	if len(club.Applications) != 1 || strings.HasPrefix(club.Applications[0].Image, "images/") {
		t.Errorf("application image = %+v, want новое имя", club.Applications)
	}
	// Одна и та же картинка в двух точках — одно новое имя.
	if story.Places[1].Image != club.Image {
		t.Errorf("дублирующаяся картинка переписана по-разному: %q и %q", story.Places[1].Image, club.Image)
	}

	// Файлы загружены в storage под новыми именами с правильным содержимым.
	files := importStorage.names()
	if len(files) != 3 {
		t.Fatalf("storage files = %v, want 3", files)
	}
	contents := map[string]bool{}
	for _, name := range files {
		file, err := importStorage.Get(context.Background(), name)
		if err != nil {
			t.Fatalf("Get(%q): %v", name, err)
		}
		contents[string(file.Data)] = true
	}
	for _, want := range []string{"cover-bytes", "club-bytes", "ticket-bytes"} {
		if !contents[want] {
			t.Errorf("в storage нет содержимого %q, есть: %v", want, contents)
		}
	}
}

func TestUploadArchiveWithIntro(t *testing.T) {
	repo := newFakeScenariosRepo()
	srcStorage := newFakeStorage()
	description := "Детективная история"
	image := "cover.png"
	scenario := &repos.Scenario{
		ID:          1,
		Name:        "Ночной клуб",
		Description: &description,
		Image:       &image,
		Author:      &repos.User{ID: 7},
		Status:      "draft",
		Scenario: `{"introduction":{"text":"Введение","audio":"intro.mp3"},"places":[
			{"code":"club","name":"Клуб","text":"Тёмный зал","image":"club.png"}
		]}`,
	}
	repo.byID[scenario.ID] = scenario
	_ = srcStorage.Put(context.Background(), &file_storage.File{Name: "cover.png", Data: []byte("cover-bytes")})
	_ = srcStorage.Put(context.Background(), &file_storage.File{Name: "club.png", Data: []byte("club-bytes")})
	_ = srcStorage.Put(context.Background(), &file_storage.File{Name: "intro.mp3", Data: []byte("intro-audio-bytes")})

	// Экспорт (Pack кладёт аудио введения в архив), затем импорт в «чистые»
	// repo и storage: ссылки должны быть переписаны на новые имена.
	svc := newTestService(repo, srcStorage)
	archive, _, err := svc.DownloadArchive(context.Background(), 1, 7, false)
	if err != nil {
		t.Fatalf("DownloadArchive: %v", err)
	}

	importRepo := newFakeScenariosRepo()
	importStorage := newFakeStorage()
	importSvc := newTestService(importRepo, importStorage)

	id, err := importSvc.UploadArchive(context.Background(), archive, 42)
	if err != nil {
		t.Fatalf("UploadArchive: %v", err)
	}

	saved := importRepo.get(id)
	story := &storytelling.Story{}
	if err := json.Unmarshal([]byte(saved.Scenario), story); err != nil {
		t.Fatalf("story не разобрался: %v", err)
	}
	if story.Introduction == nil {
		t.Fatal("Introduction = nil, want введение после импорта")
	}
	if story.Introduction.Text != "Введение" {
		t.Errorf("Introduction.Text = %q, want %q", story.Introduction.Text, "Введение")
	}
	if story.Introduction.Audio == "" || strings.HasPrefix(story.Introduction.Audio, "images/") || story.Introduction.Audio == "intro.mp3" {
		t.Errorf("Introduction.Audio = %q, want новое случайное имя без images/", story.Introduction.Audio)
	}

	// Аудио введения загружено в storage под новым именем.
	files := importStorage.names()
	found := false
	for _, name := range files {
		file, err := importStorage.Get(context.Background(), name)
		if err != nil {
			t.Fatalf("Get(%q): %v", name, err)
		}
		if string(file.Data) == "intro-audio-bytes" {
			found = true
		}
	}
	if !found {
		t.Errorf("аудио введения не загружено в storage; файлы: %v", files)
	}
}

func TestUploadArchiveMissingImage(t *testing.T) {
	repo := newFakeScenariosRepo()
	storage := newFakeStorage()
	svc := newTestService(repo, storage)

	// Архив ссылается на images/missing.png, файла в архиве нет.
	archive := buildRawArchive(t, map[string][]byte{
		scenario_archive.FileName: mustJSON(t, scenario_archive.ScenarioJSON{
			Version: scenario_archive.Version,
			Name:    "Тест",
			Image:   "images/missing.png",
			Story:   &storytelling.Story{Places: []*storytelling.Place{{Code: "p", Name: "P"}}},
		}),
	})

	if _, err := svc.UploadArchive(context.Background(), archive, 1); err == nil {
		t.Fatal("UploadArchive должен вернуть ошибку при отсутствующем файле")
	}
	if len(storage.names()) != 0 {
		t.Errorf("storage не должен содержать файлов: %v", storage.names())
	}
}

func TestUploadArchiveExternalURLKept(t *testing.T) {
	repo := newFakeScenariosRepo()
	storage := newFakeStorage()
	svc := newTestService(repo, storage)

	archive := buildRawArchive(t, map[string][]byte{
		scenario_archive.FileName: mustJSON(t, scenario_archive.ScenarioJSON{
			Version: scenario_archive.Version,
			Name:    "Тест",
			Image:   "https://example.com/cover.png",
			Story:   &storytelling.Story{Places: []*storytelling.Place{{Code: "p", Name: "P", Image: "http://other.example/x.png"}}},
		}),
	})

	id, err := svc.UploadArchive(context.Background(), archive, 1)
	if err != nil {
		t.Fatalf("UploadArchive: %v", err)
	}
	saved := repo.get(id)
	if saved.Image == nil || *saved.Image != "https://example.com/cover.png" {
		t.Errorf("Image = %v, внешний URL должен остаться без изменений", saved.Image)
	}
	if len(storage.names()) != 0 {
		t.Errorf("storage не должен содержать файлов: %v", storage.names())
	}
}

func TestUploadArchiveCleanupOnRepoError(t *testing.T) {
	repo := newFakeScenariosRepo()
	repo.addErr = errors.New("db is down")
	storage := newFakeStorage()
	svc := newTestService(repo, storage)

	archive := buildRawArchive(t, map[string][]byte{
		scenario_archive.FileName: mustJSON(t, scenario_archive.ScenarioJSON{
			Version: scenario_archive.Version,
			Name:    "Тест",
			Image:   "images/x.png",
			Story:   &storytelling.Story{Places: []*storytelling.Place{{Code: "p", Name: "P"}}},
		}),
		"images/x.png": []byte("x"),
	})

	if _, err := svc.UploadArchive(context.Background(), archive, 1); err == nil {
		t.Fatal("UploadArchive должен вернуть ошибку репозитория")
	}
	// Загруженные файлы удалены (best-effort), orphan-объектов не осталось.
	if len(storage.names()) != 0 {
		t.Errorf("после ошибки БД storage должен быть пуст: %v", storage.names())
	}
}

func TestUploadArchiveDeletesRowOnPartialFailure(t *testing.T) {
	repo := newFakeScenariosRepo()
	repo.updateErr = errors.New("db is down")
	storage := newFakeStorage()
	svc := newTestService(repo, storage)

	archive := buildRawArchive(t, map[string][]byte{
		scenario_archive.FileName: mustJSON(t, scenario_archive.ScenarioJSON{
			Version: scenario_archive.Version,
			Name:    "Тест",
			Image:   "images/x.png",
			Story:   &storytelling.Story{Places: []*storytelling.Place{{Code: "p", Name: "P"}}},
		}),
		"images/x.png": []byte("x"),
	})

	if _, err := svc.UploadArchive(context.Background(), archive, 1); err == nil {
		t.Fatal("UploadArchive должен вернуть ошибку репозитория")
	}
	// Строка помечена удалённой (best-effort, soft delete как в проде), файлы зачищены.
	if got := repo.get(1); got == nil || !got.IsDeleted {
		t.Errorf("строка сценария должна быть удалена после частичного сбоя, осталась: %+v", got)
	}
	if len(storage.names()) != 0 {
		t.Errorf("после частичного сбоя storage должен быть пуст: %v", storage.names())
	}
}

func TestDownloadArchive(t *testing.T) {
	repo := newFakeScenariosRepo()
	storage := newFakeStorage()
	seedScenario(t, repo, storage)
	svc := newTestService(repo, storage)

	data, name, err := svc.DownloadArchive(context.Background(), 1, 7, false)
	if err != nil {
		t.Fatalf("DownloadArchive: %v", err)
	}
	if name != "Ночной клуб" {
		t.Errorf("name = %q", name)
	}

	bundle, err := scenario_archive.NewScenarioArchive().Unpack(data)
	if err != nil {
		t.Fatalf("Unpack: %v", err)
	}
	if bundle.Scenario.Name != "Ночной клуб" {
		t.Errorf("scenario name = %q", bundle.Scenario.Name)
	}
	if bundle.Scenario.Image != "images/cover.png" {
		t.Errorf("scenario image = %q", bundle.Scenario.Image)
	}
	for _, want := range []string{"images/cover.png", "images/club.png", "images/ticket.jpg"} {
		if _, ok := bundle.Files[want]; !ok {
			t.Errorf("в архиве нет %q", want)
		}
	}
}

func TestDownloadArchiveNotOwner(t *testing.T) {
	repo := newFakeScenariosRepo()
	storage := newFakeStorage()
	seedScenario(t, repo, storage)
	svc := newTestService(repo, storage)

	if _, _, err := svc.DownloadArchive(context.Background(), 1, 99, false); !errors.Is(err, ErrScenarioNotOwner) {
		t.Errorf("err = %v, want ErrScenarioNotOwner", err)
	}
	// Админ может скачать чужой сценарий.
	if _, _, err := svc.DownloadArchive(context.Background(), 1, 99, true); err != nil {
		t.Errorf("админ: err = %v", err)
	}
}

func TestDownloadArchiveNotFound(t *testing.T) {
	repo := newFakeScenariosRepo()
	storage := newFakeStorage()
	svc := newTestService(repo, storage)

	if _, _, err := svc.DownloadArchive(context.Background(), 404, 1, true); !errors.Is(err, scenarios_repo.ErrScenarioNotFound) {
		t.Errorf("err = %v, want ErrScenarioNotFound", err)
	}
}

func TestDownloadArchiveMissingImage(t *testing.T) {
	repo := newFakeScenariosRepo()
	storage := newFakeStorage()
	seedScenario(t, repo, storage)
	_ = storage.Delete(context.Background(), "ticket.jpg")
	svc := newTestService(repo, storage)

	if _, _, err := svc.DownloadArchive(context.Background(), 1, 7, false); err == nil {
		t.Fatal("DownloadArchive должен вернуть ошибку при недоступном изображении")
	}
}

func TestUpdateScenarioIntro(t *testing.T) {
	repo := newFakeScenariosRepo()
	storage := newFakeStorage()
	seedScenario(t, repo, storage)
	svc := newTestService(repo, storage)

	// Установка введения автором.
	err := svc.UpdateScenarioIntro(context.Background(), 1, &storytelling.Introduction{
		Text:  "Введение",
		Audio: "intro.mp3",
	}, 7, false)
	if err != nil {
		t.Fatalf("UpdateScenarioIntro: %v", err)
	}

	// В хранилище — голое имя аудио, без домена.
	parsed := &storytelling.Story{}
	if err := json.Unmarshal([]byte(repo.get(1).Scenario), parsed); err != nil {
		t.Fatalf("json.Unmarshal: %v", err)
	}
	if parsed.Introduction == nil {
		t.Fatal("Introduction = nil, want введение в сохранённой истории")
	}
	if parsed.Introduction.Text != "Введение" {
		t.Errorf("Introduction.Text = %q, want %q", parsed.Introduction.Text, "Введение")
	}
	if parsed.Introduction.Audio != "intro.mp3" {
		t.Errorf("Introduction.Audio = %q, want %q (без домена)", parsed.Introduction.Audio, "intro.mp3")
	}

	// Наружу — аудио с доменом.
	full, err := svc.GetFullScenarioByID(context.Background(), 1)
	if err != nil {
		t.Fatalf("GetFullScenarioByID: %v", err)
	}
	if full.Story.Introduction == nil {
		t.Fatal("Story.Introduction = nil, want введение в полном сценарии")
	}
	if full.Story.Introduction.Audio != testDomain+"intro.mp3" {
		t.Errorf("Introduction.Audio = %q, want %q", full.Story.Introduction.Audio, testDomain+"intro.mp3")
	}

	// Редактирование точки не должно затрагивать введение и голое имя аудио.
	err = svc.UpdateScenarioPlace(context.Background(), 1, "club", &storytelling.Place{
		Code: "club",
		Name: "Клуб",
		Text: "Обновлённый текст.",
	}, 7, false)
	if err != nil {
		t.Fatalf("UpdateScenarioPlace: %v", err)
	}
	parsed = &storytelling.Story{}
	if err := json.Unmarshal([]byte(repo.get(1).Scenario), parsed); err != nil {
		t.Fatalf("json.Unmarshal: %v", err)
	}
	if parsed.Introduction == nil || parsed.Introduction.Text != "Введение" || parsed.Introduction.Audio != "intro.mp3" {
		t.Errorf("введение повреждено после UpdateScenarioPlace: %+v", parsed.Introduction)
	}

	// Очистка введения (nil) — поле исчезает из истории.
	err = svc.UpdateScenarioIntro(context.Background(), 1, nil, 7, false)
	if err != nil {
		t.Fatalf("UpdateScenarioIntro(nil): %v", err)
	}
	parsed = &storytelling.Story{}
	if err := json.Unmarshal([]byte(repo.get(1).Scenario), parsed); err != nil {
		t.Fatalf("json.Unmarshal: %v", err)
	}
	if parsed.Introduction != nil {
		t.Errorf("Introduction = %+v, want nil после очистки", parsed.Introduction)
	}

	// Чужой автор не может менять введение.
	err = svc.UpdateScenarioIntro(context.Background(), 1, &storytelling.Introduction{Text: "x"}, 99, false)
	if !errors.Is(err, ErrScenarioNotOwner) {
		t.Errorf("err = %v, want ErrScenarioNotOwner", err)
	}
}

// buildRawArchive собирает zip из произвольных записей (для тестов импорта).
func buildRawArchive(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()
}

func mustJSON(t *testing.T, v scenario_archive.ScenarioJSON) []byte {
	t.Helper()
	b, err := json.Marshal(v)
	if err != nil {
		t.Fatalf("json.Marshal: %v", err)
	}
	return b
}
