PdfStorageService.java

package com.wavii.service;

import com.wavii.dto.pdf.PdfResponseDto;
import com.wavii.model.PdfDocument;
import com.wavii.model.PdfLike;
import com.wavii.model.User;
import com.wavii.repository.PdfDocumentRepository;
import com.wavii.repository.PdfLikeRepository;
import lombok.RequiredArgsConstructor;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;

import java.io.IOException;
import java.net.MalformedURLException;
import java.nio.file.*;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;

/**
 * Servicio para el almacenamiento y gestión de archivos PDF (tablaturas).
 * Maneja la subida de archivos, generación de portadas y metadatos asociados.
 * 
 * @author danielrguezh
 */
@Service
@RequiredArgsConstructor
public class PdfStorageService {

    private final PdfDocumentRepository repository;
    private final PdfLikeRepository likeRepository;
    private final com.wavii.repository.PdfReportRepository reportRepository;

    @Value("${pdf.storage.path:./uploads/pdfs}")
    private String storagePath;

    /**
     * Guarda un nuevo archivo PDF (tablatura) en el sistema, incluyendo metadatos y portada opcional.
     * 
     * @param file Archivo PDF subido.
     * @param coverImage Imagen de portada opcional.
     * @param owner Usuario propietario del documento.
     * @param songTitle Título de la canción.
     * @param description Descripción opcional.
     * @param difficulty Nivel de dificultad (1-3).
     * @return Respuesta con los datos del PDF guardado.
     * @throws IOException Si ocurre un error al escribir el archivo en disco.
     */
    @Transactional
    public PdfResponseDto save(
            MultipartFile file,
            MultipartFile coverImage,
            User owner,
            String songTitle,
            String description,
            int difficulty
    ) throws IOException {
        if (file.isEmpty()) throw new IllegalArgumentException("El archivo no puede estar vacio");
        String contentType = file.getContentType();
        if (contentType == null || !contentType.equals("application/pdf")) {
            throw new IllegalArgumentException("Solo se permiten archivos PDF");
        }
        if (difficulty < 1 || difficulty > 3) difficulty = 1;

        Path storageDir = Paths.get(storagePath);
        Files.createDirectories(storageDir);

        String fileName = UUID.randomUUID() + ".pdf";
        Path filePath = storageDir.resolve(fileName);
        Files.copy(file.getInputStream(), filePath, StandardCopyOption.REPLACE_EXISTING);

        String coverImagePath = null;
        if (coverImage != null && !coverImage.isEmpty()) {
            String coverType = coverImage.getContentType();
            if (coverType == null || !coverType.startsWith("image/")) {
                throw new IllegalArgumentException("La portada debe ser una imagen");
            }

            Path coverDir = storageDir.resolve("covers");
            Files.createDirectories(coverDir);
            String coverExt = getExtension(coverImage.getOriginalFilename(), ".jpg");
            String coverName = UUID.randomUUID() + coverExt;
            Path coverPath = coverDir.resolve(coverName);
            Files.copy(coverImage.getInputStream(), coverPath, StandardCopyOption.REPLACE_EXISTING);
            coverImagePath = "pdfs/covers/" + coverName;
        }

        int pageCount = 0;
        try (PDDocument pdf = Loader.loadPDF(filePath.toFile())) {
            pageCount = pdf.getNumberOfPages();
        } catch (Exception ignored) {
        }

        PdfDocument doc = PdfDocument.builder()
                .originalName(file.getOriginalFilename() != null ? file.getOriginalFilename() : fileName)
                .fileName(fileName)
                .filePath(filePath.toAbsolutePath().toString())
                .fileSize(file.getSize())
                .pageCount(pageCount)
                .uploadedAt(LocalDateTime.now())
                .songTitle(songTitle != null ? songTitle.strip() : null)
                .description(description != null && !description.isBlank() ? description.strip() : null)
                .coverImagePath(coverImagePath)
                .difficulty(difficulty)
                .likeCount(0)
                .owner(owner)
                .build();

        return PdfResponseDto.from(repository.save(doc));
    }

    /**
     * Obtiene el feed público de tablaturas con filtros y ordenación opcional.
     * 
     * @param search Término de búsqueda (título).
     * @param difficulty Nivel de dificultad para filtrar.
     * @param sort Criterio de ordenación (NEWEST, MOST_LIKED, etc.).
     * @param currentUser Usuario actual para marcar sus "likes".
     * @return Lista de tablaturas que coinciden con los criterios.
     */
    @Transactional(readOnly = true)
    public List<PdfResponseDto> getPublicFeed(String search, Integer difficulty, String sort, User currentUser) {
        String q = (search != null && !search.isBlank()) ? search.strip() : null;
        Sort sortOrder = switch (sort != null ? sort : "NEWEST") {
            case "OLDEST" -> Sort.by(Sort.Direction.ASC, "uploadedAt");
            case "MOST_LIKED" -> Sort.by(Sort.Direction.DESC, "likeCount");
            case "LEAST_LIKED" -> Sort.by(Sort.Direction.ASC, "likeCount");
            default -> Sort.by(Sort.Direction.DESC, "uploadedAt");
        };
        Pageable page = PageRequest.of(0, 50, sortOrder);
        List<PdfDocument> docs;
        if (q != null && difficulty != null) {
            docs = repository.findPublicFeedBySearchAndDifficulty(q, difficulty, page);
        } else if (q != null) {
            docs = repository.findPublicFeedBySearch(q, page);
        } else if (difficulty != null) {
            docs = repository.findPublicFeedByDifficulty(difficulty, page);
        } else {
            docs = repository.findAllPublicFeed(page);
        }

        Set<Long> likedIds = new HashSet<>();
        if (currentUser != null && !docs.isEmpty()) {
            List<Long> ids = docs.stream().map(PdfDocument::getId).collect(Collectors.toList());
            likedIds = new HashSet<>(likeRepository.findLikedPdfIds(currentUser.getId(), ids));
        }

        final Set<Long> liked = likedIds;
        return docs.stream()
                .map(d -> PdfResponseDto.from(d, liked.contains(d.getId())))
                .collect(Collectors.toList());
    }

    /**
     * Obtiene una tablatura por ID marcando si gusta al usuario actual.
     * 
     * @param id ID del documento.
     * @param currentUser Usuario actual.
     * @return Datos del PDF.
     */
    @Transactional(readOnly = true)
    public PdfResponseDto getByIdForUser(Long id, User currentUser) {
        PdfDocument doc = findById(id);
        boolean likedByMe =
                currentUser != null &&
                        likeRepository.existsByPdfIdAndUserId(id, currentUser.getId());
        return PdfResponseDto.from(doc, likedByMe);
    }

    /**
     * Lista todas las tablaturas (privadas y públicas) de un usuario.
     * 
     * @param owner Usuario propietario.
     * @return Lista de tablaturas del usuario.
     */
    @Transactional(readOnly = true)
    public List<PdfResponseDto> listByUser(User owner) {
        return repository.findByOwnerOrderByUploadedAtDesc(owner)
                .stream()
                .map(PdfResponseDto::from)
                .collect(Collectors.toList());
    }

    /**
     * Obtiene las tablaturas públicas de un usuario específico para vista de perfil.
     * 
     * @param userId ID del usuario dueño.
     * @param viewer Usuario que consulta.
     * @return Lista de tablaturas públicas.
     */
    @Transactional(readOnly = true)
    public List<PdfResponseDto> getPublicTabsByUser(UUID userId, User viewer) {
        List<PdfDocument> docs = repository.findPublicByOwnerIdOrderByUploadedAtDesc(userId);
        if (docs.isEmpty()) return List.of();

        Set<Long> likedIds = new HashSet<>();
        if (viewer != null) {
            List<Long> ids = docs.stream().map(PdfDocument::getId).collect(Collectors.toList());
            likedIds.addAll(likeRepository.findLikedPdfIds(viewer.getId(), ids));
        }
        return docs.stream()
                .map(doc -> PdfResponseDto.from(doc, likedIds.contains(doc.getId())))
                .collect(Collectors.toList());
    }

    /**
     * Registra un "me gusta" en una tablatura.
     * 
     * @param id ID de la tablatura.
     * @param user Usuario que da like.
     * @return Datos actualizados del PDF.
     */
    @Transactional
    public PdfResponseDto like(Long id, User user) {
        PdfDocument doc = findById(id);
        if (!likeRepository.existsByPdfIdAndUserId(id, user.getId())) {
            likeRepository.save(PdfLike.builder().pdf(doc).user(user).likedAt(LocalDateTime.now()).build());
            repository.incrementLikeCount(id);
            doc.setLikeCount(doc.getLikeCount() + 1);
        }
        return PdfResponseDto.from(doc, true);
    }

    /**
     * Retira un "me gusta" de una tablatura.
     * 
     * @param id ID de la tablatura.
     * @param user Usuario que retira el like.
     * @return Datos actualizados del PDF.
     */
    @Transactional
    public PdfResponseDto unlike(Long id, User user) {
        PdfDocument doc = findById(id);
        likeRepository.findByPdfIdAndUserId(id, user.getId()).ifPresent(like -> {
            likeRepository.delete(like);
            repository.decrementLikeCount(id);
            doc.setLikeCount(Math.max(0, doc.getLikeCount() - 1));
        });
        return PdfResponseDto.from(doc, false);
    }

    /**
     * Busca un documento PDF por su ID interno.
     */
    public PdfDocument findById(Long id) {
        return repository.findById(id)
                .orElseThrow(() -> new RuntimeException("PDF no encontrado con id: " + id));
    }

    /**
     * Carga el archivo PDF desde el disco como recurso para su descarga.
     * 
     * @param id ID del documento.
     * @return Recurso descargable.
     * @throws MalformedURLException Si la ruta del archivo es inválida.
     */
    public Resource loadAsResource(Long id) throws MalformedURLException {
        PdfDocument doc = findById(id);
        Path path = Paths.get(doc.getFilePath());
        Resource resource = new UrlResource(path.toUri());
        if (!resource.exists()) throw new RuntimeException("Archivo no encontrado en disco: " + doc.getFileName());
        return resource;
    }

    /**
     * Elimina un archivo PDF del sistema (disco y base de datos).
     * 
     * @param id ID del documento.
     * @param owner Usuario que solicita el borrado (debe ser el dueño).
     * @throws IOException Si falla la eliminación física del archivo.
     */
    @Transactional
    public void delete(Long id, User owner) throws IOException {
        PdfDocument doc = repository.findByIdAndOwner(id, owner)
                .orElseThrow(() -> new SecurityException("No autorizado o PDF no encontrado"));
        Files.deleteIfExists(Paths.get(doc.getFilePath()));
        if (doc.getCoverImagePath() != null && !doc.getCoverImagePath().isBlank()) {
            Path uploadsRoot = Paths.get(storagePath).getParent();
            if (uploadsRoot == null) {
                uploadsRoot = Paths.get("uploads");
            }
            Files.deleteIfExists(uploadsRoot.resolve(doc.getCoverImagePath()));
        }
        likeRepository.deleteByPdfId(id);
        reportRepository.deleteByPdfDocumentId(id);
        repository.delete(doc);
    }

    private String getExtension(String originalFilename, String fallback) {
        if (originalFilename == null || !originalFilename.contains(".")) {
            return fallback;
        }
        String ext = originalFilename.substring(originalFilename.lastIndexOf('.'));
        return ext.isBlank() ? fallback : ext;
    }
}