BulletinController.java

package com.wavii.controller;

import com.wavii.dto.bulletin.BulletinBoardResponse;
import com.wavii.dto.bulletin.BulletinTeacherResponse;
import com.wavii.dto.bulletin.BulletinUpdateRequest;
import com.wavii.model.User;
import com.wavii.model.enums.Role;
import com.wavii.model.enums.Subscription;
import com.wavii.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.UUID;

@RestController
@RequestMapping("/api/bulletin")
@RequiredArgsConstructor
@Slf4j
public class BulletinController {

    private static final int FREE_BULLETIN_LIMIT = 3;
    private static final String UPLOAD_DIR = "/app/uploads/bulletin/";

    private final UserRepository userRepository;

    @Value("${wavii.app.base-url:http://localhost:8080}")
    private String appBaseUrl;

    /**
     * Obtiene el listado de profesores para el tablón de anuncios.
     * 
     * @param currentUser Usuario autenticado.
     * @param query Búsqueda por texto (nombre, bio, etc.).
     * @param instrument Instrumento que imparte.
     * @param role Rol (Certificado o Particular).
     * @param modality Modalidad (Online, Presencial, Ambas).
     * @param availability Disponibilidad horaria.
     * @param city Ciudad para clases presenciales.
     * @return Listado filtrado y paginado según el plan del usuario.
     */
    @GetMapping
    @Transactional(readOnly = true)
    public ResponseEntity<BulletinBoardResponse> getTeachers(
            @AuthenticationPrincipal User currentUser,
            @RequestParam(value = "query", required = false) String query,
            @RequestParam(value = "instrument", required = false) String instrument,
            @RequestParam(value = "role", required = false) String role,
            @RequestParam(value = "modality", required = false) String modality,
            @RequestParam(value = "availability", required = false) String availability,
            @RequestParam(value = "city", required = false) String city) {
// ... (omitted code for brevity, but I will replace the entire block)
        List<BulletinTeacherResponse> allTeachers = userRepository
                .findByRoleIn(List.of(Role.PROFESOR_PARTICULAR, Role.PROFESOR_CERTIFICADO))
                .stream()
                .map(this::toResponse)
                .filter(teacher -> matchesQuery(teacher, query))
                .filter(teacher -> matchesInstrument(teacher, instrument))
                .filter(teacher -> matchesRole(teacher, role))
                .filter(teacher -> matchesModality(teacher, modality))
                .filter(teacher -> matchesAvailability(teacher, availability))
                .filter(teacher -> matchesCity(teacher, city))
                .sorted(Comparator.comparing((BulletinTeacherResponse t) ->
                        "profesor_certificado".equalsIgnoreCase(t.role()) ? 0 : 1)
                        .thenComparing(BulletinTeacherResponse::name, String.CASE_INSENSITIVE_ORDER))
                .toList();

        boolean hasFullAccess = currentUser != null;
        boolean canPublish = canPublish(currentUser);

        List<BulletinTeacherResponse> visibleTeachers = hasFullAccess
                ? allTeachers
                : allTeachers.stream().limit(FREE_BULLETIN_LIMIT).toList();

        return ResponseEntity.ok(new BulletinBoardResponse(
                visibleTeachers,
                hasFullAccess,
                canPublish,
                FREE_BULLETIN_LIMIT,
                allTeachers.size(),
                Math.max(0, allTeachers.size() - visibleTeachers.size()),
                "scholar"));
    }

    /**
     * Obtiene el perfil público de un profesor.
     * 
     * @param teacherId ID del profesor.
     * @return El perfil del profesor.
     */
    @GetMapping("/{teacherId}")
    @Transactional(readOnly = true)
    public ResponseEntity<?> getTeacher(@PathVariable UUID teacherId) {
        User teacher = userRepository.findById(teacherId)
                .filter(user -> user.getRole() == Role.PROFESOR_PARTICULAR
                        || user.getRole() == Role.PROFESOR_CERTIFICADO)
                .orElse(null);
        if (teacher == null) {
            return ResponseEntity.status(HttpStatus.NOT_FOUND)
                    .body(Map.of("message", "Profesor no encontrado"));
        }
        return ResponseEntity.ok(toResponse(teacher));
    }

    /**
     * Actualiza el perfil de profesor en el tablón.
     * 
     * @param currentUser Usuario autenticado.
     * @param request Datos del perfil a actualizar.
     * @return El perfil actualizado.
     */
    @PostMapping
    public ResponseEntity<?> updateProfile(
            @AuthenticationPrincipal User currentUser,
            @RequestBody BulletinUpdateRequest request) {
        if (!canPublish(currentUser)) {
            return ResponseEntity.status(403)
                    .body(Map.of(
                            "error",
                            "Solo los usuarios con suscripción Scholar pueden publicar en el tablón",
                            "requiredPlan", "scholar"));
        }

        String modality = normalize(request.classModality());
        String city = normalize(request.city());
        if (("PRESENCIAL".equals(modality) || "AMBAS".equals(modality)) && (city == null || city.isBlank())) {
            return ResponseEntity.badRequest().body(Map.of(
                    "message", "La ciudad es obligatoria si la modalidad es presencial o ambas"));
        }
        if (isBlank(request.province())) {
            return ResponseEntity.badRequest().body(Map.of("message", "La provincia es obligatoria"));
        }
        if (isBlank(request.contactEmail())) {
            return ResponseEntity.badRequest().body(Map.of("message", "El correo de contacto es obligatorio"));
        }
        if (isBlank(request.contactPhone())) {
            return ResponseEntity.badRequest().body(Map.of("message", "El número de contacto es obligatorio"));
        }
        if (!isValidEmail(request.contactEmail())) {
            return ResponseEntity.badRequest().body(Map.of("message", "El correo de contacto no es válido"));
        }
        if (!isValidPhone(request.contactPhone())) {
            return ResponseEntity.badRequest().body(Map.of("message", "El número de contacto no es válido"));
        }

        if (currentUser.getRole() == Role.USUARIO) {
            currentUser.setRole(Role.PROFESOR_PARTICULAR);
        }

        currentUser.setBio(normalize(request.bio()));
        currentUser.setInstrument(normalize(request.instrument()));
        currentUser.setPricePerHour(request.pricePerHour());
        currentUser.setCity(city);
        currentUser.setLatitude(request.latitude());
        currentUser.setLongitude(request.longitude());
        currentUser.setAddress(normalize(request.address()));
        currentUser.setProvince(normalize(request.province()));
        currentUser.setContactEmail(normalize(request.contactEmail()));
        currentUser.setContactPhone(normalize(request.contactPhone()));
        currentUser.setInstagramUrl(normalize(request.instagramUrl()));
        currentUser.setTiktokUrl(normalize(request.tiktokUrl()));
        currentUser.setYoutubeUrl(normalize(request.youtubeUrl()));
        currentUser.setFacebookUrl(normalize(request.facebookUrl()));
        currentUser.setBannerImageUrl(normalize(request.bannerImageUrl()));
        currentUser.setPlaceImageUrls(safeImageUrls(request.placeImageUrls()));
        currentUser.setAvailabilityPreference(normalizeAvailability(request.availabilityPreference()));
        currentUser.setAvailabilityNotes(normalize(request.availabilityNotes()));
        currentUser.setClassModality(modality);

        User saved = userRepository.save(currentUser);
        return ResponseEntity.ok(toResponse(saved));
    }

    /**
     * Sube una imagen para el perfil del profesor (banner o fotos del lugar).
     * 
     * @param file Archivo de imagen.
     * @param kind Tipo de imagen ("banner" o "place").
     * @return URL de la imagen subida.
     */
    @PostMapping("/images")
    public ResponseEntity<?> uploadImage(@RequestParam("file") MultipartFile file,
                                         @RequestParam(value = "kind", required = false) String kind) {
        if (file.isEmpty()) {
            return ResponseEntity.badRequest().body(Map.of("message", "El archivo no puede estar vacío"));
        }

        try {
            Path uploadPath = Paths.get(UPLOAD_DIR);
            Files.createDirectories(uploadPath);

            String original = file.getOriginalFilename();
            String extension = "";
            if (original != null && original.contains(".")) {
                extension = original.substring(original.lastIndexOf('.'));
            }

            String prefix = "banner".equalsIgnoreCase(kind) ? "banner" : "place";
            String storedName = prefix + "_" + UUID.randomUUID() + extension;
            Path destination = uploadPath.resolve(storedName);
            Files.copy(file.getInputStream(), destination, StandardCopyOption.REPLACE_EXISTING);

            String url = appBaseUrl + "/uploads/bulletin/" + storedName;
            return ResponseEntity.ok(Map.of("url", url, "fileName", storedName));
        } catch (IOException e) {
            log.error("Error subiendo imagen del tablón: {}", e.getMessage());
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
                    .body(Map.of("message", "No se pudo guardar la imagen"));
        }
    }

    /** Convierte un usuario a su respuesta DTO para el tablón. */
    private BulletinTeacherResponse toResponse(User user) {
        return new BulletinTeacherResponse(
                user.getId().toString(),
                user.getName(),
                user.getRole().name().toLowerCase(),
                user.getBio(),
                user.getInstrument(),
                user.getPricePerHour(),
                user.getCity(),
                user.getLatitude(),
                user.getLongitude(),
                user.getAddress(),
                user.getProvince(),
                user.getContactEmail(),
                user.getContactPhone(),
                user.getInstagramUrl(),
                user.getTiktokUrl(),
                user.getYoutubeUrl(),
                user.getFacebookUrl(),
                user.getBannerImageUrl(),
                safeImageUrls(user.getPlaceImageUrls()),
                user.getAvailabilityPreference(),
                user.getAvailabilityNotes(),
                user.getClassModality());
    }

    /** Comprueba si el usuario tiene acceso completo al tablón (Plan Scholar). */
    private boolean hasScholarAccess(User user) {
        return user != null && user.getSubscription() == Subscription.SCHOLAR;
    }

    /** Comprueba si el usuario puede publicar en el tablón. */
    private boolean canPublish(User user) {
        return user != null && hasScholarAccess(user);
    }

    /** Filtra por texto en varios campos del profesor. */
    private boolean matchesQuery(BulletinTeacherResponse teacher, String query) {
        if (isBlank(query)) return true;
        String q = query.trim().toLowerCase();
        return contains(teacher.name(), q)
                || contains(teacher.bio(), q)
                || contains(teacher.instrument(), q)
                || contains(teacher.city(), q)
                || contains(teacher.province(), q);
    }

    /** Filtra por instrumento. */
    private boolean matchesInstrument(BulletinTeacherResponse teacher, String instrument) {
        if (isBlank(instrument) || "Todos".equalsIgnoreCase(instrument)) return true;
        return contains(teacher.instrument(), instrument.trim().toLowerCase());
    }

    /** Filtra por rol (Certificado/Particular). */
    private boolean matchesRole(BulletinTeacherResponse teacher, String role) {
        if (isBlank(role) || "Todos".equalsIgnoreCase(role)) return true;
        if ("Certificados".equalsIgnoreCase(role)) return "profesor_certificado".equalsIgnoreCase(teacher.role());
        if ("Particulares".equalsIgnoreCase(role)) return "profesor_particular".equalsIgnoreCase(teacher.role());
        return teacher.role().equalsIgnoreCase(role.trim());
    }

    /** Filtra por modalidad de clase. */
    private boolean matchesModality(BulletinTeacherResponse teacher, String modality) {
        if (isBlank(modality) || "Todos".equalsIgnoreCase(modality)) return true;
        return teacher.classModality() != null && teacher.classModality().equalsIgnoreCase(modality.trim());
    }

    /** Filtra por disponibilidad horaria. */
    private boolean matchesAvailability(BulletinTeacherResponse teacher, String availability) {
        if (isBlank(availability) || "Todos".equalsIgnoreCase(availability)) return true;
        return teacher.availabilityPreference() != null
                && teacher.availabilityPreference().equalsIgnoreCase(availability.trim());
    }

    /** Filtra por ciudad. */
    private boolean matchesCity(BulletinTeacherResponse teacher, String city) {
        if (isBlank(city)) return true;
        return contains(teacher.city(), city.trim().toLowerCase());
    }

    /** Comprueba si una cadena contiene otra (case-insensitive). */
    private boolean contains(String value, String query) {
        return value != null && value.toLowerCase().contains(query);
    }

    /** Comprueba si una cadena es nula o vacía. */
    private boolean isBlank(String value) {
        return value == null || value.isBlank();
    }

    /** Normaliza una cadena quitando espacios y devolviendo null si queda vacía. */
    private String normalize(String value) {
        if (value == null) return null;
        String trimmed = value.trim();
        return trimmed.isEmpty() ? null : trimmed;
    }

    /** Valida el formato de un email. */
    private boolean isValidEmail(String value) {
        return value != null && value.trim().matches("^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$");
    }

    /** Valida el formato de un número de teléfono. */
    private boolean isValidPhone(String value) {
        return value != null && value.trim().matches("^\\+?[0-9\\s-]{7,20}$");
    }

    /** Filtra y limpia una lista de URLs de imágenes. */
    private List<String> safeImageUrls(List<String> urls) {
        if (urls == null) return new ArrayList<>();
        return urls.stream()
                .filter(url -> url != null && !url.isBlank())
                .map(String::trim)
                .toList();
    }

    /** Normaliza el valor de preferencia de disponibilidad. */
    private String normalizeAvailability(String value) {
        String normalized = normalize(value);
        if (normalized == null) {
            return null;
        }
        return switch (normalized.toUpperCase()) {
            case "MORNING", "AFTERNOON", "ANYTIME", "CUSTOM" -> normalized.toUpperCase();
            default -> null;
        };
    }
}