package handlers

import (
	"archive/zip"
	"bytes"
	"context"
	"encoding/xml"
	"errors"
	"io"
	"net/http"
	"path"
	"path/filepath"
	"strings"
	"time"

	"github.com/gofiber/fiber/v2"

	filemanager "wucher/internal/domain/file_manager"
	helicopterfile "wucher/internal/domain/helicopter_file"
	takeoverdomain "wucher/internal/domain/takeover"
	helicopterfilerealtime "wucher/internal/realtime/helicopterfile"
	"wucher/internal/service"
	"wucher/internal/shared/pkg/uuidv7"
	dto "wucher/internal/transport/http/dto"
	requestdto "wucher/internal/transport/http/dto/request"
	responsedto "wucher/internal/transport/http/dto/response"
)

type templateCopyStorage interface {
	CopyObject(ctx context.Context, sourceObjectKey, destinationObjectKey string) (filemanager.ObjectMetadata, error)
	GetObject(ctx context.Context, objectKey string) (io.ReadCloser, filemanager.ObjectMetadata, error)
	HeadObject(ctx context.Context, objectKey string) (filemanager.ObjectMetadata, error)
	DeleteObject(ctx context.Context, objectKey string) error
}

type templateTakeoverFileLookupService interface {
	GetFileByTemplateAndTakeover(ctx context.Context, templateUUID, takeoverID []byte) (*filemanager.File, error)
}

type uploadIntentSizeUpdater interface {
	UpdateUploadIntentSize(ctx context.Context, params filemanager.FileUploadIntentSizeUpdateParams) error
}

type helicopterFileEventPublisher interface {
	PublishToUsers(userUUIDs []string, event helicopterfilerealtime.Event) int
}

const (
	wopiStorageReadyAttempts = 5
	wopiStorageReadyDelay    = 200 * time.Millisecond
)

var _ = dto.ErrorResponse{}

// CreateFromTemplate godoc
// @Summary Create file from template and start a WOPI edit session
// @Description Copies a helicopter template file into a new draft file and returns a Collabora/WOPI write session for the newly created file.
// @Description This endpoint always marks the created file as `is_template=false` because the result is a normal working document, not a reusable template.
// @Description The source template must be a helicopter file that belongs to the same `helicopter_id` provided in the request.
// @Description `template_uuid` is the helicopter-file UUID, not the file UUID.
// @Description Request fields: `template_uuid`, `helicopter_id`, optional `takeover_id`, and `name`.
// @Description Success response includes the new `file_id`, Collabora editor action URL, an access token, and the token TTL in milliseconds.
// @Tags File Manager
// @Accept json
// @Produce json
// @Param request body requestdto.FileManagerFileTemplateCreateRequest true "Create-from-template payload"
// @Success 200 {object} responsedto.FileManagerFileWOPISessionResponse
// @Failure 400 {object} dto.ErrorResponse
// @Failure 401 {object} dto.ErrorResponse
// @Failure 403 {object} dto.ErrorResponse
// @Failure 404 {object} dto.ErrorResponse
// @Failure 422 {object} dto.ErrorResponse
// @Failure 503 {object} dto.ErrorResponse
// @Router /api/v1/file-manager/files/create-from-template [post]
func (h *FileManagerFileHandler) CreateFromTemplate(c *fiber.Ctx) error {
	if h == nil || h.svc == nil || h.heliFile == nil || h.wopi == nil || h.storage == nil {
		return writeFileManagerError(c, fiber.StatusServiceUnavailable, "Create failed", "file manager service is not configured", nil)
	}

	var req requestdto.FileManagerFileTemplateCreateRequest
	if err := c.BodyParser(&req); err != nil {
		return writeFileManagerError(c, fiber.StatusBadRequest, "Invalid JSON", err.Error(), nil)
	}
	if strings.TrimSpace(req.Data.Type) != "file_manager_file_create_from_template" && strings.TrimSpace(req.Data.Type) != "file_manager_file_create" {
		return writeFileManagerError(c, fiber.StatusUnprocessableEntity, "Validation error", "type must be file_manager_file_create_from_template or file_manager_file_create", nil)
	}

	attrs := req.Data.Attributes
	templateID, err := uuidv7.ParseString(strings.TrimSpace(attrs.TemplateUUID))
	if err != nil {
		return writeFileManagerError(c, fiber.StatusUnprocessableEntity, "Validation error", "template_uuid is invalid UUID", nil)
	}
	helicopterID, err := uuidv7.ParseString(strings.TrimSpace(attrs.HelicopterID))
	if err != nil {
		return writeFileManagerError(c, fiber.StatusUnprocessableEntity, "Validation error", "helicopter_id is invalid UUID", nil)
	}
	var takeoverID []byte
	if takeoverRaw := strings.TrimSpace(attrs.TakeoverID); takeoverRaw != "" {
		takeoverID, err = uuidv7.ParseString(takeoverRaw)
		if err != nil {
			return writeFileManagerError(c, fiber.StatusUnprocessableEntity, "Validation error", "takeover_id is invalid UUID", nil)
		}
	}
	name := strings.TrimSpace(attrs.Name)
	if name == "" {
		return writeFileManagerError(c, fiber.StatusUnprocessableEntity, "Validation error", "name is required", nil)
	}

	actorID := actorUserID(c)
	templateHeliFile, err := h.heliFile.GetByID(c.UserContext(), templateID)
	if err != nil {
		return writeFileManagerActionError(c, "Create from template failed", err)
	}
	if templateHeliFile == nil || len(templateHeliFile.HelicopterID) != 16 || !bytes.Equal(templateHeliFile.HelicopterID, helicopterID) {
		return writeFileManagerError(c, fiber.StatusForbidden, "Forbidden", "forbidden access", nil)
	}

	templateRow, err := h.resolveHelicopterTemplateSourceFile(c.UserContext(), templateHeliFile)
	if err != nil {
		return writeFileManagerError(c, fiber.StatusForbidden, "Forbidden", "forbidden access", nil)
	}

	copyStorage, ok := any(h.storage).(templateCopyStorage)
	if !ok {
		return writeFileManagerError(c, fiber.StatusServiceUnavailable, "Storage unavailable", "storage is not configured for copy", nil)
	}
	if err := validateWOPITemplateCandidate(templateRow, copyStorage); err != nil {
		return writeFileManagerError(c, fiber.StatusUnprocessableEntity, "Validation error", err.Error(), nil)
	}

	var takeoverRow *takeoverdomain.TakeoverAc
	if len(takeoverID) == 16 {
		if h.takeover == nil {
			return writeFileManagerError(c, fiber.StatusServiceUnavailable, "Create failed", "takeover service is not configured", nil)
		}
		takeoverRow, err = h.takeover.GetByID(c.UserContext(), takeoverID)
		if err != nil {
			return writeFileManagerActionError(c, "Create from template failed", err)
		}
		if takeoverRow == nil || (!takeoverHasUserAccess(takeoverRow, actorID) && !h.takeoverUserHasGlobalAccessForUserID(c.UserContext(), actorID)) {
			return writeFileManagerError(c, fiber.StatusForbidden, "Forbidden", "forbidden access", nil)
		}
		existing, lookupErr := h.findExistingTemplateTakeoverFile(c.UserContext(), templateID, takeoverID, takeoverRow, name, strings.TrimSpace(templateRow.MimeType))
		if lookupErr != nil {
			return writeFileManagerActionError(c, "Create from template failed", lookupErr)
		}
		if existing != nil {
			if err := waitStoredWOPIFileReady(c.UserContext(), copyStorage, existing); err != nil {
				return writeFileManagerError(c, fiber.StatusUnprocessableEntity, "Validation error", err.Error(), nil)
			}
			if _, _, err := h.sanitizeStoredXLSXObject(c.UserContext(), existing, existing.ObjectKey, existing.Name, existing.MimeType, "create-from-template-existing"); err != nil {
				return writeFileManagerActionError(c, "Create from template failed", err)
			}
			if err := h.ensureTemplateTakeoverMetadata(c.UserContext(), existing, templateID, takeoverID, actorID); err != nil {
				return writeFileManagerActionError(c, "Create from template failed", err)
			}
			if err := h.ensureTakeoverAttachment(c.UserContext(), takeoverRow, existing.ID, takeoverID, actorID); err != nil {
				return writeFileManagerActionError(c, "Create from template failed", err)
			}
			childRow, childCreated, err := h.ensureHelicopterFileChild(c.UserContext(), helicopterID, existing.ID, actorID)
			if err != nil {
				return writeFileManagerActionError(c, "Create from template failed", err)
			}
			if childCreated {
				h.publishHelicopterFileCreated(actorID, helicopterID, childRow, name, templateRow.MimeType, childRow.Section, childRow.Position, childRow.IsMandatory, true)
			}
			token, ttl, err := h.wopi.MintAccessToken(existing.ID, actorID, takeoverID, service.WOPIPermissionWrite)
			if err != nil {
				return writeFileManagerActionError(c, "Create from template failed", err)
			}
			fileID := idString(existing.ID)
			actionURL, err := h.wopi.BuildActionURLWithToken(c.UserContext(), fileID, token, ttl)
			if err != nil {
				return writeFileManagerActionError(c, "Create from template failed", err)
			}

			return c.Status(fiber.StatusOK).JSON(responsedto.FileManagerFileWOPISessionResponse{
				Data: responsedto.FileManagerFileWOPISessionData{
					FileID:          fileID,
					CollaboraAction: actionURL,
					AccessToken:     token,
					AccessTokenTTL:  ttl,
				},
			})
		}
	}

	grant, err := h.svc.RequestFileUploadIntent(c.UserContext(), filemanager.RequestFileUploadIntentParams{
		Name:        name,
		ContentType: strings.TrimSpace(templateRow.MimeType),
		SizeBytes:   templateRow.SizeBytes,
		ActorID:     actorID,
	})
	if err != nil {
		return writeFileManagerActionError(c, "Create from template failed", err)
	}

	templateObjectKey := strings.TrimSpace(templateRow.ObjectKey)
	if templateObjectKey == "" {
		return writeFileManagerError(c, fiber.StatusNotFound, "Not found", "template object not found", nil)
	}
	if _, err := copyStorage.CopyObject(c.UserContext(), templateObjectKey, grant.ObjectKey); err != nil {
		_ = copyStorageDelete(copyStorage, grant.ObjectKey)
		return writeFileManagerActionError(c, "Create from template failed", err)
	}
	if err := waitStoredWOPIFileReady(c.UserContext(), copyStorage, &filemanager.File{
		Name:      name,
		ObjectKey: grant.ObjectKey,
		MimeType:  strings.TrimSpace(templateRow.MimeType),
	}); err != nil {
		_ = copyStorageDelete(copyStorage, grant.ObjectKey)
		return writeFileManagerError(c, fiber.StatusUnprocessableEntity, "Validation error", err.Error(), nil)
	}
	changed, sanitizedSize, err := h.sanitizeStoredXLSXObject(c.UserContext(), nil, grant.ObjectKey, name, templateRow.MimeType, "create-from-template-copy")
	if err != nil {
		_ = copyStorageDelete(copyStorage, grant.ObjectKey)
		return writeFileManagerActionError(c, "Create from template failed", err)
	}
	if changed {
		updater, ok := any(h.svc).(uploadIntentSizeUpdater)
		if !ok {
			_ = copyStorageDelete(copyStorage, grant.ObjectKey)
			return writeFileManagerError(c, fiber.StatusServiceUnavailable, "Create failed", "file manager service does not support upload intent size updates", nil)
		}
		if err := updater.UpdateUploadIntentSize(c.UserContext(), filemanager.FileUploadIntentSizeUpdateParams{
			ID:        grant.UploadIntentID,
			SizeBytes: sanitizedSize,
			UpdatedBy: actorID,
		}); err != nil {
			_ = copyStorageDelete(copyStorage, grant.ObjectKey)
			return writeFileManagerActionError(c, "Create from template failed", err)
		}
	}

	created, err := h.svc.CreateFileNodeFromUploadIntent(c.UserContext(), filemanager.CreateFileFromUploadIntentParams{
		UploadIntentID: grant.UploadIntentID,
		FolderProvided: false,
		Name:           name,
		ActorID:        actorID,
		IsTemplate:     false,
	})
	if err != nil {
		_ = copyStorageDelete(copyStorage, grant.ObjectKey)
		return writeFileManagerActionError(c, "Create from template failed", err)
	}

	now := time.Now().UTC()
	created.Status = filemanager.FileStatusReady
	created.LifecycleStatus = filemanager.FileLifecycleStatusDraft
	created.NameSlot = "draft"
	created.Name = name
	created.NameNormalized = strings.ToLower(strings.TrimSpace(name))
	created.TemplateUUID = append([]byte(nil), templateID...)
	created.TakeoverID = append([]byte(nil), takeoverID...)
	created.UpdatedAt = now
	created.UpdatedBy = actorID
	if err := h.svc.UpdateFile(c.UserContext(), created); err != nil {
		_ = copyStorageDelete(copyStorage, grant.ObjectKey)
		_ = h.svc.DeleteFile(c.UserContext(), created.ID, actorID, nil)
		return writeFileManagerActionError(c, "Create from template failed", err)
	}
	if err := h.ensureTakeoverAttachment(c.UserContext(), takeoverRow, created.ID, takeoverID, actorID); err != nil {
		_ = copyStorageDelete(copyStorage, grant.ObjectKey)
		_ = h.svc.DeleteFile(c.UserContext(), created.ID, actorID, nil)
		return writeFileManagerActionError(c, "Create from template failed", err)
	}
	childRow, childCreated, err := h.ensureHelicopterFileChild(c.UserContext(), helicopterID, created.ID, actorID)
	if err != nil {
		_ = copyStorageDelete(copyStorage, grant.ObjectKey)
		_ = h.svc.DeleteFile(c.UserContext(), created.ID, actorID, nil)
		return writeFileManagerActionError(c, "Create from template failed", err)
	}
	if childCreated {
		h.publishHelicopterFileCreated(actorID, helicopterID, childRow, name, templateRow.MimeType, childRow.Section, childRow.Position, childRow.IsMandatory, true)
	}

	token, ttl, err := h.wopi.MintAccessToken(created.ID, actorID, takeoverID, service.WOPIPermissionWrite)
	if err != nil {
		return writeFileManagerActionError(c, "Create from template failed", err)
	}
	fileID := idString(created.ID)
	actionURL, err := h.wopi.BuildActionURLWithToken(c.UserContext(), fileID, token, ttl)
	if err != nil {
		return writeFileManagerActionError(c, "Create from template failed", err)
	}

	return c.Status(fiber.StatusOK).JSON(responsedto.FileManagerFileWOPISessionResponse{
		Data: responsedto.FileManagerFileWOPISessionData{
			FileID:          fileID,
			CollaboraAction: actionURL,
			AccessToken:     token,
			AccessTokenTTL:  ttl,
		},
	})
}

func (h *FileManagerFileHandler) findExistingTemplateTakeoverFile(ctx context.Context, templateID, takeoverID []byte, takeoverRow *takeoverdomain.TakeoverAc, requestedName, requestedMime string) (*filemanager.File, error) {
	if h != nil && h.svc != nil {
		if lookupSvc, ok := any(h.svc).(templateTakeoverFileLookupService); ok {
			row, err := lookupSvc.GetFileByTemplateAndTakeover(ctx, templateID, takeoverID)
			if err != nil || row != nil {
				return row, err
			}
		}
	}
	return nil, nil
}

func (h *FileManagerFileHandler) ensureTemplateTakeoverMetadata(ctx context.Context, row *filemanager.File, templateID, takeoverID, actorID []byte) error {
	if h == nil || h.svc == nil || row == nil || len(templateID) != 16 || len(takeoverID) != 16 {
		return nil
	}
	if bytes.Equal(row.TemplateUUID, templateID) && bytes.Equal(row.TakeoverID, takeoverID) {
		return nil
	}
	row.TemplateUUID = append([]byte(nil), templateID...)
	row.TakeoverID = append([]byte(nil), takeoverID...)
	row.UpdatedAt = time.Now().UTC()
	row.UpdatedBy = append([]byte(nil), actorID...)
	return h.svc.UpdateFile(ctx, row)
}

func (h *FileManagerFileHandler) ensureTakeoverAttachment(ctx context.Context, takeoverRow *takeoverdomain.TakeoverAc, fileID, takeoverID, actorID []byte) error {
	if h == nil || h.svc == nil || h.takeover == nil || len(fileID) != 16 || len(takeoverID) != 16 {
		return nil
	}
	if takeoverRow == nil {
		row, err := h.takeover.GetByID(ctx, takeoverID)
		if err != nil {
			return err
		}
		takeoverRow = row
	}
	if takeoverRow != nil && takeoverHasFile(takeoverRow, fileID) {
		return nil
	}

	attachment, err := h.svc.CreateAttachment(ctx, filemanager.CreateAttachmentParams{
		FileID:    append([]byte(nil), fileID...),
		RefType:   "takeover",
		RefID:     idString(takeoverID),
		SortOrder: 0,
		IsPrimary: false,
		ActorID:   append([]byte(nil), actorID...),
	})
	if err != nil {
		if !errors.Is(err, filemanager.ErrAttachmentAlreadyExists) {
			return err
		}
		attachments, _, listErr := h.svc.ListAttachmentsByReference(ctx, filemanager.ListAttachmentsParams{
			RefType: "takeover",
			RefID:   idString(takeoverID),
			Sort:    "-created_at",
			Limit:   100,
		})
		if listErr != nil {
			return listErr
		}
		for i := range attachments {
			if bytes.Equal(attachments[i].FileID, fileID) {
				attachment = &attachments[i]
				break
			}
		}
		if attachment == nil {
			return filemanager.ErrAttachmentAlreadyExists
		}
	}

	if attachment == nil {
		return nil
	}
	return h.takeover.AttachFile(ctx, takeoverID, attachment.ID)
}

func (h *FileManagerFileHandler) ensureHelicopterFileChild(ctx context.Context, helicopterID, sourceFileID, actorID []byte) (*helicopterfile.HelicopterFile, bool, error) {
	if h == nil || h.heliFile == nil || len(helicopterID) != 16 || len(sourceFileID) != 16 {
		return nil, false, nil
	}

	rows, err := h.heliFile.ListByHelicopterAndSection(ctx, helicopterID, helicopterfile.SectionFlightPreparationAndWB)
	if err != nil {
		return nil, false, err
	}
	for i := range rows {
		row := rows[i]
		if bytes.Equal(row.SourceFileID, sourceFileID) {
			return &row, false, nil
		}
		if row.FileAttachment != nil && row.FileAttachment.File != nil && bytes.Equal(row.FileAttachment.File.ID, sourceFileID) {
			return &row, false, nil
		}
	}

	row := &helicopterfile.HelicopterFile{
		Section:      helicopterfile.SectionFlightPreparationAndWB,
		HelicopterID: append([]byte(nil), helicopterID...),
		SourceFileID: append([]byte(nil), sourceFileID...),
		CreatedBy:    append([]byte(nil), actorID...),
		UpdatedBy:    append([]byte(nil), actorID...),
	}
	if err := h.heliFile.Create(ctx, row); err != nil {
		return nil, false, err
	}
	return row, true, nil
}

func (h *FileManagerFileHandler) publishHelicopterFileCreated(actorID, helicopterID []byte, row *helicopterfile.HelicopterFile, name, mimeType, section string, position int, isMandatory, attachmentPending bool) {
	if h == nil || h.helicopterFileEvents == nil || row == nil || len(actorID) != 16 || len(helicopterID) != 16 {
		return
	}
	userUUID, err := uuidv7.BytesToString(actorID)
	if err != nil || strings.TrimSpace(userUUID) == "" {
		return
	}
	helicopterUUID, err := uuidv7.BytesToString(helicopterID)
	if err != nil || strings.TrimSpace(helicopterUUID) == "" {
		return
	}
	helicopterFileID := idString(row.ID)
	fileID := idString(row.SourceFileID)
	event := helicopterfilerealtime.Event{
		EventType:         helicopterfilerealtime.DefaultEventType,
		HelicopterID:      helicopterUUID,
		HelicopterFileID:  helicopterFileID,
		FileID:            fileID,
		Section:           strings.TrimSpace(section),
		Name:              strings.TrimSpace(name),
		MimeType:          strings.TrimSpace(mimeType),
		AttachmentPending: attachmentPending,
		Position:          position,
		IsMandatory:       isMandatory,
		UpdatedAt:         time.Now().UTC().Format(time.RFC3339),
	}
	h.helicopterFileEvents.PublishToUsers([]string{userUUID}, event)
}

func findTakeoverTemplateFileByName(row *takeoverdomain.TakeoverAc, requestedName, requestedMime string) *filemanager.File {
	if row == nil {
		return nil
	}
	nameNormalized := normalizeWOPIRequestedName(requestedName)
	mimeType := strings.TrimSpace(requestedMime)
	var match *filemanager.File
	for i := range row.Files {
		attachment := row.Files[i].FileAttachment
		if attachment == nil || attachment.File == nil {
			continue
		}
		fileRow := attachment.File
		if strings.TrimSpace(fileRow.NameNormalized) != nameNormalized {
			continue
		}
		if mimeType != "" && !strings.EqualFold(strings.TrimSpace(fileRow.MimeType), mimeType) {
			continue
		}
		if filemanager.NormalizeFileStatus(fileRow.Status) != filemanager.FileStatusReady {
			continue
		}
		if fileRow.DeletedAt != nil {
			continue
		}
		if match != nil && !bytes.Equal(match.ID, fileRow.ID) {
			return nil
		}
		match = fileRow
	}
	return match
}

func normalizeWOPIRequestedName(raw string) string {
	name := strings.TrimSpace(raw)
	if name == "" {
		return ""
	}
	name = strings.ReplaceAll(name, "\\", "/")
	name = filepath.Base(name)
	return strings.ToLower(name)
}

func (h *FileManagerFileHandler) resolveHelicopterTemplateSourceFile(ctx context.Context, row *helicopterfile.HelicopterFile) (*filemanager.File, error) {
	if h == nil || h.svc == nil || row == nil {
		return nil, errors.New("template file not found")
	}
	if row.SourceFile != nil {
		return row.SourceFile, nil
	}
	if row.FileAttachment != nil && row.FileAttachment.File != nil {
		return row.FileAttachment.File, nil
	}
	if len(row.SourceFileID) == 16 {
		sourceFile, err := h.svc.GetFileByIDAny(ctx, row.SourceFileID)
		if err != nil {
			return nil, err
		}
		if sourceFile != nil {
			return sourceFile, nil
		}
	}
	return nil, errors.New("template file not found")
}

// EditSession godoc
// @Summary Start a WOPI edit session for an existing file
// @Description Returns a Collabora/WOPI write session for an already existing file that belongs to the authenticated user's takeover scope or to the authenticated user as an unattached draft.
// @Description This endpoint does not create or copy files. It only resolves the file, checks access, and mints a new access token.
// @Description Use this when reopening an existing file for editing.
// @Description Path param `fileId` must be a valid UUIDv7.
// @Tags File Manager
// @Accept json
// @Produce json
// @Param fileId path string true "File UUID (UUIDv7)"
// @Success 200 {object} responsedto.FileManagerFileWOPISessionResponse
// @Failure 400 {object} dto.ErrorResponse
// @Failure 401 {object} dto.ErrorResponse
// @Failure 403 {object} dto.ErrorResponse
// @Failure 404 {object} dto.ErrorResponse
// @Failure 422 {object} dto.ErrorResponse
// @Failure 503 {object} dto.ErrorResponse
// @Router /api/v1/file-manager/files/{fileId}/edit-session [get]
func (h *FileManagerFileHandler) EditSession(c *fiber.Ctx) error {
	if h == nil || h.svc == nil || h.wopi == nil {
		return writeFileManagerError(c, fiber.StatusServiceUnavailable, "Create failed", "file manager service is not configured", nil)
	}

	fileID, err := uuidv7.ParseString(strings.TrimSpace(c.Params("fileId")))
	if err != nil {
		return writeFileManagerError(c, fiber.StatusUnprocessableEntity, "Validation error", "fileId is invalid UUID", nil)
	}

	fileRow, err := h.svc.GetFileByIDAny(c.UserContext(), fileID)
	if err != nil {
		return writeFileManagerActionError(c, "Edit session failed", err)
	}
	if fileRow == nil {
		return writeFileManagerError(c, fiber.StatusNotFound, "Not found", "file not found", nil)
	}
	if storage, ok := any(h.storage).(templateCopyStorage); ok {
		if err := waitStoredWOPIFileReady(c.UserContext(), storage, fileRow); err != nil {
			return writeFileManagerError(c, fiber.StatusUnprocessableEntity, "Validation error", err.Error(), nil)
		}
	}

	actorID := actorUserID(c)
	takeoverRow, takeoverID, err := h.findTakeoverForFile(c.UserContext(), fileID)
	if err != nil {
		if !errors.Is(err, filemanager.ErrFileNotFound) {
			return writeFileManagerActionError(c, "Edit session failed", err)
		}
		if !bytes.Equal(fileRow.CreatedBy, actorID) {
			return writeFileManagerError(c, fiber.StatusForbidden, "Forbidden", "forbidden access", nil)
		}
		takeoverID = nil
	} else if takeoverRow == nil || len(takeoverID) != 16 || (!takeoverHasUserAccess(takeoverRow, actorID) && !h.takeoverUserHasGlobalAccessForUserID(c.UserContext(), actorID)) {
		return writeFileManagerError(c, fiber.StatusForbidden, "Forbidden", "forbidden access", nil)
	}

	if _, _, err := h.sanitizeStoredXLSXObject(c.UserContext(), fileRow, fileRow.ObjectKey, fileRow.Name, fileRow.MimeType, "edit-session"); err != nil {
		return writeFileManagerActionError(c, "Edit session failed", err)
	}

	token, ttl, err := h.wopi.MintAccessToken(fileRow.ID, actorID, takeoverID, service.WOPIPermissionWrite)
	if err != nil {
		return writeFileManagerActionError(c, "Edit session failed", err)
	}
	actionURL, err := h.wopi.BuildActionURLWithToken(c.UserContext(), idString(fileRow.ID), token, ttl)
	if err != nil {
		return writeFileManagerActionError(c, "Edit session failed", err)
	}

	return c.Status(fiber.StatusOK).JSON(responsedto.FileManagerFileWOPISessionResponse{
		Data: responsedto.FileManagerFileWOPISessionData{
			FileID:          idString(fileRow.ID),
			CollaboraAction: actionURL,
			AccessToken:     token,
			AccessTokenTTL:  ttl,
		},
	})
}

func (h *FileManagerFileHandler) fileManagerFileForTakeover(row *takeoverdomain.TakeoverAc, fileID []byte) (*filemanager.File, error) {
	attachmentFile, err := takeoverFindFileByID(row, fileID)
	if err != nil {
		return nil, err
	}
	if attachmentFile == nil {
		return nil, filemanager.ErrFileNotFound
	}
	return attachmentFile, nil
}

func (h *FileManagerFileHandler) findTakeoverForFile(ctx context.Context, fileID []byte) (*takeoverdomain.TakeoverAc, []byte, error) {
	if h.takeover == nil || len(fileID) != 16 {
		return nil, nil, filemanager.ErrFileNotFound
	}

	const pageSize = 100
	for offset := 0; ; offset += pageSize {
		rows, total, err := h.takeover.List(ctx, pageSize, offset)
		if err != nil {
			return nil, nil, err
		}
		for i := range rows {
			row := rows[i]
			if !takeoverHasFile(&row, fileID) {
				continue
			}
			return &row, append([]byte(nil), row.ID...), nil
		}
		if total <= int64(offset+len(rows)) || len(rows) == 0 {
			break
		}
	}
	return nil, nil, filemanager.ErrFileNotFound
}

func takeoverHasFile(row *takeoverdomain.TakeoverAc, fileID []byte) bool {
	if row == nil || len(fileID) != 16 {
		return false
	}
	for i := range row.Files {
		attachment := row.Files[i].FileAttachment
		if attachment == nil || attachment.File == nil {
			continue
		}
		if bytes.Equal(attachment.File.ID, fileID) {
			return true
		}
	}
	return false
}

func validateWOPITemplateCandidate(row *filemanager.File, storage templateCopyStorage) error {
	if row == nil {
		return errors.New("template file not found")
	}
	name := strings.TrimSpace(row.Name)
	ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(name), "."))
	if ext == "" {
		ext = strings.ToLower(strings.TrimPrefix(filepath.Ext(strings.TrimSpace(row.ObjectKey)), "."))
	}
	if ext == "" {
		return errors.New("template file extension is required")
	}
	switch ext {
	case "docx", "xlsx", "pptx", "odt", "ods", "odp", "doc", "xls", "ppt", "rtf":
		if storage == nil {
			return nil
		}
		return waitStoredWOPIFileReady(context.Background(), storage, row)
	default:
		return errors.New("template file extension is not allowed")
	}
}

func validateWOPISaveContent(name, declaredMime string, payload []byte) error {
	ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(strings.TrimSpace(name)), "."))
	if ext == "" {
		return errors.New("file extension is required")
	}
	switch ext {
	case "docx", "xlsx", "pptx", "odt", "ods", "odp":
		if err := validateOfficeContentSample(ext, declaredMime, payload); err != nil {
			return err
		}
		return validateOfficeZipStructure(ext, payload)
	case "doc", "xls", "ppt", "rtf":
		return validateOfficeContentSample(ext, declaredMime, payload)
	default:
		return errors.New("file extension is not allowed")
	}
}

func validateOfficeContentSample(ext, declaredMime string, sample []byte) error {
	if len(sample) == 0 {
		return errors.New("file content is empty")
	}
	detected := strings.ToLower(http.DetectContentType(sample))
	declared := strings.ToLower(strings.TrimSpace(declaredMime))
	switch ext {
	case "docx", "xlsx", "pptx", "odt", "ods", "odp":
		if strings.HasPrefix(detected, "text/") {
			return errors.New("file content is not a supported office document")
		}
		if declared != "" && !isAllowedOfficeContentType(declared) {
			return errors.New("file content type is not allowed")
		}
	case "rtf":
		if !strings.Contains(detected, "rtf") && !strings.HasPrefix(detected, "text/") {
			return errors.New("file content is not rtf")
		}
	default:
		_ = declared
	}
	return nil
}

func isAllowedOfficeContentType(contentType string) bool {
	contentType = strings.ToLower(strings.TrimSpace(strings.Split(contentType, ";")[0]))
	if contentType == "" {
		return true
	}
	switch contentType {
	case "application/octet-stream", "binary/octet-stream", "application/download", "application/x-download":
		return true
	}
	return strings.Contains(contentType, "officedocument") ||
		strings.Contains(contentType, "opendocument") ||
		strings.Contains(contentType, "zip")
}

func canonicalOfficeContentType(name string) string {
	switch strings.ToLower(strings.TrimPrefix(filepath.Ext(strings.TrimSpace(name)), ".")) {
	case "docx":
		return "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
	case "xlsx":
		return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
	case "pptx":
		return "application/vnd.openxmlformats-officedocument.presentationml.presentation"
	case "odt":
		return "application/vnd.oasis.opendocument.text"
	case "ods":
		return "application/vnd.oasis.opendocument.spreadsheet"
	case "odp":
		return "application/vnd.oasis.opendocument.presentation"
	case "doc":
		return "application/msword"
	case "xls":
		return "application/vnd.ms-excel"
	case "ppt":
		return "application/vnd.ms-powerpoint"
	case "rtf":
		return "application/rtf"
	default:
		return ""
	}
}

func waitStoredWOPIFileReady(ctx context.Context, storage templateCopyStorage, row *filemanager.File) error {
	if storage == nil || row == nil {
		return nil
	}
	if ctx == nil {
		ctx = context.Background()
	}
	var lastErr error
	for attempt := 0; attempt < wopiStorageReadyAttempts; attempt++ {
		if err := validateStoredWOPIFile(ctx, storage, row); err != nil {
			lastErr = err
			if attempt < wopiStorageReadyAttempts-1 {
				select {
				case <-ctx.Done():
					return ctx.Err()
				case <-time.After(wopiStorageReadyDelay):
				}
			}
			continue
		}
		return nil
	}
	return lastErr
}

func validateStoredWOPIFile(ctx context.Context, storage templateCopyStorage, row *filemanager.File) error {
	if storage == nil || row == nil {
		return nil
	}
	if ctx == nil {
		ctx = context.Background()
	}
	name := strings.TrimSpace(row.Name)
	ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(name), "."))
	if ext == "" {
		ext = strings.ToLower(strings.TrimPrefix(filepath.Ext(strings.TrimSpace(row.ObjectKey)), "."))
	}
	if ext == "" {
		return errors.New("file extension is required")
	}
	body, meta, err := storage.GetObject(ctx, row.ObjectKey)
	if err != nil {
		return err
	}
	defer body.Close()

	payload, err := io.ReadAll(body)
	if err != nil {
		return err
	}
	if meta.SizeBytes > 0 && int64(len(payload)) != meta.SizeBytes {
		return errors.New("file content is incomplete")
	}
	if err := validateOfficeContentSample(ext, firstNonEmpty(meta.ContentType, row.MimeType), payload); err != nil {
		return err
	}
	switch ext {
	case "docx", "xlsx", "pptx", "odt", "ods", "odp":
		return validateOfficeZipStructure(ext, payload)
	default:
		return nil
	}
}

func validateOfficeZipStructure(ext string, payload []byte) error {
	reader, err := zip.NewReader(bytes.NewReader(payload), int64(len(payload)))
	if err != nil {
		return errors.New("file content is not a valid office zip document")
	}
	requiredEntries := officeZipRequiredEntries(ext)
	if len(requiredEntries) == 0 {
		return nil
	}
	seen := make(map[string]struct{}, len(reader.File))
	files := make(map[string]*zip.File, len(reader.File))
	for _, file := range reader.File {
		entryName := strings.TrimSpace(file.Name)
		seen[entryName] = struct{}{}
		files[entryName] = file
		if err := validateOfficeZipEntry(file, ext); err != nil {
			return err
		}
	}
	for _, entry := range requiredEntries {
		if _, ok := seen[entry]; !ok {
			return errors.New("file content is corrupt or incomplete")
		}
	}
	for _, prefix := range officeZipRequiredEntryPrefixes(ext) {
		if !officeZipHasEntryPrefix(seen, prefix) {
			return errors.New("file content is corrupt or incomplete")
		}
	}
	if ext == "xlsx" {
		if err := validateXLSXWorkbook(files); err != nil {
			return err
		}
	}
	return nil
}

type xlsxWorkbookXML struct {
	Sheets []xlsxSheetXML `xml:"sheets>sheet"`
}

type xlsxSheetXML struct {
	ID string `xml:"http://schemas.openxmlformats.org/officeDocument/2006/relationships id,attr"`
}

type officeRelationshipsXML struct {
	Relationships []officeRelationshipXML `xml:"Relationship"`
}

type officeRelationshipXML struct {
	ID     string `xml:"Id,attr"`
	Type   string `xml:"Type,attr"`
	Target string `xml:"Target,attr"`
}

func validateXLSXWorkbook(files map[string]*zip.File) error {
	workbookPayload, err := readOfficeZipEntry(files, "xl/workbook.xml")
	if err != nil {
		return err
	}
	var workbook xlsxWorkbookXML
	if err := xml.Unmarshal(workbookPayload, &workbook); err != nil {
		return errors.New("file content is corrupt or incomplete")
	}
	if len(workbook.Sheets) == 0 {
		return errors.New("file content is corrupt or incomplete")
	}

	relsPayload, err := readOfficeZipEntry(files, "xl/_rels/workbook.xml.rels")
	if err != nil {
		return err
	}
	var rels officeRelationshipsXML
	if err := xml.Unmarshal(relsPayload, &rels); err != nil {
		return errors.New("file content is corrupt or incomplete")
	}
	byID := make(map[string]officeRelationshipXML, len(rels.Relationships))
	for _, rel := range rels.Relationships {
		id := strings.TrimSpace(rel.ID)
		if id != "" {
			byID[id] = rel
		}
	}
	for _, sheet := range workbook.Sheets {
		rel, ok := byID[strings.TrimSpace(sheet.ID)]
		if !ok || !strings.Contains(strings.ToLower(rel.Type), "/worksheet") {
			return errors.New("file content is corrupt or incomplete")
		}
		worksheetName := resolveOfficeRelationshipTarget("xl/workbook.xml", rel.Target)
		if _, err := readOfficeZipEntry(files, worksheetName); err != nil {
			return err
		}
	}
	return nil
}

func readOfficeZipEntry(files map[string]*zip.File, name string) ([]byte, error) {
	file := files[strings.TrimSpace(name)]
	if file == nil {
		return nil, errors.New("file content is corrupt or incomplete")
	}
	rc, err := file.Open()
	if err != nil {
		return nil, errors.New("file content is corrupt or incomplete")
	}
	defer rc.Close()
	payload, err := io.ReadAll(rc)
	if err != nil {
		return nil, errors.New("file content is corrupt or incomplete")
	}
	return payload, nil
}

func resolveOfficeRelationshipTarget(source, target string) string {
	target = strings.TrimSpace(strings.ReplaceAll(target, "\\", "/"))
	if target == "" {
		return target
	}
	if strings.HasPrefix(target, "/") {
		return strings.TrimPrefix(path.Clean(target), "/")
	}
	base := path.Dir(strings.TrimSpace(source))
	if base == "." || base == "/" {
		return strings.TrimPrefix(path.Clean(target), "/")
	}
	return strings.TrimPrefix(path.Clean(path.Join(base, target)), "/")
}

func validateOfficeZipEntry(file *zip.File, ext string) error {
	if file == nil {
		return nil
	}
	name := strings.TrimSpace(file.Name)
	if name == "" || strings.HasSuffix(name, "/") {
		return nil
	}
	rc, err := file.Open()
	if err != nil {
		return errors.New("file content is corrupt or incomplete")
	}
	defer rc.Close()

	payload, err := io.ReadAll(rc)
	if err != nil {
		return errors.New("file content is corrupt or incomplete")
	}
	if shouldValidateOfficeXML(ext, name) {
		if err := validateXMLPayload(payload); err != nil {
			return errors.New("file content is corrupt or incomplete")
		}
	}
	return nil
}

func shouldValidateOfficeXML(ext, name string) bool {
	name = strings.ToLower(strings.TrimSpace(name))
	if name == "" || (!strings.HasSuffix(name, ".xml") && !strings.HasSuffix(name, ".rels")) {
		return false
	}
	switch ext {
	case "docx":
		return name == "[content_types].xml" || strings.HasPrefix(name, "word/")
	case "xlsx":
		return name == "[content_types].xml" || strings.HasPrefix(name, "xl/")
	case "pptx":
		return name == "[content_types].xml" || strings.HasPrefix(name, "ppt/")
	case "odt", "ods", "odp":
		return name == "content.xml" || name == "styles.xml" || name == "meta.xml" || name == "settings.xml"
	default:
		return false
	}
}

func validateXMLPayload(payload []byte) error {
	if len(bytes.TrimSpace(payload)) == 0 {
		return errors.New("empty xml")
	}
	decoder := xml.NewDecoder(bytes.NewReader(payload))
	for {
		if _, err := decoder.Token(); err != nil {
			if errors.Is(err, io.EOF) {
				return nil
			}
			return err
		}
	}
}

func officeZipRequiredEntries(ext string) []string {
	switch ext {
	case "docx":
		return []string{"[Content_Types].xml", "word/document.xml"}
	case "xlsx":
		return []string{"[Content_Types].xml", "xl/workbook.xml", "xl/_rels/workbook.xml.rels"}
	case "pptx":
		return []string{"[Content_Types].xml", "ppt/presentation.xml"}
	case "odt", "ods", "odp":
		return []string{"mimetype", "content.xml"}
	default:
		return nil
	}
}

func officeZipRequiredEntryPrefixes(ext string) []string {
	switch ext {
	case "xlsx":
		return []string{"xl/worksheets/"}
	default:
		return nil
	}
}

func officeZipHasEntryPrefix(entries map[string]struct{}, prefix string) bool {
	prefix = strings.TrimSpace(prefix)
	if prefix == "" {
		return true
	}
	for entry := range entries {
		if strings.HasPrefix(entry, prefix) && strings.HasSuffix(strings.ToLower(entry), ".xml") {
			return true
		}
	}
	return false
}

func copyStorageDelete(storage any, objectKey string) error {
	deleter, ok := storage.(interface {
		DeleteObject(context.Context, string) error
	})
	if !ok {
		return nil
	}
	return deleter.DeleteObject(context.Background(), objectKey)
}
