package service

import (
	"context"
	"errors"
	"fmt"
	"mime"
	"mime/multipart"
	"net/url"
	"path/filepath"
	"strings"
	"time"

	commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
	"gitlab.com/mbugroup/lti-api.git/internal/config"
	entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
	"gitlab.com/mbugroup/lti-api.git/internal/utils"

	"github.com/google/uuid"
)

const (
	defaultDocumentPathLimit = 255
	defaultDocumentKeyPrefix = "docs"
	maxDocumentNameLength    = 50
)

type DocumentService interface {
	UploadDocuments(ctx context.Context, req DocumentUploadRequest) ([]DocumentUploadResult, error)
	ListByTarget(ctx context.Context, documentableType string, documentableID uint64) ([]entity.Document, error)
	DeleteDocuments(ctx context.Context, ids []uint, removeFromStorage bool) error
	DeleteByTarget(ctx context.Context, documentableType string, documentableID uint64, removeFromStorage bool) error
	PublicURL(document entity.Document) string
	PresignURL(ctx context.Context, document entity.Document, expires time.Duration) (string, error)
}

type DocumentUploadRequest struct {
	DocumentableType string
	DocumentableID   uint64
	CreatedBy        *uint
	Files            []DocumentFile
}

type DocumentFile struct {
	File  *multipart.FileHeader
	Type  string
	Index *int
}

type DocumentUploadResult struct {
	Document entity.Document
	URL      string
	Index    *int
}

type DocumentServiceOption func(*documentService)

type documentService struct {
	repo          commonRepo.DocumentRepository
	storage       DocumentStorage
	keyPrefix     string
	maxPathLength int
}

func NewDocumentService(repo commonRepo.DocumentRepository, storage DocumentStorage, opts ...DocumentServiceOption) DocumentService {
	svc := &documentService{
		repo:          repo,
		storage:       storage,
		keyPrefix:     defaultDocumentKeyPrefix,
		maxPathLength: defaultDocumentPathLimit,
	}

	for _, opt := range opts {
		opt(svc)
	}

	return svc
}

func NewDocumentServiceFromConfig(ctx context.Context, repo commonRepo.DocumentRepository) (DocumentService, error) {
	if repo == nil {
		return nil, errors.New("document repository is required")
	}
	if strings.TrimSpace(config.S3Bucket) == "" {
		return nil, errors.New("S3_BUCKET is not configured")
	}

	storage, err := NewS3DocumentStorage(ctx, S3DocumentStorageConfig{
		Region:         config.S3Region,
		Bucket:         config.S3Bucket,
		AccessKey:      config.S3AccessKey,
		SecretKey:      config.S3SecretKey,
		Endpoint:       config.S3Endpoint,
		BaseURL:        config.S3PublicBaseURL,
		ForcePathStyle: config.S3ForcePathStyle,
	})
	if err != nil {
		return nil, err
	}

	prefix := config.S3DocumentKeyPrefix
	if prefix == "" {
		prefix = defaultDocumentKeyPrefix
	}

	return NewDocumentService(
		repo,
		storage,
		WithDocumentKeyPrefix(prefix),
		WithDocumentPathLimit(defaultDocumentPathLimit),
	), nil
}

func WithDocumentKeyPrefix(prefix string) DocumentServiceOption {
	return func(svc *documentService) {
		prefix = strings.Trim(prefix, "/")
		if prefix == "" {
			prefix = defaultDocumentKeyPrefix
		}
		svc.keyPrefix = prefix
	}
}

func WithDocumentPathLimit(limit int) DocumentServiceOption {
	return func(svc *documentService) {
		if limit > 0 {
			svc.maxPathLength = limit
		}
	}
}

func (s *documentService) UploadDocuments(ctx context.Context, req DocumentUploadRequest) ([]DocumentUploadResult, error) {
	if s.repo == nil {
		return nil, errors.New("document repository not configured")
	}
	if s.storage == nil {
		return nil, errors.New("document storage not configured")
	}

	documentableType := strings.ToUpper(strings.TrimSpace(req.DocumentableType))
	if documentableType == "" {
		return nil, errors.New("documentable type is required")
	}
	if req.DocumentableID == 0 {
		return nil, errors.New("documentable id is required")
	}
	if len(req.Files) == 0 {
		return nil, errors.New("no files to upload")
	}

	var createdBy *uint
	if req.CreatedBy != nil && *req.CreatedBy != 0 {
		idCopy := *req.CreatedBy
		createdBy = &idCopy
	}

	results := make([]DocumentUploadResult, 0, len(req.Files))
	createdDocs := make([]entity.Document, 0, len(req.Files))

	for _, file := range req.Files {
		if file.File == nil {
			return nil, errors.New("file header is required")
		}

		originalName := sanitizeDocumentName(file.File.Filename)
		contentType := detectContentType(file.File, originalName)
		ext := detectExtension(file.File.Filename, contentType)
		key, err := s.generateObjectKey(ext)
		if err != nil {
			s.rollbackDocuments(ctx, createdDocs)
			return nil, err
		}

		reader, err := file.File.Open()
		if err != nil {
			s.rollbackDocuments(ctx, createdDocs)
			return nil, err
		}
		uploadRes, err := s.storage.Upload(ctx, key, reader, file.File.Size, contentType)
		_ = reader.Close()
		if err != nil {
			s.rollbackDocuments(ctx, createdDocs)
			return nil, err
		}

		docType := resolveDocumentType(file.Type, documentableType)
		doc := entity.Document{
			DocumentableType: documentableType,
			DocumentableId:   req.DocumentableID,
			Type:             docType,
			Path:             uploadRes.Key,
			Name:             originalName,
			Ext:              strings.TrimPrefix(ext, "."),
			Size:             float64(file.File.Size),
			CreatedBy:        createdBy,
		}

		if err := s.repo.CreateOne(ctx, &doc, nil); err != nil {
			_ = s.storage.Delete(ctx, uploadRes.Key)
			s.rollbackDocuments(ctx, createdDocs)
			return nil, err
		}

		createdDocs = append(createdDocs, doc)
		results = append(results, DocumentUploadResult{
			Document: doc,
			URL:      uploadRes.URL,
			Index:    cloneIndex(file.Index),
		})
	}

	return results, nil
}

func (s *documentService) ListByTarget(ctx context.Context, documentableType string, documentableID uint64) ([]entity.Document, error) {
	if s.repo == nil {
		return nil, errors.New("document repository not configured")
	}

	documentableType = strings.ToUpper(strings.TrimSpace(documentableType))
	if documentableType == "" {
		return nil, errors.New("documentable type is required")
	}
	if documentableID == 0 {
		return nil, errors.New("documentable id is required")
	}

	return s.repo.ListByTarget(ctx, documentableType, documentableID, nil)
}

func (s *documentService) DeleteDocuments(ctx context.Context, ids []uint, removeFromStorage bool) error {
	if s.repo == nil {
		return errors.New("document repository not configured")
	}
	if len(ids) == 0 {
		return nil
	}

	docs, err := s.repo.GetByIDs(ctx, ids, nil)
	if err != nil {
		return err
	}

	for _, doc := range docs {
		if err := s.repo.DeleteOne(ctx, doc.Id); err != nil {
			return err
		}
		if removeFromStorage && s.storage != nil {
			if err := s.storage.Delete(ctx, doc.Path); err != nil {
				utils.Log.WithError(err).Warnf("failed to delete document object %s", doc.Path)
			}
		}
	}

	return nil
}

func (s *documentService) DeleteByTarget(ctx context.Context, documentableType string, documentableID uint64, removeFromStorage bool) error {
	if s.repo == nil {
		return errors.New("document repository not configured")
	}

	documentableType = strings.ToUpper(strings.TrimSpace(documentableType))
	if documentableType == "" || documentableID == 0 {
		return errors.New("documentable type and id are required")
	}

	var docs []entity.Document
	if removeFromStorage && s.storage != nil {
		var err error
		docs, err = s.repo.ListByTarget(ctx, documentableType, documentableID, nil)
		if err != nil {
			return err
		}
	}

	if err := s.repo.DeleteByTarget(ctx, documentableType, documentableID, nil); err != nil {
		return err
	}

	if removeFromStorage && len(docs) > 0 {
		for _, doc := range docs {
			if err := s.storage.Delete(ctx, doc.Path); err != nil {
				utils.Log.WithError(err).Warnf("failed to delete document object %s", doc.Path)
			}
		}
	}

	return nil
}

func (s *documentService) PublicURL(document entity.Document) string {
	if s.storage == nil || strings.TrimSpace(document.Path) == "" {
		return ""
	}
	return s.storage.URL(document.Path)
}

func (s *documentService) PresignURL(ctx context.Context, document entity.Document, expires time.Duration) (string, error) {
	if s.storage == nil {
		return "", errors.New("document storage not configured")
	}
	if strings.TrimSpace(document.Path) == "" {
		return "", errors.New("document path is required")
	}
	return s.storage.PresignURL(ctx, document.Path, expires)
}

// ResolveDocumentURL normalizes a stored path or URL into a presigned URL.
func ResolveDocumentURL(
	ctx context.Context,
	svc DocumentService,
	rawPath string,
	expires time.Duration,
) (string, error) {
	if svc == nil {
		return "", nil
	}

	rawPath = strings.TrimSpace(rawPath)
	if rawPath == "" {
		return "", nil
	}

	key := rawPath
	lower := strings.ToLower(rawPath)
	if strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") {
		key = extractS3KeyFromURL(rawPath)
		if key == "" {
			return "", nil
		}
	}

	return svc.PresignURL(ctx, entity.Document{Path: key}, expires)
}

func extractS3KeyFromURL(raw string) string {
	parsed, err := url.Parse(strings.TrimSpace(raw))
	if err != nil {
		return ""
	}
	path := strings.TrimPrefix(parsed.Path, "/")
	if path == "" {
		return ""
	}

	host := strings.ToLower(strings.TrimSpace(parsed.Host))
	if strings.HasPrefix(host, "s3.") || strings.HasPrefix(host, "s3-") {
		parts := strings.SplitN(path, "/", 2)
		if len(parts) == 2 {
			return parts[1]
		}
		return ""
	}

	return path
}

func (s *documentService) generateObjectKey(ext string) (string, error) {
	normalizedExt := strings.TrimSpace(ext)
	if normalizedExt != "" && !strings.HasPrefix(normalizedExt, ".") {
		normalizedExt = "." + normalizedExt
	}

	u := uuid.New().String()
	keyPrefix := strings.Trim(s.keyPrefix, "/")
	key := fmt.Sprintf("%s%s", u, normalizedExt)
	if keyPrefix != "" {
		key = fmt.Sprintf("%s/%s%s", keyPrefix, u, normalizedExt)
	}

	if len(key) > s.maxPathLength {
		compact := strings.ReplaceAll(u, "-", "")
		if keyPrefix != "" {
			key = fmt.Sprintf("%s/%s%s", keyPrefix, compact, normalizedExt)
		} else {
			key = fmt.Sprintf("%s%s", compact, normalizedExt)
		}
	}

	if len(key) > s.maxPathLength {
		return "", fmt.Errorf("object key exceeds maximum length (%d)", s.maxPathLength)
	}

	return key, nil
}

func (s *documentService) rollbackDocuments(ctx context.Context, docs []entity.Document) {
	if len(docs) == 0 {
		return
	}

	for i := len(docs) - 1; i >= 0; i-- {
		doc := docs[i]
		if s.repo != nil && doc.Id != 0 {
			if err := s.repo.DeleteOne(ctx, doc.Id); err != nil {
				utils.Log.WithError(err).Warnf("failed to rollback document #%d", doc.Id)
			}
		}
		if s.storage != nil && strings.TrimSpace(doc.Path) != "" {
			if err := s.storage.Delete(ctx, doc.Path); err != nil {
				utils.Log.WithError(err).Warnf("failed to rollback document object %s", doc.Path)
			}
		}
	}
}

func sanitizeDocumentName(name string) string {
	name = filepath.Base(strings.TrimSpace(name))
	if name == "." || name == "" {
		name = "document"
	}
	name = strings.Map(func(r rune) rune {
		if r < 32 {
			return -1
		}
		switch r {
		case '\\', '/', ':', '*', '?', '"', '<', '>', '|':
			return '-'
		default:
			return r
		}
	}, name)

	if len(name) > maxDocumentNameLength {
		runes := []rune(name)
		if len(runes) > maxDocumentNameLength {
			name = string(runes[:maxDocumentNameLength])
		}
	}
	return name
}

func detectExtension(filename, contentType string) string {
	ext := strings.ToLower(strings.TrimSpace(filepath.Ext(filename)))
	if ext == "" && contentType != "" {
		if exts, _ := mime.ExtensionsByType(contentType); len(exts) > 0 {
			ext = exts[0]
		}
	}
	if ext == "" {
		return ".bin"
	}
	if !strings.HasPrefix(ext, ".") {
		ext = "." + ext
	}
	return ext
}

func detectContentType(file *multipart.FileHeader, filename string) string {
	if file == nil {
		return "application/octet-stream"
	}
	contentType := strings.TrimSpace(file.Header.Get("Content-Type"))
	if contentType != "" {
		return contentType
	}
	if ext := filepath.Ext(filename); ext != "" {
		if guess := mime.TypeByExtension(ext); guess != "" {
			return guess
		}
	}
	return "application/octet-stream"
}

func resolveDocumentType(fileType, fallback string) string {
	value := strings.ToUpper(strings.TrimSpace(fileType))
	if value == "" {
		return fallback
	}
	return value
}

func cloneIndex(index *int) *int {
	if index == nil {
		return nil
	}
	value := *index
	return &value
}
