// Пакет scenario_archive — сборка и разбор ZIP-архива сценария.
// Архив: scenario.json (описание и история) + images/<имя> (файлы изображений).
// Все пути внутри архива относительные.
package scenario_archive

import (
	"archive/zip"
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"path"
	"sort"
	"strings"
	"time"

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

// Лимиты на входящие архивы (защита от zip-bomb). Суммарный объём считается
// по фактически распакованным байтам — размеры в заголовках ZIP подделываются.
var (
	maxArchiveSize  = 32 << 20  // 32 МБ — сырой размер архива
	maxUnpackedSize = 128 << 20 // 128 МБ — суммарный объём распакованного
	maxEntries      = 500       // число записей
	maxEntrySize    = 32 << 20  // 32 МБ — размер одной записи
	maxPackSize     = 256 << 20 // 256 МБ — суммарный объём изображений при сборке
)

type scenarioArchive struct{}

// NewScenarioArchive создаёт реализацию IScenarioArchive.
func NewScenarioArchive() IScenarioArchive {
	return &scenarioArchive{}
}

// MaxArchiveSize — максимальный размер входящего архива (для проверок
// в хендлере и на HTTP-слое).
func MaxArchiveSize() int {
	return maxArchiveSize
}

// ImagePath возвращает путь изображения внутри архива (images/<имя>) и true
// для ссылок нашего хранилища: относительное имя или URL с доменом (legacy).
// Внешние URL и пустые ссылки — ("", false): в архив не кладутся.
func ImagePath(ref, domain string) (string, bool) {
	ref = strings.TrimPrefix(ref, domain)
	if ref == "" {
		return "", false
	}
	low := strings.ToLower(ref)
	if strings.HasPrefix(low, "http://") || strings.HasPrefix(low, "https://") {
		return "", false
	}
	return "images/" + path.Base(ref), true
}

// Pack собирает ZIP-архив сценария: ссылки на изображения хранилища
// переписываются в images/<имя>, файлы запрашиваются через getFile; внешние
// URL остаются без изменений. Архив детерминирован (порядок записей и метки).
func (a *scenarioArchive) Pack(
	ctx context.Context,
	scenario *repos.Scenario,
	domain string,
	getFile func(ctx context.Context, name string) ([]byte, error),
) ([]byte, error) {
	if scenario == nil {
		return nil, errors.New("сценарий не задан")
	}

	story := &storytelling.Story{}
	if scenario.Scenario != "" {
		if err := json.Unmarshal([]byte(scenario.Scenario), story); err != nil {
			return nil, fmt.Errorf("не удалось разобрать историю сценария: %w", err)
		}
	}

	// Имя в хранилище → путь в архиве; конфликт базовых имён — ошибка.
	archivePathOf := map[string]string{}
	addRef := func(ref string) error {
		if ref == "" {
			return nil
		}
		archivePath, ok := ImagePath(ref, domain)
		if !ok {
			return nil
		}
		if _, exists := archivePathOf[ref]; exists {
			return nil
		}
		for storageName, existingPath := range archivePathOf {
			if existingPath == archivePath {
				return fmt.Errorf("конфликт имён изображений в архиве: %q и %q", storageName, ref)
			}
		}
		archivePathOf[ref] = archivePath
		return nil
	}
	if scenario.Image != nil {
		if err := addRef(*scenario.Image); err != nil {
			return nil, err
		}
	}
	if story.Introduction != nil {
		if err := addRef(story.Introduction.Audio); err != nil {
			return nil, err
		}
	}
	for _, place := range story.Places {
		if err := addRef(place.Image); err != nil {
			return nil, err
		}
		for _, application := range place.Applications {
			if err := addRef(application.Image); err != nil {
				return nil, err
			}
		}
	}

	// Загружаем файлы из хранилища (имя = ссылка без домена), с суммарным лимитом.
	images := make(map[string][]byte, len(archivePathOf))
	var totalPacked int64
	for ref, archivePath := range archivePathOf {
		storageName := strings.TrimPrefix(ref, domain)
		data, err := getFile(ctx, storageName)
		if err != nil {
			return nil, fmt.Errorf("не удалось получить изображение %q: %w", storageName, err)
		}
		totalPacked += int64(len(data))
		if totalPacked > int64(maxPackSize) {
			return nil, fmt.Errorf("суммарный объём изображений больше %d байт", maxPackSize)
		}
		images[archivePath] = data
	}

	for _, place := range story.Places {
		place.Image = rewriteRef(place.Image, archivePathOf)
		for _, application := range place.Applications {
			application.Image = rewriteRef(application.Image, archivePathOf)
		}
	}
	if story.Introduction != nil {
		story.Introduction.Audio = rewriteRef(story.Introduction.Audio, archivePathOf)
	}

	doc := ScenarioJSON{
		Version:     Version,
		Name:        scenario.Name,
		Description: strPtrValue(scenario.Description),
		Story:       story,
	}
	if scenario.Image != nil {
		doc.Image = rewriteRef(*scenario.Image, archivePathOf)
	}
	docJSON, err := json.Marshal(doc)
	if err != nil {
		return nil, fmt.Errorf("не удалось собрать %s: %w", FileName, err)
	}

	var buf bytes.Buffer
	zw := zip.NewWriter(&buf)
	if err := writeZipEntry(zw, FileName, docJSON); err != nil {
		return nil, err
	}
	names := make([]string, 0, len(images))
	for name := range images {
		names = append(names, name)
	}
	sort.Strings(names)
	for _, name := range names {
		if err := writeZipEntry(zw, name, images[name]); err != nil {
			return nil, err
		}
	}
	if err := zw.Close(); err != nil {
		return nil, fmt.Errorf("не удалось закрыть архив: %w", err)
	}
	return buf.Bytes(), nil
}

func writeZipEntry(zw *zip.Writer, name string, data []byte) error {
	header := &zip.FileHeader{
		Name:   name,
		Method: zip.Deflate,
	}
	// Нулевое время — детерминизм архива между запусками.
	header.Modified = time.Time{}
	w, err := zw.CreateHeader(header)
	if err != nil {
		return fmt.Errorf("не удалось создать запись %q: %w", name, err)
	}
	if _, err := w.Write(data); err != nil {
		return fmt.Errorf("не удалось записать %q: %w", name, err)
	}
	return nil
}

func rewriteRef(ref string, archivePathOf map[string]string) string {
	if ref == "" {
		return ""
	}
	if archivePath, ok := archivePathOf[ref]; ok {
		return archivePath
	}
	return ref
}

func strPtrValue(p *string) string {
	if p == nil {
		return ""
	}
	return *p
}

// Unpack разбирает входящий архив с проверкой лимитов и путей записей.
func (a *scenarioArchive) Unpack(data []byte) (*Bundle, error) {
	if len(data) == 0 {
		return nil, errors.New("архив пуст")
	}
	if len(data) > maxArchiveSize {
		return nil, fmt.Errorf("архив больше максимального размера (%d байт)", maxArchiveSize)
	}

	zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
	if err != nil {
		return nil, fmt.Errorf("не удалось открыть архив: %w", err)
	}
	if len(zr.File) > maxEntries {
		return nil, fmt.Errorf("в архиве больше %d записей", maxEntries)
	}

	bundle := &Bundle{Files: make(map[string][]byte, len(zr.File))}
	var totalUnpacked int64
	var scenarioRaw []byte

	for _, f := range zr.File {
		name := f.Name
		if f.FileInfo().IsDir() {
			continue
		}
		if err := validateEntryPath(name); err != nil {
			return nil, err
		}

		rc, err := f.Open()
		if err != nil {
			return nil, fmt.Errorf("не удалось открыть запись %q: %w", name, err)
		}
		content, err := readEntry(rc)
		rc.Close()
		if err != nil {
			return nil, fmt.Errorf("запись %q: %w", name, err)
		}

		totalUnpacked += int64(len(content))
		if totalUnpacked > int64(maxUnpackedSize) {
			return nil, fmt.Errorf("суммарный размер распакованного архива больше %d байт", maxUnpackedSize)
		}

		// Дубли записей отвергаем — «последний победил» портит данные.
		if name == FileName {
			if scenarioRaw != nil {
				return nil, fmt.Errorf("в архиве несколько записей %s", FileName)
			}
			scenarioRaw = content
			continue
		}
		if _, exists := bundle.Files[name]; exists {
			return nil, fmt.Errorf("в архиве несколько записей %q", name)
		}
		bundle.Files[name] = content
	}

	if scenarioRaw == nil {
		return nil, fmt.Errorf("в архиве нет файла %s", FileName)
	}

	scenario := &ScenarioJSON{}
	if err := json.Unmarshal(scenarioRaw, scenario); err != nil {
		return nil, fmt.Errorf("не удалось разобрать %s: %w", FileName, err)
	}
	if scenario.Version != Version {
		return nil, fmt.Errorf("неподдерживаемая версия архива: %d", scenario.Version)
	}
	if scenario.Name == "" {
		return nil, errors.New("в сценарии не указано название")
	}
	if scenario.Story == nil {
		return nil, errors.New("в сценарии не указана история")
	}

	bundle.Scenario = scenario
	return bundle, nil
}

// readEntry читает запись с лимитом по фактическим байтам (заголовки ZIP
// подделываются тривиально).
func readEntry(r io.Reader) ([]byte, error) {
	lr := io.LimitReader(r, int64(maxEntrySize)+1)
	content, err := io.ReadAll(lr)
	if err != nil {
		return nil, fmt.Errorf("не удалось прочитать: %w", err)
	}
	if int64(len(content)) > int64(maxEntrySize) {
		return nil, fmt.Errorf("размер больше максимального (%d байт)", maxEntrySize)
	}
	return content, nil
}

// validateEntryPath отклоняет небезопасные пути записей.
func validateEntryPath(name string) error {
	if name == "" {
		return errors.New("пустое имя записи в архиве")
	}
	if strings.Contains(name, "\\") {
		return fmt.Errorf("недопустимое имя записи в архиве: %q", name)
	}
	if strings.HasPrefix(name, "/") {
		return fmt.Errorf("недопустимое имя записи в архиве: %q", name)
	}
	for _, part := range strings.Split(name, "/") {
		if part == ".." {
			return fmt.Errorf("недопустимое имя записи в архиве: %q", name)
		}
	}
	if clean := path.Clean(name); clean != name {
		return fmt.Errorf("недопустимое имя записи в архиве: %q", name)
	}
	return nil
}
