package handlers

import (
	"archive/zip"
	"bytes"
	"context"
	"encoding/json"
	"io"
	"net/http"
	"net/http/httptest"
	"net/url"
	"path"
	"strings"
	"testing"
	"time"

	"github.com/gofiber/fiber/v2"

	filemanager "wucher/internal/domain/file_manager"
	helicopterfile "wucher/internal/domain/helicopter_file"
	takeoverdomain "wucher/internal/domain/takeover"
	"wucher/internal/service"
	"wucher/internal/shared/pkg/uuidv7"
)

type fileManagerTemplateServiceMock struct {
	filemanager.Service

	requestUploadIntentFn    func(context.Context, filemanager.RequestFileUploadIntentParams) (*filemanager.FileUploadIntentGrant, error)
	createFromIntentFn       func(context.Context, filemanager.CreateFileFromUploadIntentParams) (*filemanager.File, error)
	createAttachmentFn       func(context.Context, filemanager.CreateAttachmentParams) (*filemanager.Attachment, error)
	updateFileFn             func(context.Context, *filemanager.File) error
	deleteFileFn             func(context.Context, []byte, []byte, *time.Time) error
	deleteAttachmentFn       func(context.Context, []byte) error
	getFileByIDAnyFn         func(context.Context, []byte) (*filemanager.File, error)
	getByTemplateTakeoverFn  func(context.Context, []byte, []byte) (*filemanager.File, error)
	updateUploadIntentSizeFn func(context.Context, filemanager.FileUploadIntentSizeUpdateParams) error

	requestUploadIntentCalls int
	createFromIntentCalls    int
	createAttachmentCalls    int
	updateFileCalls          int
	updateUploadIntentCalls  int
}

func (m *fileManagerTemplateServiceMock) RequestFileUploadIntent(ctx context.Context, params filemanager.RequestFileUploadIntentParams) (*filemanager.FileUploadIntentGrant, error) {
	m.requestUploadIntentCalls++
	if m.requestUploadIntentFn != nil {
		return m.requestUploadIntentFn(ctx, params)
	}
	return nil, nil
}

func (m *fileManagerTemplateServiceMock) CreateFileNodeFromUploadIntent(ctx context.Context, params filemanager.CreateFileFromUploadIntentParams) (*filemanager.File, error) {
	m.createFromIntentCalls++
	if m.createFromIntentFn != nil {
		return m.createFromIntentFn(ctx, params)
	}
	return nil, nil
}

func (m *fileManagerTemplateServiceMock) UpdateUploadIntentSize(ctx context.Context, params filemanager.FileUploadIntentSizeUpdateParams) error {
	m.updateUploadIntentCalls++
	if m.updateUploadIntentSizeFn != nil {
		return m.updateUploadIntentSizeFn(ctx, params)
	}
	return nil
}

func (m *fileManagerTemplateServiceMock) CreateAttachment(ctx context.Context, params filemanager.CreateAttachmentParams) (*filemanager.Attachment, error) {
	m.createAttachmentCalls++
	if m.createAttachmentFn != nil {
		return m.createAttachmentFn(ctx, params)
	}
	return nil, nil
}

func (m *fileManagerTemplateServiceMock) UpdateFile(ctx context.Context, row *filemanager.File) error {
	m.updateFileCalls++
	if m.updateFileFn != nil {
		return m.updateFileFn(ctx, row)
	}
	return nil
}

func (m *fileManagerTemplateServiceMock) DeleteFile(ctx context.Context, id []byte, deletedBy []byte, purgeAt *time.Time) error {
	if m.deleteFileFn != nil {
		return m.deleteFileFn(ctx, id, deletedBy, purgeAt)
	}
	return nil
}

func (m *fileManagerTemplateServiceMock) DeleteAttachment(ctx context.Context, id []byte) error {
	if m.deleteAttachmentFn != nil {
		return m.deleteAttachmentFn(ctx, id)
	}
	return nil
}

func (m *fileManagerTemplateServiceMock) GetFileByIDAny(ctx context.Context, id []byte) (*filemanager.File, error) {
	if m.getFileByIDAnyFn != nil {
		return m.getFileByIDAnyFn(ctx, id)
	}
	return nil, nil
}

func (m *fileManagerTemplateServiceMock) GetFileByTemplateAndTakeover(ctx context.Context, templateUUID, takeoverID []byte) (*filemanager.File, error) {
	if m.getByTemplateTakeoverFn != nil {
		return m.getByTemplateTakeoverFn(ctx, templateUUID, takeoverID)
	}
	return nil, nil
}

type helicopterFileTemplateServiceMock struct {
	helicopterfile.Service
	getByIDFn                    func(context.Context, []byte) (*helicopterfile.HelicopterFile, error)
	createFn                     func(context.Context, *helicopterfile.HelicopterFile) error
	listByHelicopterAndSectionFn func(context.Context, []byte, string) ([]helicopterfile.HelicopterFile, error)
	createCalls                  int
	listCalls                    int
}

func (m *helicopterFileTemplateServiceMock) GetByID(ctx context.Context, id []byte) (*helicopterfile.HelicopterFile, error) {
	if m.getByIDFn != nil {
		return m.getByIDFn(ctx, id)
	}
	return nil, nil
}

func (m *helicopterFileTemplateServiceMock) Create(ctx context.Context, row *helicopterfile.HelicopterFile) error {
	m.createCalls++
	if m.createFn != nil {
		return m.createFn(ctx, row)
	}
	return nil
}

func (m *helicopterFileTemplateServiceMock) ListByHelicopterAndSection(ctx context.Context, helicopterID []byte, section string) ([]helicopterfile.HelicopterFile, error) {
	m.listCalls++
	if m.listByHelicopterAndSectionFn != nil {
		return m.listByHelicopterAndSectionFn(ctx, helicopterID, section)
	}
	return nil, nil
}

type takeoverTemplateServiceMock struct {
	getByIDFn func(context.Context, []byte) (*takeoverdomain.TakeoverAc, error)
	attachFn  func(context.Context, []byte, []byte) error
	listFn    func(context.Context, int, int) ([]takeoverdomain.TakeoverAc, int64, error)
}

func (m *takeoverTemplateServiceMock) Create(context.Context, *takeoverdomain.TakeoverAc) error {
	return nil
}

func (m *takeoverTemplateServiceMock) Update(context.Context, *takeoverdomain.TakeoverAc) error {
	return nil
}

func (m *takeoverTemplateServiceMock) Delete(context.Context, []byte, []byte) error {
	return nil
}

func (m *takeoverTemplateServiceMock) GetByID(ctx context.Context, id []byte) (*takeoverdomain.TakeoverAc, error) {
	if m.getByIDFn != nil {
		return m.getByIDFn(ctx, id)
	}
	return nil, nil
}

func (m *takeoverTemplateServiceMock) List(ctx context.Context, limit, offset int) ([]takeoverdomain.TakeoverAc, int64, error) {
	if m.listFn != nil {
		return m.listFn(ctx, limit, offset)
	}
	return nil, 0, nil
}

func (m *takeoverTemplateServiceMock) AttachFile(ctx context.Context, takeoverID, attachmentID []byte) error {
	if m.attachFn != nil {
		return m.attachFn(ctx, takeoverID, attachmentID)
	}
	return nil
}

type templateCopyStorageMock struct {
	copyFn       func(context.Context, string, string) (filemanager.ObjectMetadata, error)
	getFn        func(context.Context, string) (io.ReadCloser, filemanager.ObjectMetadata, error)
	objects      map[string][]byte
	contentTypes map[string]string
}

func (m *templateCopyStorageMock) EnsureBucket(ctx context.Context) error {
	return nil
}

func (m *templateCopyStorageMock) PutObject(ctx context.Context, input filemanager.PutObjectInput) (filemanager.ObjectMetadata, error) {
	if m.objects == nil {
		m.objects = map[string][]byte{}
	}
	if m.contentTypes == nil {
		m.contentTypes = map[string]string{}
	}
	payload, err := io.ReadAll(input.Body)
	if err != nil {
		return filemanager.ObjectMetadata{}, err
	}
	key := strings.TrimSpace(input.ObjectKey)
	m.objects[key] = append([]byte(nil), payload...)
	m.contentTypes[key] = strings.TrimSpace(input.ContentType)
	now := time.Now().UTC()
	return filemanager.ObjectMetadata{
		Bucket:       "bucket",
		ObjectKey:    key,
		ETag:         "etag-" + key,
		VersionID:    "version-" + key,
		SizeBytes:    int64(len(payload)),
		LastModified: &now,
		ContentType:  strings.TrimSpace(input.ContentType),
	}, nil
}

func (m *templateCopyStorageMock) CopyObject(ctx context.Context, sourceObjectKey, destinationObjectKey string) (filemanager.ObjectMetadata, error) {
	if m.copyFn != nil {
		return m.copyFn(ctx, sourceObjectKey, destinationObjectKey)
	}
	return filemanager.ObjectMetadata{}, nil
}

func (m *templateCopyStorageMock) HeadObject(ctx context.Context, objectKey string) (filemanager.ObjectMetadata, error) {
	key := strings.TrimSpace(objectKey)
	if m.objects != nil {
		if payload, ok := m.objects[key]; ok {
			now := time.Now().UTC()
			return filemanager.ObjectMetadata{
				Bucket:       "bucket",
				ObjectKey:    key,
				ETag:         "etag-" + key,
				VersionID:    "version-" + key,
				SizeBytes:    int64(len(payload)),
				LastModified: &now,
				ContentType:  m.contentTypes[key],
			}, nil
		}
	}
	return filemanager.ObjectMetadata{}, nil
}

func (m *templateCopyStorageMock) PresignPutObject(ctx context.Context, input filemanager.PresignPutObjectInput) (filemanager.PresignedPutObject, error) {
	return filemanager.PresignedPutObject{}, nil
}

func (m *templateCopyStorageMock) PresignGetObject(ctx context.Context, objectKey string, ttl time.Duration) (string, error) {
	return "https://example.com/" + objectKey, nil
}

func (m *templateCopyStorageMock) GetObject(ctx context.Context, objectKey string) (io.ReadCloser, filemanager.ObjectMetadata, error) {
	if m.objects != nil {
		if payload, ok := m.objects[strings.TrimSpace(objectKey)]; ok {
			now := time.Now().UTC()
			return io.NopCloser(bytes.NewReader(payload)), filemanager.ObjectMetadata{
				Bucket:       "bucket",
				ObjectKey:    strings.TrimSpace(objectKey),
				ETag:         "etag-" + strings.TrimSpace(objectKey),
				VersionID:    "version-" + strings.TrimSpace(objectKey),
				SizeBytes:    int64(len(payload)),
				LastModified: &now,
				ContentType:  m.contentTypes[strings.TrimSpace(objectKey)],
			}, nil
		}
	}
	if m.getFn != nil {
		return m.getFn(ctx, objectKey)
	}
	return io.NopCloser(strings.NewReader("")), filemanager.ObjectMetadata{}, nil
}

func (m *templateCopyStorageMock) DeleteObject(ctx context.Context, objectKey string) error {
	return nil
}

func TestFileManagerCreateFromTemplate_UsesHelicopterFileSource(t *testing.T) {
	actorID := uuidv7.MustBytes()
	helicopterID := uuidv7.MustBytes()
	templateHeliFileID := uuidv7.MustBytes()
	sourceFileID := uuidv7.MustBytes()
	uploadIntentID := uuidv7.MustBytes()
	createdFileID := uuidv7.MustBytes()

	svc := &fileManagerTemplateServiceMock{
		requestUploadIntentFn: func(_ context.Context, params filemanager.RequestFileUploadIntentParams) (*filemanager.FileUploadIntentGrant, error) {
			if params.Name != "draft.docx" {
				t.Fatalf("unexpected upload intent name: %q", params.Name)
			}
			return &filemanager.FileUploadIntentGrant{
				UploadIntentID: uploadIntentID,
				ObjectKey:      "objects/new-draft.docx",
			}, nil
		},
		createFromIntentFn: func(_ context.Context, params filemanager.CreateFileFromUploadIntentParams) (*filemanager.File, error) {
			if params.IsTemplate {
				t.Fatal("expected created file to be a normal file, not template")
			}
			return &filemanager.File{
				ID:        createdFileID,
				ObjectKey: "objects/new-draft.docx",
				MimeType:  "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
				SizeBytes: 1024,
			}, nil
		},
	}

	heliSvc := &helicopterFileTemplateServiceMock{
		getByIDFn: func(_ context.Context, id []byte) (*helicopterfile.HelicopterFile, error) {
			if !bytes.Equal(id, templateHeliFileID) {
				t.Fatal("unexpected helicopter file id lookup")
			}
			return &helicopterfile.HelicopterFile{
				ID:           templateHeliFileID,
				HelicopterID: helicopterID,
				SourceFile: &filemanager.File{
					ID:         sourceFileID,
					Name:       "template.docx",
					ObjectKey:  "templates/template.docx",
					MimeType:   "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
					SizeBytes:  2048,
					IsTemplate: true,
				},
			}, nil
		},
		listByHelicopterAndSectionFn: func(_ context.Context, gotHelicopterID []byte, section string) ([]helicopterfile.HelicopterFile, error) {
			if !bytes.Equal(gotHelicopterID, helicopterID) {
				t.Fatal("unexpected helicopter id lookup")
			}
			if section != helicopterfile.SectionFlightPreparationAndWB {
				t.Fatalf("unexpected section %q", section)
			}
			return nil, nil
		},
		createFn: func(_ context.Context, row *helicopterfile.HelicopterFile) error {
			if row == nil {
				t.Fatal("expected helicopter file row")
			}
			if !bytes.Equal(row.HelicopterID, helicopterID) {
				t.Fatal("unexpected helicopter id on child row")
			}
			if !bytes.Equal(row.SourceFileID, createdFileID) {
				t.Fatal("unexpected source file id on child row")
			}
			if row.Section != helicopterfile.SectionFlightPreparationAndWB {
				t.Fatalf("unexpected section %q", row.Section)
			}
			return nil
		},
	}

	docxPayload := testOfficeZip(t, []string{
		"[Content_Types].xml",
		"word/document.xml",
	})
	handler := NewFileManagerFileHandler(svc, &templateCopyStorageMock{
		copyFn: func(_ context.Context, sourceObjectKey, destinationObjectKey string) (filemanager.ObjectMetadata, error) {
			if sourceObjectKey != "templates/template.docx" {
				t.Fatalf("unexpected source object key %q", sourceObjectKey)
			}
			if destinationObjectKey != "objects/new-draft.docx" {
				t.Fatalf("unexpected destination object key %q", destinationObjectKey)
			}
			return filemanager.ObjectMetadata{}, nil
		},
		getFn: func(_ context.Context, objectKey string) (io.ReadCloser, filemanager.ObjectMetadata, error) {
			if objectKey != "templates/template.docx" && objectKey != "objects/new-draft.docx" {
				t.Fatalf("unexpected object key %q", objectKey)
			}
			return io.NopCloser(bytes.NewReader(docxPayload)), filemanager.ObjectMetadata{
				SizeBytes:   int64(len(docxPayload)),
				ContentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
			}, nil
		},
	}).
		WithHelicopterFileService(heliSvc).
		WithWOPIService(service.NewWOPIService(service.WOPIConfig{
			TokenSecret:   "test-secret",
			EditActionURL: "https://collabora.example.com/loleaflet/{WOPISrc}",
			PublicBaseURL: "https://api.example.com",
			TokenTTL:      30 * time.Minute,
		}))

	app := fiber.New()
	app.Use(func(c *fiber.Ctx) error {
		c.Locals("user_id", actorID)
		return c.Next()
	})
	app.Post("/api/v1/file-manager/files/create-from-template", handler.CreateFromTemplate)

	body := `{"data":{"type":"file_manager_file_create_from_template","attributes":{"template_uuid":"` + uuidString(templateHeliFileID) + `","helicopter_id":"` + uuidString(helicopterID) + `","name":"draft.docx"}}}`
	req := httptest.NewRequest(http.MethodPost, "/api/v1/file-manager/files/create-from-template", strings.NewReader(body))
	req.Header.Set("Content-Type", "application/json")
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app test failed: %v", err)
	}
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("unexpected status %d", resp.StatusCode)
	}
	var out struct {
		Data struct {
			FileID          string `json:"file_id"`
			CollaboraAction string `json:"collabora_action"`
			AccessToken     string `json:"access_token"`
			AccessTokenTTL  int64  `json:"access_token_ttl"`
		} `json:"data"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
		t.Fatalf("decode response: %v", err)
	}
	if out.Data.FileID != uuidString(createdFileID) {
		t.Fatalf("unexpected file id %q", out.Data.FileID)
	}
	assertWOPIActionContainsAccessToken(t, out.Data.CollaboraAction, out.Data.AccessToken)
	if out.Data.AccessTokenTTL <= 0 {
		t.Fatalf("expected positive access token ttl, got %d", out.Data.AccessTokenTTL)
	}
	if svc.requestUploadIntentCalls != 1 || svc.createFromIntentCalls != 1 || svc.createAttachmentCalls != 0 || svc.updateFileCalls != 1 {
		t.Fatalf("unexpected service call counts: upload=%d create=%d attach=%d update=%d", svc.requestUploadIntentCalls, svc.createFromIntentCalls, svc.createAttachmentCalls, svc.updateFileCalls)
	}
	if heliSvc.createCalls != 1 || heliSvc.listCalls != 1 {
		t.Fatalf("unexpected helicopter file call counts: create=%d list=%d", heliSvc.createCalls, heliSvc.listCalls)
	}
}

func TestFileManagerCreateFromTemplate_UpdatesUploadIntentSizeAfterXLSXSanitize(t *testing.T) {
	actorID := uuidv7.MustBytes()
	helicopterID := uuidv7.MustBytes()
	templateHeliFileID := uuidv7.MustBytes()
	sourceFileID := uuidv7.MustBytes()
	uploadIntentID := uuidv7.MustBytes()
	createdFileID := uuidv7.MustBytes()

	originalPayload := testMalformedXLSXZip(t)
	sanitizedPayload, _, changed, err := sanitizeXLSXPayload(originalPayload)
	if err != nil {
		t.Fatalf("sanitize payload: %v", err)
	}
	if !changed {
		t.Fatal("expected malformed xlsx payload to be sanitized")
	}
	expectedSize := int64(len(sanitizedPayload))

	var updatedUploadIntentSize int64
	svc := &fileManagerTemplateServiceMock{
		requestUploadIntentFn: func(_ context.Context, params filemanager.RequestFileUploadIntentParams) (*filemanager.FileUploadIntentGrant, error) {
			if params.SizeBytes != int64(len(originalPayload)) {
				t.Fatalf("unexpected upload intent size: %d", params.SizeBytes)
			}
			return &filemanager.FileUploadIntentGrant{
				UploadIntentID: uploadIntentID,
				ObjectKey:      "objects/new-draft.xlsx",
			}, nil
		},
		updateUploadIntentSizeFn: func(_ context.Context, params filemanager.FileUploadIntentSizeUpdateParams) error {
			updatedUploadIntentSize = params.SizeBytes
			if !bytes.Equal(params.ID, uploadIntentID) {
				t.Fatalf("unexpected upload intent id: %x", params.ID)
			}
			if params.SizeBytes != expectedSize {
				t.Fatalf("unexpected sanitized size: got %d want %d", params.SizeBytes, expectedSize)
			}
			return nil
		},
		createFromIntentFn: func(_ context.Context, params filemanager.CreateFileFromUploadIntentParams) (*filemanager.File, error) {
			if updatedUploadIntentSize != expectedSize {
				t.Fatalf("expected upload intent size update before finalize, got %d want %d", updatedUploadIntentSize, expectedSize)
			}
			return &filemanager.File{
				ID:        createdFileID,
				ObjectKey: "objects/new-draft.xlsx",
				MimeType:  "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
				SizeBytes: expectedSize,
			}, nil
		},
	}

	heliSvc := &helicopterFileTemplateServiceMock{
		getByIDFn: func(_ context.Context, id []byte) (*helicopterfile.HelicopterFile, error) {
			if !bytes.Equal(id, templateHeliFileID) {
				t.Fatal("unexpected helicopter file id lookup")
			}
			return &helicopterfile.HelicopterFile{
				ID:           templateHeliFileID,
				HelicopterID: helicopterID,
				SourceFile: &filemanager.File{
					ID:         sourceFileID,
					Name:       "template.xlsx",
					ObjectKey:  "templates/template.xlsx",
					MimeType:   "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
					SizeBytes:  int64(len(originalPayload)),
					IsTemplate: true,
				},
			}, nil
		},
		listByHelicopterAndSectionFn: func(_ context.Context, gotHelicopterID []byte, section string) ([]helicopterfile.HelicopterFile, error) {
			if !bytes.Equal(gotHelicopterID, helicopterID) {
				t.Fatal("unexpected helicopter id lookup")
			}
			if section != helicopterfile.SectionFlightPreparationAndWB {
				t.Fatalf("unexpected section %q", section)
			}
			return nil, nil
		},
		createFn: func(_ context.Context, row *helicopterfile.HelicopterFile) error {
			if row == nil {
				t.Fatal("expected helicopter file row")
			}
			if !bytes.Equal(row.HelicopterID, helicopterID) {
				t.Fatal("unexpected helicopter id on child row")
			}
			if !bytes.Equal(row.SourceFileID, createdFileID) {
				t.Fatal("unexpected source file id on child row")
			}
			return nil
		},
	}

	handler := NewFileManagerFileHandler(svc, &templateCopyStorageMock{
		copyFn: func(_ context.Context, sourceObjectKey, destinationObjectKey string) (filemanager.ObjectMetadata, error) {
			if sourceObjectKey != "templates/template.xlsx" {
				t.Fatalf("unexpected source object key %q", sourceObjectKey)
			}
			if destinationObjectKey != "objects/new-draft.xlsx" {
				t.Fatalf("unexpected destination object key %q", destinationObjectKey)
			}
			return filemanager.ObjectMetadata{}, nil
		},
		getFn: func(_ context.Context, objectKey string) (io.ReadCloser, filemanager.ObjectMetadata, error) {
			if objectKey != "templates/template.xlsx" && objectKey != "objects/new-draft.xlsx" {
				t.Fatalf("unexpected object key %q", objectKey)
			}
			return io.NopCloser(bytes.NewReader(originalPayload)), filemanager.ObjectMetadata{
				SizeBytes:   int64(len(originalPayload)),
				ContentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
			}, nil
		},
	}).
		WithHelicopterFileService(heliSvc).
		WithWOPIService(service.NewWOPIService(service.WOPIConfig{
			TokenSecret:   "test-secret",
			EditActionURL: "https://collabora.example.com/loleaflet/{WOPISrc}",
			PublicBaseURL: "https://api.example.com",
			TokenTTL:      30 * time.Minute,
		}))

	app := fiber.New()
	app.Use(func(c *fiber.Ctx) error {
		c.Locals("user_id", actorID)
		return c.Next()
	})
	app.Post("/api/v1/file-manager/files/create-from-template", handler.CreateFromTemplate)

	body := `{"data":{"type":"file_manager_file_create_from_template","attributes":{"template_uuid":"` + uuidString(templateHeliFileID) + `","helicopter_id":"` + uuidString(helicopterID) + `","name":"draft.xlsx"}}}`
	req := httptest.NewRequest(http.MethodPost, "/api/v1/file-manager/files/create-from-template", strings.NewReader(body))
	req.Header.Set("Content-Type", "application/json")
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app test failed: %v", err)
	}
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("unexpected status %d", resp.StatusCode)
	}
	if svc.requestUploadIntentCalls != 1 || svc.updateUploadIntentCalls != 1 || svc.createFromIntentCalls != 1 {
		t.Fatalf("unexpected service call counts: upload=%d update_intent=%d create=%d", svc.requestUploadIntentCalls, svc.updateUploadIntentCalls, svc.createFromIntentCalls)
	}
	if updatedUploadIntentSize != expectedSize {
		t.Fatalf("unexpected updated upload intent size: got %d want %d", updatedUploadIntentSize, expectedSize)
	}
}

func TestFileManagerCreateFromTemplate_AttachesNewTakeoverFile(t *testing.T) {
	actorID := uuidv7.MustBytes()
	helicopterID := uuidv7.MustBytes()
	takeoverID := uuidv7.MustBytes()
	templateHeliFileID := uuidv7.MustBytes()
	sourceFileID := uuidv7.MustBytes()
	createdFileID := uuidv7.MustBytes()
	attachmentID := uuidv7.MustBytes()
	attachCalls := 0

	svc := &fileManagerTemplateServiceMock{
		requestUploadIntentFn: func(_ context.Context, params filemanager.RequestFileUploadIntentParams) (*filemanager.FileUploadIntentGrant, error) {
			if params.Name != "draft.docx" {
				t.Fatalf("unexpected upload intent name %q", params.Name)
			}
			return &filemanager.FileUploadIntentGrant{
				UploadIntentID: uuidv7.MustBytes(),
				ObjectKey:      "objects/new-draft.docx",
			}, nil
		},
		createFromIntentFn: func(_ context.Context, params filemanager.CreateFileFromUploadIntentParams) (*filemanager.File, error) {
			if params.Name != "draft.docx" {
				t.Fatalf("unexpected create-from-intent name %q", params.Name)
			}
			return &filemanager.File{
				ID:             createdFileID,
				Name:           "draft.docx",
				NameNormalized: "draft.docx",
				ObjectKey:      "objects/new-draft.docx",
				MimeType:       "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
				SizeBytes:      1024,
				Status:         filemanager.FileStatusReady,
			}, nil
		},
		createAttachmentFn: func(_ context.Context, params filemanager.CreateAttachmentParams) (*filemanager.Attachment, error) {
			if !bytes.Equal(params.FileID, createdFileID) {
				t.Fatalf("unexpected attachment file id")
			}
			if params.RefType != "takeover" {
				t.Fatalf("unexpected attachment ref type %q", params.RefType)
			}
			if params.RefID != uuidString(takeoverID) {
				t.Fatalf("unexpected attachment ref id %q", params.RefID)
			}
			return &filemanager.Attachment{ID: attachmentID, FileID: createdFileID, RefType: "takeover", RefID: uuidString(takeoverID)}, nil
		},
	}
	heliSvc := &helicopterFileTemplateServiceMock{
		getByIDFn: func(_ context.Context, id []byte) (*helicopterfile.HelicopterFile, error) {
			if !bytes.Equal(id, templateHeliFileID) {
				t.Fatal("unexpected helicopter file id lookup")
			}
			return &helicopterfile.HelicopterFile{
				ID:           templateHeliFileID,
				HelicopterID: helicopterID,
				SourceFile: &filemanager.File{
					ID:        sourceFileID,
					Name:      "template.docx",
					ObjectKey: "templates/template.docx",
					MimeType:  "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
					SizeBytes: 2048,
				},
			}, nil
		},
		listByHelicopterAndSectionFn: func(_ context.Context, gotHelicopterID []byte, section string) ([]helicopterfile.HelicopterFile, error) {
			if !bytes.Equal(gotHelicopterID, helicopterID) {
				t.Fatal("unexpected helicopter id lookup")
			}
			if section != helicopterfile.SectionFlightPreparationAndWB {
				t.Fatalf("unexpected section %q", section)
			}
			return []helicopterfile.HelicopterFile{}, nil
		},
	}
	takeoverSvc := &takeoverTemplateServiceMock{
		getByIDFn: func(_ context.Context, id []byte) (*takeoverdomain.TakeoverAc, error) {
			if !bytes.Equal(id, takeoverID) {
				t.Fatal("unexpected takeover id lookup")
			}
			return &takeoverdomain.TakeoverAc{ID: takeoverID, CreatedBy: actorID}, nil
		},
		attachFn: func(_ context.Context, gotTakeoverID, gotAttachmentID []byte) error {
			attachCalls++
			if !bytes.Equal(gotTakeoverID, takeoverID) {
				t.Fatal("unexpected takeover attach id")
			}
			if !bytes.Equal(gotAttachmentID, attachmentID) {
				t.Fatal("unexpected attachment attach id")
			}
			return nil
		},
	}

	docxPayload := testOfficeZip(t, []string{
		"[Content_Types].xml",
		"word/document.xml",
	})
	handler := NewFileManagerFileHandler(svc, &templateCopyStorageMock{
		copyFn: func(_ context.Context, sourceObjectKey, destinationObjectKey string) (filemanager.ObjectMetadata, error) {
			if sourceObjectKey != "templates/template.docx" {
				t.Fatalf("unexpected source object key %q", sourceObjectKey)
			}
			if destinationObjectKey != "objects/new-draft.docx" {
				t.Fatalf("unexpected destination object key %q", destinationObjectKey)
			}
			return filemanager.ObjectMetadata{}, nil
		},
		getFn: func(_ context.Context, objectKey string) (io.ReadCloser, filemanager.ObjectMetadata, error) {
			if objectKey != "templates/template.docx" && objectKey != "objects/new-draft.docx" {
				t.Fatalf("unexpected object key %q", objectKey)
			}
			return io.NopCloser(bytes.NewReader(docxPayload)), filemanager.ObjectMetadata{
				SizeBytes:   int64(len(docxPayload)),
				ContentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
			}, nil
		},
	}).
		WithHelicopterFileService(heliSvc).
		WithTakeoverService(takeoverSvc).
		WithWOPIService(service.NewWOPIService(service.WOPIConfig{
			TokenSecret:   "test-secret",
			EditActionURL: "https://collabora.example.com/loleaflet/{WOPISrc}",
			PublicBaseURL: "https://api.example.com",
			TokenTTL:      30 * time.Minute,
		}))

	app := fiber.New()
	app.Use(func(c *fiber.Ctx) error {
		c.Locals("user_id", actorID)
		return c.Next()
	})
	app.Post("/api/v1/file-manager/files/create-from-template", handler.CreateFromTemplate)

	body := `{"data":{"type":"file_manager_file_create_from_template","attributes":{"template_uuid":"` + uuidString(templateHeliFileID) + `","helicopter_id":"` + uuidString(helicopterID) + `","takeover_id":"` + uuidString(takeoverID) + `","name":"draft.docx"}}}`
	req := httptest.NewRequest(http.MethodPost, "/api/v1/file-manager/files/create-from-template", strings.NewReader(body))
	req.Header.Set("Content-Type", "application/json")
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app test failed: %v", err)
	}
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("unexpected status %d", resp.StatusCode)
	}
	var out struct {
		Data struct {
			FileID          string `json:"file_id"`
			CollaboraAction string `json:"collabora_action"`
			AccessToken     string `json:"access_token"`
			AccessTokenTTL  int64  `json:"access_token_ttl"`
		} `json:"data"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
		t.Fatalf("decode response: %v", err)
	}
	if out.Data.FileID != uuidString(createdFileID) {
		t.Fatalf("unexpected file id %q", out.Data.FileID)
	}
	assertWOPIActionContainsAccessToken(t, out.Data.CollaboraAction, out.Data.AccessToken)
	if out.Data.AccessTokenTTL <= 0 {
		t.Fatalf("expected positive access token ttl, got %d", out.Data.AccessTokenTTL)
	}
	if svc.requestUploadIntentCalls != 1 || svc.createFromIntentCalls != 1 || svc.createAttachmentCalls != 1 || svc.updateFileCalls != 1 {
		t.Fatalf("unexpected service call counts: upload=%d create=%d attach=%d update=%d", svc.requestUploadIntentCalls, svc.createFromIntentCalls, svc.createAttachmentCalls, svc.updateFileCalls)
	}
	if heliSvc.createCalls != 1 || heliSvc.listCalls != 1 {
		t.Fatalf("unexpected helicopter file call counts: create=%d list=%d", heliSvc.createCalls, heliSvc.listCalls)
	}
	if attachCalls != 1 {
		t.Fatalf("expected one takeover attach call, got %d", attachCalls)
	}
}

func TestFileManagerCreateFromTemplate_ReusesExistingTakeoverFile(t *testing.T) {
	actorID := uuidv7.MustBytes()
	helicopterID := uuidv7.MustBytes()
	takeoverID := uuidv7.MustBytes()
	templateHeliFileID := uuidv7.MustBytes()
	sourceFileID := uuidv7.MustBytes()
	existingFileID := uuidv7.MustBytes()
	attachmentID := uuidv7.MustBytes()
	attachCalls := 0

	svc := &fileManagerTemplateServiceMock{
		createAttachmentFn: func(_ context.Context, params filemanager.CreateAttachmentParams) (*filemanager.Attachment, error) {
			if !bytes.Equal(params.FileID, existingFileID) {
				t.Fatal("unexpected attachment file id")
			}
			if params.RefType != "takeover" {
				t.Fatalf("unexpected attachment ref type %q", params.RefType)
			}
			if params.RefID != uuidString(takeoverID) {
				t.Fatalf("unexpected attachment ref id %q", params.RefID)
			}
			return &filemanager.Attachment{ID: attachmentID, FileID: existingFileID, RefType: "takeover", RefID: uuidString(takeoverID)}, nil
		},
	}
	svc.getByTemplateTakeoverFn = func(_ context.Context, gotTemplateID, gotTakeoverID []byte) (*filemanager.File, error) {
		if !bytes.Equal(gotTemplateID, templateHeliFileID) {
			t.Fatal("unexpected template lookup id")
		}
		if !bytes.Equal(gotTakeoverID, takeoverID) {
			t.Fatal("unexpected takeover lookup id")
		}
		return &filemanager.File{
			ID:             existingFileID,
			Name:           "draft.docx",
			NameNormalized: "draft.docx",
			NameSlot:       "draft",
			ObjectKey:      "objects/existing-draft.docx",
			MimeType:       "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
			SizeBytes:      1024,
			Status:         filemanager.FileStatusReady,
			TemplateUUID:   templateHeliFileID,
			TakeoverID:     takeoverID,
		}, nil
	}
	heliSvc := &helicopterFileTemplateServiceMock{
		getByIDFn: func(_ context.Context, id []byte) (*helicopterfile.HelicopterFile, error) {
			if !bytes.Equal(id, templateHeliFileID) {
				t.Fatal("unexpected helicopter file id lookup")
			}
			return &helicopterfile.HelicopterFile{
				ID:           templateHeliFileID,
				HelicopterID: helicopterID,
				SourceFile: &filemanager.File{
					ID:        sourceFileID,
					Name:      "template.docx",
					ObjectKey: "templates/template.docx",
					MimeType:  "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
					SizeBytes: 2048,
				},
			}, nil
		},
		listByHelicopterAndSectionFn: func(_ context.Context, gotHelicopterID []byte, section string) ([]helicopterfile.HelicopterFile, error) {
			if !bytes.Equal(gotHelicopterID, helicopterID) {
				t.Fatal("unexpected helicopter id lookup")
			}
			if section != helicopterfile.SectionFlightPreparationAndWB {
				t.Fatalf("unexpected section %q", section)
			}
			return []helicopterfile.HelicopterFile{
				{
					ID:           uuidv7.MustBytes(),
					Section:      helicopterfile.SectionFlightPreparationAndWB,
					HelicopterID: helicopterID,
					SourceFileID: existingFileID,
				},
			}, nil
		},
	}
	takeoverSvc := &takeoverTemplateServiceMock{
		getByIDFn: func(_ context.Context, id []byte) (*takeoverdomain.TakeoverAc, error) {
			if !bytes.Equal(id, takeoverID) {
				t.Fatal("unexpected takeover id lookup")
			}
			return &takeoverdomain.TakeoverAc{
				ID:        takeoverID,
				CreatedBy: actorID,
			}, nil
		},
		attachFn: func(_ context.Context, gotTakeoverID, gotAttachmentID []byte) error {
			attachCalls++
			if !bytes.Equal(gotTakeoverID, takeoverID) {
				t.Fatal("unexpected takeover attach id")
			}
			if !bytes.Equal(gotAttachmentID, attachmentID) {
				t.Fatal("unexpected attachment attach id")
			}
			return nil
		},
	}

	docxPayload := testOfficeZip(t, []string{
		"[Content_Types].xml",
		"word/document.xml",
	})
	handler := NewFileManagerFileHandler(svc, &templateCopyStorageMock{
		getFn: func(_ context.Context, objectKey string) (io.ReadCloser, filemanager.ObjectMetadata, error) {
			if objectKey != "templates/template.docx" && objectKey != "objects/existing-draft.docx" {
				t.Fatalf("unexpected object key %q", objectKey)
			}
			return io.NopCloser(bytes.NewReader(docxPayload)), filemanager.ObjectMetadata{
				SizeBytes:   int64(len(docxPayload)),
				ContentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
			}, nil
		},
	}).
		WithHelicopterFileService(heliSvc).
		WithTakeoverService(takeoverSvc).
		WithWOPIService(service.NewWOPIService(service.WOPIConfig{
			TokenSecret:   "test-secret",
			EditActionURL: "https://collabora.example.com/loleaflet/{WOPISrc}",
			PublicBaseURL: "https://api.example.com",
			TokenTTL:      30 * time.Minute,
		}))

	app := fiber.New()
	app.Use(func(c *fiber.Ctx) error {
		c.Locals("user_id", actorID)
		return c.Next()
	})
	app.Post("/api/v1/file-manager/files/create-from-template", handler.CreateFromTemplate)

	body := `{"data":{"type":"file_manager_file_create_from_template","attributes":{"template_uuid":"` + uuidString(templateHeliFileID) + `","helicopter_id":"` + uuidString(helicopterID) + `","takeover_id":"` + uuidString(takeoverID) + `","name":"draft.docx"}}}`
	req := httptest.NewRequest(http.MethodPost, "/api/v1/file-manager/files/create-from-template", strings.NewReader(body))
	req.Header.Set("Content-Type", "application/json")
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app test failed: %v", err)
	}
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("unexpected status %d", resp.StatusCode)
	}
	var out struct {
		Data struct {
			FileID          string `json:"file_id"`
			CollaboraAction string `json:"collabora_action"`
			AccessToken     string `json:"access_token"`
			AccessTokenTTL  int64  `json:"access_token_ttl"`
		} `json:"data"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
		t.Fatalf("decode response: %v", err)
	}
	if out.Data.FileID != uuidString(existingFileID) {
		t.Fatalf("unexpected file id %q", out.Data.FileID)
	}
	assertWOPIActionContainsAccessToken(t, out.Data.CollaboraAction, out.Data.AccessToken)
	if out.Data.AccessTokenTTL <= 0 {
		t.Fatalf("expected positive access token ttl, got %d", out.Data.AccessTokenTTL)
	}
	if svc.requestUploadIntentCalls != 0 || svc.createFromIntentCalls != 0 || svc.createAttachmentCalls != 1 || svc.updateFileCalls != 0 {
		t.Fatalf("unexpected service call counts: upload=%d create=%d attach=%d update=%d", svc.requestUploadIntentCalls, svc.createFromIntentCalls, svc.createAttachmentCalls, svc.updateFileCalls)
	}
	if heliSvc.createCalls != 0 || heliSvc.listCalls != 1 {
		t.Fatalf("unexpected helicopter file call counts: create=%d list=%d", heliSvc.createCalls, heliSvc.listCalls)
	}
	if attachCalls != 1 {
		t.Fatalf("expected one takeover attach call, got %d", attachCalls)
	}
}

func TestFileManagerCreateFromTemplate_DoesNotReuseNameMatchedTakeoverFileWithoutTemplateMetadata(t *testing.T) {
	actorID := uuidv7.MustBytes()
	helicopterID := uuidv7.MustBytes()
	takeoverID := uuidv7.MustBytes()
	templateHeliFileID := uuidv7.MustBytes()
	sourceFileID := uuidv7.MustBytes()
	createdFileID := uuidv7.MustBytes()

	svc := &fileManagerTemplateServiceMock{
		requestUploadIntentFn: func(_ context.Context, params filemanager.RequestFileUploadIntentParams) (*filemanager.FileUploadIntentGrant, error) {
			if params.Name != "draft.xlsx" {
				t.Fatalf("unexpected upload intent name %q", params.Name)
			}
			return &filemanager.FileUploadIntentGrant{
				UploadIntentID: uuidv7.MustBytes(),
				ObjectKey:      "objects/new-draft.xlsx",
				Name:           "draft.xlsx",
				MimeType:       "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
				SizeBytes:      int64(len(testXLSXZip(t))),
			}, nil
		},
		createFromIntentFn: func(_ context.Context, params filemanager.CreateFileFromUploadIntentParams) (*filemanager.File, error) {
			if params.Name != "draft.xlsx" {
				t.Fatalf("unexpected create-from-intent name %q", params.Name)
			}
			return &filemanager.File{
				ID:        createdFileID,
				Name:      "draft.xlsx",
				ObjectKey: "objects/new-draft.xlsx",
				MimeType:  "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
				SizeBytes: int64(len(testXLSXZip(t))),
				Status:    filemanager.FileStatusReady,
			}, nil
		},
		createAttachmentFn: func(_ context.Context, params filemanager.CreateAttachmentParams) (*filemanager.Attachment, error) {
			return &filemanager.Attachment{ID: uuidv7.MustBytes(), FileID: params.FileID, RefType: params.RefType, RefID: params.RefID}, nil
		},
		getByTemplateTakeoverFn: func(_ context.Context, gotTemplateID, gotTakeoverID []byte) (*filemanager.File, error) {
			if !bytes.Equal(gotTemplateID, templateHeliFileID) || !bytes.Equal(gotTakeoverID, takeoverID) {
				t.Fatal("unexpected template/takeover lookup args")
			}
			return nil, nil
		},
	}

	heliSvc := &helicopterFileTemplateServiceMock{
		getByIDFn: func(_ context.Context, id []byte) (*helicopterfile.HelicopterFile, error) {
			if !bytes.Equal(id, templateHeliFileID) {
				t.Fatal("unexpected helicopter file id lookup")
			}
			return &helicopterfile.HelicopterFile{
				ID:           templateHeliFileID,
				HelicopterID: helicopterID,
				SourceFile: &filemanager.File{
					ID:        sourceFileID,
					Name:      "template.xlsx",
					ObjectKey: "templates/template.xlsx",
					MimeType:  "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
					SizeBytes: int64(len(testXLSXZip(t))),
				},
			}, nil
		},
		listByHelicopterAndSectionFn: func(_ context.Context, gotHelicopterID []byte, section string) ([]helicopterfile.HelicopterFile, error) {
			if !bytes.Equal(gotHelicopterID, helicopterID) {
				t.Fatal("unexpected helicopter id lookup")
			}
			if section != helicopterfile.SectionFlightPreparationAndWB {
				t.Fatalf("unexpected section %q", section)
			}
			return nil, nil
		},
	}

	takeoverSvc := &takeoverTemplateServiceMock{
		getByIDFn: func(_ context.Context, id []byte) (*takeoverdomain.TakeoverAc, error) {
			if !bytes.Equal(id, takeoverID) {
				t.Fatal("unexpected takeover id lookup")
			}
			return &takeoverdomain.TakeoverAc{
				ID:        takeoverID,
				CreatedBy: actorID,
				Files: []takeoverdomain.TakeoverFile{
					{
						FileAttachment: &filemanager.Attachment{
							File: &filemanager.File{
								ID:             uuidv7.MustBytes(),
								Name:           "draft.xlsx",
								NameNormalized: "draft.xlsx",
								ObjectKey:      "objects/blank-draft.xlsx",
								MimeType:       "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
								Status:         filemanager.FileStatusReady,
							},
						},
					},
				},
			}, nil
		},
		attachFn: func(_ context.Context, gotTakeoverID, _ []byte) error {
			if !bytes.Equal(gotTakeoverID, takeoverID) {
				t.Fatal("unexpected takeover attach id")
			}
			return nil
		},
	}

	templatePayload := testXLSXZip(t)
	wopiSvc := service.NewWOPIService(service.WOPIConfig{
		TokenSecret:   "test-secret",
		EditActionURL: "https://collabora.example.com/loleaflet/{WOPISrc}",
		PublicBaseURL: "https://api.example.com",
		TokenTTL:      30 * time.Minute,
	})
	handler := NewFileManagerFileHandler(svc, &templateCopyStorageMock{
		copyFn: func(_ context.Context, sourceObjectKey, destinationObjectKey string) (filemanager.ObjectMetadata, error) {
			if sourceObjectKey != "templates/template.xlsx" || destinationObjectKey != "objects/new-draft.xlsx" {
				t.Fatalf("unexpected copy %q -> %q", sourceObjectKey, destinationObjectKey)
			}
			return filemanager.ObjectMetadata{}, nil
		},
		getFn: func(_ context.Context, objectKey string) (io.ReadCloser, filemanager.ObjectMetadata, error) {
			switch objectKey {
			case "templates/template.xlsx", "objects/new-draft.xlsx":
				return io.NopCloser(bytes.NewReader(templatePayload)), filemanager.ObjectMetadata{
					SizeBytes:   int64(len(templatePayload)),
					ContentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
				}, nil
			default:
				t.Fatalf("unexpected object key %q", objectKey)
				return nil, filemanager.ObjectMetadata{}, nil
			}
		},
	}).
		WithHelicopterFileService(heliSvc).
		WithTakeoverService(takeoverSvc).
		WithWOPIService(wopiSvc)

	app := fiber.New()
	app.Use(func(c *fiber.Ctx) error {
		c.Locals("user_id", actorID)
		return c.Next()
	})
	app.Post("/api/v1/file-manager/files/create-from-template", handler.CreateFromTemplate)

	body := `{"data":{"type":"file_manager_file_create_from_template","attributes":{"template_uuid":"` + uuidString(templateHeliFileID) + `","helicopter_id":"` + uuidString(helicopterID) + `","takeover_id":"` + uuidString(takeoverID) + `","name":"draft.xlsx"}}}`
	req := httptest.NewRequest(http.MethodPost, "/api/v1/file-manager/files/create-from-template", strings.NewReader(body))
	req.Header.Set("Content-Type", "application/json")
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app test failed: %v", err)
	}
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("unexpected status %d", resp.StatusCode)
	}

	var out struct {
		Data struct {
			FileID      string `json:"file_id"`
			AccessToken string `json:"access_token"`
		} `json:"data"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
		t.Fatalf("decode response: %v", err)
	}
	if out.Data.FileID != uuidString(createdFileID) {
		t.Fatalf("expected a fresh template copy, got file id %q", out.Data.FileID)
	}
	if svc.requestUploadIntentCalls != 1 || svc.createFromIntentCalls != 1 {
		t.Fatalf("expected fresh copy path, got upload=%d create=%d", svc.requestUploadIntentCalls, svc.createFromIntentCalls)
	}
	claims, err := wopiSvc.VerifyAccessToken(out.Data.AccessToken, createdFileID)
	if err != nil {
		t.Fatalf("verify response token: %v", err)
	}
	if claims.TakeoverID != uuidString(takeoverID) {
		t.Fatalf("expected takeover-scoped wopi token, got takeover_id %q", claims.TakeoverID)
	}
}

func TestFileManagerCreateFromTemplate_RejectsHelicopterMismatch(t *testing.T) {
	actorID := uuidv7.MustBytes()
	helicopterID := uuidv7.MustBytes()
	otherHelicopterID := uuidv7.MustBytes()
	templateHeliFileID := uuidv7.MustBytes()

	svc := &fileManagerTemplateServiceMock{}
	heliSvc := &helicopterFileTemplateServiceMock{
		getByIDFn: func(_ context.Context, id []byte) (*helicopterfile.HelicopterFile, error) {
			if !bytes.Equal(id, templateHeliFileID) {
				t.Fatal("unexpected helicopter file id lookup")
			}
			return &helicopterfile.HelicopterFile{
				ID:           templateHeliFileID,
				HelicopterID: otherHelicopterID,
				SourceFile: &filemanager.File{
					ID:        uuidv7.MustBytes(),
					Name:      "template.docx",
					ObjectKey: "templates/template.docx",
					MimeType:  "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
					SizeBytes: 2048,
				},
			}, nil
		},
	}
	handler := NewFileManagerFileHandler(svc, &templateCopyStorageMock{}).
		WithHelicopterFileService(heliSvc).
		WithWOPIService(service.NewWOPIService(service.WOPIConfig{
			TokenSecret:   "test-secret",
			EditActionURL: "https://collabora.example.com/loleaflet/{WOPISrc}",
			PublicBaseURL: "https://api.example.com",
			TokenTTL:      30 * time.Minute,
		}))

	app := fiber.New()
	app.Use(func(c *fiber.Ctx) error {
		c.Locals("user_id", actorID)
		return c.Next()
	})
	app.Post("/api/v1/file-manager/files/create-from-template", handler.CreateFromTemplate)

	body := `{"data":{"type":"file_manager_file_create_from_template","attributes":{"template_uuid":"` + uuidString(templateHeliFileID) + `","helicopter_id":"` + uuidString(helicopterID) + `","name":"draft.docx"}}}`
	req := httptest.NewRequest(http.MethodPost, "/api/v1/file-manager/files/create-from-template", strings.NewReader(body))
	req.Header.Set("Content-Type", "application/json")
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app test failed: %v", err)
	}
	if resp.StatusCode != http.StatusForbidden {
		t.Fatalf("unexpected status %d", resp.StatusCode)
	}
	if svc.requestUploadIntentCalls != 0 {
		t.Fatalf("expected no upload intent call, got %d", svc.requestUploadIntentCalls)
	}
}

func TestFileManagerEditSession_AllowsDraftOwnerWithoutTakeover(t *testing.T) {
	actorID := uuidv7.MustBytes()
	fileID := uuidv7.MustBytes()
	docxPayload := testOfficeZip(t, []string{
		"[Content_Types].xml",
		"word/document.xml",
	})

	svc := &fileManagerTemplateServiceMock{
		getFileByIDAnyFn: func(_ context.Context, id []byte) (*filemanager.File, error) {
			if !bytes.Equal(id, fileID) {
				t.Fatal("unexpected file id lookup")
			}
			return &filemanager.File{
				ID:        fileID,
				Name:      "draft.docx",
				ObjectKey: "objects/draft.docx",
				MimeType:  "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
				CreatedBy: actorID,
			}, nil
		},
	}

	handler := NewFileManagerFileHandler(svc, &templateCopyStorageMock{
		getFn: func(_ context.Context, objectKey string) (io.ReadCloser, filemanager.ObjectMetadata, error) {
			if objectKey != "objects/draft.docx" {
				t.Fatalf("unexpected object key %q", objectKey)
			}
			return io.NopCloser(bytes.NewReader(docxPayload)), filemanager.ObjectMetadata{
				SizeBytes:   int64(len(docxPayload)),
				ContentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
			}, nil
		},
	}).
		WithTakeoverService(&takeoverTemplateServiceMock{}).
		WithWOPIService(service.NewWOPIService(service.WOPIConfig{
			TokenSecret:   "test-secret",
			EditActionURL: "https://collabora.example.com/loleaflet/{WOPISrc}",
			PublicBaseURL: "https://api.example.com",
			TokenTTL:      30 * time.Minute,
		}))

	app := fiber.New()
	app.Use(func(c *fiber.Ctx) error {
		c.Locals("user_id", actorID)
		return c.Next()
	})
	app.Get("/api/v1/file-manager/files/:fileId/edit-session", handler.EditSession)

	req := httptest.NewRequest(http.MethodGet, "/api/v1/file-manager/files/"+uuidString(fileID)+"/edit-session", nil)
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app test failed: %v", err)
	}
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("unexpected status %d", resp.StatusCode)
	}
	var out struct {
		Data struct {
			FileID          string `json:"file_id"`
			CollaboraAction string `json:"collabora_action"`
			AccessToken     string `json:"access_token"`
			AccessTokenTTL  int64  `json:"access_token_ttl"`
		} `json:"data"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
		t.Fatalf("decode response: %v", err)
	}
	if out.Data.FileID != uuidString(fileID) {
		t.Fatalf("unexpected file id %q", out.Data.FileID)
	}
	assertWOPIActionContainsAccessToken(t, out.Data.CollaboraAction, out.Data.AccessToken)
	if out.Data.AccessTokenTTL <= 0 {
		t.Fatalf("expected positive access token ttl, got %d", out.Data.AccessTokenTTL)
	}
}

func TestValidateWOPISaveContent_AllowsGenericCollaboraContentType(t *testing.T) {
	xlsxPayload := testXLSXZip(t)

	if err := validateWOPISaveContent("draft.xlsx", "application/octet-stream", xlsxPayload); err != nil {
		t.Fatalf("expected generic Collabora content type to be accepted: %v", err)
	}
}

func TestValidateWOPISaveContent_RejectsIncompleteXLSX(t *testing.T) {
	xlsxPayload := testOfficeZip(t, []string{
		"[Content_Types].xml",
		"xl/workbook.xml",
		"xl/_rels/workbook.xml.rels",
	})

	if err := validateWOPISaveContent("draft.xlsx", "application/octet-stream", xlsxPayload); err == nil {
		t.Fatal("expected incomplete xlsx to be rejected")
	}
}

func assertWOPIActionContainsAccessToken(t *testing.T, actionURL, expectedToken string) {
	t.Helper()
	if strings.TrimSpace(actionURL) == "" {
		t.Fatal("expected collabora action url")
	}
	if strings.TrimSpace(expectedToken) == "" {
		t.Fatal("expected access token in response")
	}
	parsed, err := url.Parse(actionURL)
	if err != nil {
		t.Fatalf("parse collabora action url: %v", err)
	}
	// access_token and access_token_ttl must be SEPARATE top-level query params of
	// the action URL (WOPI/Collabora contract), NOT embedded inside WOPISrc.
	if got := parsed.Query().Get("access_token"); got != expectedToken {
		t.Fatalf("unexpected access token in action url: got %q want %q", got, expectedToken)
	}
	if got := strings.TrimSpace(parsed.Query().Get("access_token_ttl")); got == "" {
		t.Fatal("expected access_token_ttl in action url")
	}

	// WOPISrc must be present and must be the BARE file URL with no embedded token.
	wopiSrcEncoded := strings.TrimSpace(parsed.Query().Get("WOPISrc"))
	if wopiSrcEncoded == "" {
		wopiSrcEncoded = strings.TrimSpace(path.Base(parsed.EscapedPath()))
	}
	if wopiSrcEncoded == "" {
		t.Fatal("expected WOPISrc in collabora action url")
	}
	wopiSrc, err := url.QueryUnescape(wopiSrcEncoded)
	if err != nil {
		t.Fatalf("unescape WOPISrc: %v", err)
	}
	wopiParsed, err := url.Parse(wopiSrc)
	if err != nil {
		t.Fatalf("parse WOPISrc: %v", err)
	}
	if got := wopiParsed.Query().Get("access_token"); got != "" {
		t.Fatalf("WOPISrc must not embed access_token, got %q", got)
	}
}

func uuidString(id []byte) string {
	s, err := uuidv7.BytesToString(id)
	if err != nil {
		return ""
	}
	return s
}

func testOfficeZip(t *testing.T, entries []string) []byte {
	t.Helper()

	contents := make(map[string]string, len(entries))
	for _, entry := range entries {
		contents[entry] = "<xml/>"
	}
	return testOfficeZipWithContents(t, contents)
}

func testXLSXZip(t *testing.T) []byte {
	t.Helper()
	return testOfficeZipWithContents(t, map[string]string{
		"[Content_Types].xml": `<?xml version="1.0" encoding="UTF-8"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
  <Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
  <Default Extension="xml" ContentType="application/xml"/>
  <Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
  <Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>
</Types>`,
		"xl/workbook.xml": `<?xml version="1.0" encoding="UTF-8"?>
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"
  xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
  <sheets>
    <sheet name="Sheet1" sheetId="1" r:id="rId1"/>
  </sheets>
</workbook>`,
		"xl/_rels/workbook.xml.rels": `<?xml version="1.0" encoding="UTF-8"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
  <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>
</Relationships>`,
		"xl/worksheets/sheet1.xml": `<?xml version="1.0" encoding="UTF-8"?>
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
  <sheetData/>
</worksheet>`,
	})
}

func testOfficeZipWithContents(t *testing.T, contents map[string]string) []byte {
	t.Helper()

	var buf bytes.Buffer
	writer := zip.NewWriter(&buf)
	for entry, content := range contents {
		w, err := writer.Create(entry)
		if err != nil {
			t.Fatalf("create zip entry %q: %v", entry, err)
		}
		if _, err := w.Write([]byte(content)); err != nil {
			t.Fatalf("write zip entry %q: %v", entry, err)
		}
	}
	if err := writer.Close(); err != nil {
		t.Fatalf("close zip: %v", err)
	}
	return buf.Bytes()
}
