package com.example.ldp.service;

import java.math.BigDecimal;
import java.time.Instant;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;

import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import com.example.ldp.api.dto.CreateSongRequest;
import com.example.ldp.api.dto.DeleteDancePreviewDto;
import com.example.ldp.api.dto.DeleteSongPreviewDto;
import com.example.ldp.api.dto.LinkRequest;
import com.example.ldp.api.dto.SongDetailDto;
import com.example.ldp.api.mapper.SongMapper;
import com.example.ldp.config.ErrorCode;
import com.example.ldp.domain.Link;
import com.example.ldp.domain.LinkKind;
import com.example.ldp.domain.LinkKindConstants;
import com.example.ldp.domain.Song;
import com.example.ldp.repo.DanceSongRepository;
import com.example.ldp.repo.DanceSuggestionRepository;
import com.example.ldp.repo.EventSetlistItemRepository;
import com.example.ldp.repo.LinkRepository;
import com.example.ldp.repo.SongMergeHistoryRepository;
import com.example.ldp.repo.SongRepository;
import com.example.ldp.repo.spec.SongSpecs;
import com.example.ldp.service.exception.BadRequestException;
import com.example.ldp.service.exception.ClaimException;
import com.example.ldp.service.exception.NotFoundException;
import com.example.ldp.user.User;
import com.example.ldp.user.UserRepository;

import lombok.extern.slf4j.Slf4j;

@Service
@Slf4j
@Transactional(readOnly = true)
public class SongService {

    private final SongRepository songs;
    private final LinkRepository links;
    private final DanceSongRepository danceSongs;
    private final SongMapper mapper;
    private final UserRepository users;
    private final SongMergeHistoryRepository songMergeHistory;
    private final EventSetlistItemRepository eventSetlistItems;
    private final DanceSuggestionRepository danceSuggestions;

    public SongService(SongRepository songs, LinkRepository links, DanceSongRepository danceSongs,
            SongMapper mapper, UserRepository users,
            SongMergeHistoryRepository songMergeHistory,
            EventSetlistItemRepository eventSetlistItems,
            DanceSuggestionRepository danceSuggestions) {
        this.songs = songs;
        this.links = links;
        this.danceSongs = danceSongs;
        this.mapper = mapper;
        this.users = users;
        this.songMergeHistory = songMergeHistory;
        this.eventSetlistItems = eventSetlistItems;
        this.danceSuggestions = danceSuggestions;
    }

    /**
     * Cerca cançons per títol o artista (case-insensitive) amb suport de
     * paginació.
     *
     * @param query Text a cercar (o null per obtenir totes)
     * @param pageable Configuració de pàgina i ordenació
     * @return Pàgina de cançons que coincideixen amb la cerca
     */
    public Page<Song> search(String query, Pageable pageable) {
        if (query == null || query.isBlank()) {
            return songs.findAll(pageable);
        }
        return songs.search(query.trim(), pageable);
    }

    /**
     * Cerca avançada de cançons amb múltiples filtres. Només per ús
     * administratiu.
     *
     * @param q Cerca per títol/artista (opcional)
     * @param yearFrom Any mínim (opcional)
     * @param yearTo Any màxim (opcional)
     * @param bpmFrom BPM mínim (opcional)
     * @param bpmTo BPM màxim (opcional)
     * @param genres Llista de gèneres a filtrar (OR) (opcional)
     * @param hasPreview Filtrar per si té preview (opcional)
     * @param pageable Configuració de pàgina i ordenació
     * @return Pàgina de cançons que compleixen els filtres
     */
    public Page<Song> advancedSearch(
            String q,
            Long id,
            Integer yearFrom,
            Integer yearTo,
            Integer bpmFrom,
            Integer bpmTo,
            List<String> genres,
            Boolean hasPreview,
            Pageable pageable
    ) {
        Specification<Song> spec = Specification.where(SongSpecs.idEquals(id))
                .and(SongSpecs.titleOrArtistContains(q))
                .and(SongSpecs.yearFrom(yearFrom))
                .and(SongSpecs.yearTo(yearTo))
                .and(SongSpecs.bpmFrom(bpmFrom))
                .and(SongSpecs.bpmTo(bpmTo))
                .and(SongSpecs.genresContainsAny(genres))
                .and(SongSpecs.hasPreview(hasPreview));

        return songs.findAll(spec, pageable);
    }

    /**
     * Obté una cançó pel seu ID.
     *
     * @param id ID de la cançó
     * @return Optional amb la cançó si existeix
     */
    public Optional<Song> get(Long id) {
        return songs.findById(id);
    }

    /**
     * Busca una cançó pel seu ISRC.
     * ISRC és l'identificador únic internacional per a enregistraments musicals.
     *
     * @param isrc Codi ISRC
     * @return Optional amb la cançó si existeix
     */
    public Optional<Song> findByIsrc(String isrc) {
        if (isrc == null || isrc.isBlank()) {
            return Optional.empty();
        }
        return songs.findByIsrc(isrc);
    }

    /**
     * Busca una cançó per títol i artista (case-insensitive).
     *
     * @param title Títol de la cançó
     * @param artist Artista
     * @return Optional amb la cançó si existeix
     */
    public Optional<Song> findByTitleAndArtist(String title, String artist) {
        if (title == null || title.isBlank() || artist == null || artist.isBlank()) {
            return Optional.empty();
        }
        return songs.findByTitleAndArtistIgnoreCase(title, artist);
    }

    /**
     * Cerca fuzzy per títol i artista usant pg_trgm similarity.
     * Útil quan ACRCloud retorna noms lleugerament diferents dels de la BD.
     */
    public Optional<Song> findByTitleAndArtistFuzzy(String title, String artist) {
        if (title == null || title.isBlank() || artist == null || artist.isBlank()) {
            return Optional.empty();
        }
        return songs.findByTitleAndArtistFuzzy(title, artist);
    }

    /**
     * Obté tots els enllaços d'una cançó específica. Utilitza query method
     * optimitzat per evitar N+1.
     *
     * @param id ID de la cançó
     * @return Llista d'enllaços de la cançó
     */
    public List<Link> linksForSong(Long id) {
        return links.findBySong_Id(id);
    }

    /**
     * Obté els enllaços de múltiples cançons en una sola query (batch). Evita
     * N+1 queries quan es carrega una pàgina de cançons.
     *
     * @param songIds Llista d'IDs de cançons
     * @return Mapa de songId -> llista d'enllaços
     */
    public java.util.Map<Long, List<Link>> linksForSongs(List<Long> songIds) {
        if (songIds == null || songIds.isEmpty()) {
            return java.util.Map.of();
        }
        return links.findBySong_IdIn(songIds).stream()
                .collect(Collectors.groupingBy(link -> link.getSong().getId()));
    }

    /**
     * Guarda o actualitza una cançó.
     *
     * @param song Entitat Song a guardar
     * @return Cançó guardada amb ID generat
     */
    @Transactional
    public Song save(Song song) {
        return songs.save(song);
    }

    /**
     * Actualitza una cançó i retorna SongDetailDto amb totes les relacions
     * carregades. Implementa el patró "Hydrate + Map" per evitar
     * LazyInitializationException: 1. Carrega entitat amb owner per verificar
     * permisos 2. Aplica canvis 3. Guarda 4. Rehidrata amb findDetailById
     * (fetch joins) 5. Mapeja a DTO dins la transacció
     *
     * @param id ID de la cançó a actualitzar
     * @param req Request amb les noves dades
     * @param requesterEmail Email de l'usuari que fa la petició
     * @return SongDetailDto amb totes les relacions
     * @throws NotFoundException si la cançó no existeix
     * @throws AccessDeniedException si l'usuari no té permisos
     */
    @Transactional
    public SongDetailDto updateAndReturnDetailDto(Long id, CreateSongRequest req, String requesterEmail) {
        // 1. Carrega amb owner per verificar permisos
        Song existing = songs.findByIdWithOwner(id)
                .orElseThrow(() -> new NotFoundException("Song not found: " + id));

        // 2. Verificar permisos
        User requester = users.findByEmailIgnoreCase(requesterEmail)
                .orElseThrow(() -> new NotFoundException("User not found: " + requesterEmail));
        boolean isOwner = existing.getOwner() != null
                && existing.getOwner().getId() != null
                && existing.getOwner().getId().equals(requester.getId());
        boolean isAdmin = requester.getRole() != null
                && requester.getRole().toString().equals("ADMIN");
        if (!isOwner && !isAdmin) {
            throw new AccessDeniedException("No tens permisos per editar aquesta cançó");
        }

        // 3. Aplica canvis
        existing.setTitle(req.title());
        existing.setArtist(req.artist());
        existing.setYear(req.year());
        existing.setAlbum(req.album());
        existing.setIsrc(req.isrc());
        existing.setDurationSeconds(req.durationSeconds());
        existing.setBpm(req.bpm());
        existing.setIntensity(req.intensity());
        existing.setSpotifyId(req.spotifyId());
        existing.setSpotifyEnergy(req.spotifyEnergy() != null ? BigDecimal.valueOf(req.spotifyEnergy()) : null);
        existing.setPreviewUrl(req.previewUrl());
        existing.setAlbumArtUrl(req.albumArtUrl());
        existing.setGenres(sanitizeGenres(req.genres()));
        existing.setUpdatedAt(Instant.now());

        // 4. Guarda
        songs.save(existing);

        // 5. Actualitza links si s'han proporcionat (replace-all strategy)
        if (req.links() != null) {
            replaceLinksForSong(existing, req.links());
        }

        // 6. Rehidrata amb fetch joins per evitar LazyInitializationException
        Song hydrated = songs.findDetailById(id)
                .orElseThrow(() -> new NotFoundException("Song not found after save: " + id));

        // 7. Carrega links i mapeja a DTO dins la transacció
        List<Link> songLinks = links.findBySong_Id(id);
        return mapper.toDetailDto(hydrated, songLinks);
    }

    /**
     * Reemplaça els user links d'una cançó amb la llista proporcionada.
     * GUARDRAIL: Rebutja si el payload conté streaming links (SPOTIFY/APPLE).
     * Preserva els streaming links existents de la cançó. Deduplica per (kind,
     * url) dins la mateixa cançó.
     *
     * @param song Cançó a la qual associar els links
     * @param linkRequests Llista de links a guardar (només user links permesos)
     * @throws BadRequestException si linkRequests conté streaming links
     */
    private void replaceLinksForSong(Song song, List<LinkRequest> linkRequests) {
        // GUARDRAIL: Validar que no hi ha streaming links al payload
        validateNoStreamingLinksInPayload(linkRequests);

        // Obtenir links existents
        List<Link> existingLinks = links.findBySong_Id(song.getId());

        // Separar streaming links (a preservar) dels user links (a eliminar)
        List<Link> streamingLinksToPreserve = existingLinks.stream()
                .filter(link -> LinkKindConstants.isStreamingLinkKind(link.getKind()))
                .collect(Collectors.toList());

        List<Link> userLinksToDelete = existingLinks.stream()
                .filter(link -> !LinkKindConstants.isStreamingLinkKind(link.getKind()))
                .collect(Collectors.toList());

        // Elimina només els user links existents
        links.deleteAll(userLinksToDelete);
        links.flush(); // Force delete execution before inserting new links

        if (linkRequests == null || linkRequests.isEmpty()) {
            return;
        }

        // Deduplica per (kind, url) - inclou streaming links preservats per evitar duplicats
        Set<String> seen = new HashSet<>();
        for (Link preserved : streamingLinksToPreserve) {
            seen.add(preserved.getKind().name() + "::" + preserved.getUrl());
        }

        for (LinkRequest req : linkRequests) {
            if (req == null || req.kind() == null || req.url() == null) {
                continue;
            }

            LinkKind kind;
            try {
                kind = LinkKind.valueOf(req.kind().toUpperCase());
            } catch (IllegalArgumentException e) {
                // Ignora links amb kind invàlid
                continue;
            }

            String dedupKey = kind.name() + "::" + req.url().trim();
            if (seen.contains(dedupKey)) {
                continue; // Skip duplicat
            }
            seen.add(dedupKey);

            Link link = Link.builder()
                    .kind(kind)
                    .url(req.url().trim())
                    .title(req.title() != null ? req.title().trim() : null)
                    .language(req.language() != null ? req.language().trim() : null)
                    .song(song)
                    .build();
            links.save(link);
        }
    }

    /**
     * Valida que no hi ha streaming links (SPOTIFY, APPLE) al payload.
     *
     * @param linkRequests Llista de links a validar
     * @throws BadRequestException si es detecten streaming links
     */
    private void validateNoStreamingLinksInPayload(List<LinkRequest> linkRequests) {
        if (linkRequests == null || linkRequests.isEmpty()) {
            return;
        }

        for (LinkRequest req : linkRequests) {
            if (req == null || req.kind() == null) {
                continue;
            }
            try {
                LinkKind kind = LinkKind.valueOf(req.kind().toUpperCase());
                if (LinkKindConstants.isStreamingLinkKind(kind)) {
                    throw new BadRequestException(
                            "Streaming links (SPOTIFY, APPLE) cannot be set via this endpoint. "
                            + "They are managed automatically by music import."
                    );
                }
            } catch (IllegalArgumentException e) {
                // Ignora kinds invàlids - es filtraran després
            }
        }
    }

    /**
     * Pre-flight summary for hard-deleting a song. Returns blockers (any dance
     * using it, song-merge survivor) and a footprint of cascade/SET-NULL
     * effects so the admin can decide.
     */
    public DeleteSongPreviewDto previewDelete(Long id) {
        Song song = songs.findById(id)
                .orElseThrow(() -> new NotFoundException("Song not found: " + id));

        java.util.List<DeleteDancePreviewDto.DeleteBlocker> blockers = new java.util.ArrayList<>();

        long dancesUsingSong = danceSongs.countBySongId(id);
        if (dancesUsingSong > 0) {
            blockers.add(new DeleteDancePreviewDto.DeleteBlocker(
                    "DANCE_USING_SONG",
                    "La cançó està en ús per " + dancesUsingSong + " ball(s).",
                    java.util.List.of()
            ));
        }

        long survivorMergeCount = songMergeHistory.countBySurvivorId(id);
        if (survivorMergeCount > 0) {
            blockers.add(new DeleteDancePreviewDto.DeleteBlocker(
                    "MERGE_SURVIVOR",
                    "Aquesta cançó és el survivor de " + survivorMergeCount
                            + " fusió(ns); eliminar-la trencaria l'historial.",
                    java.util.List.of()
            ));
        }

        DeleteSongPreviewDto.CascadeSummary summary = new DeleteSongPreviewDto.CascadeSummary(
                links.countBySong_Id(id),
                eventSetlistItems.countBySongId(id),
                danceSuggestions.countBySong_Id(id)
        );

        return new DeleteSongPreviewDto(
                song.getId(), song.getTitle(), song.getArtist(), blockers, summary);
    }

    /**
     * Hard-delete a song with full safeguards. Mirror of
     * {@code AdminDanceService.deleteDance}.
     * <ol>
     *   <li>Refuses if any blocker is present (dance using the song, merge survivor).</li>
     *   <li>Requires the admin to type the exact "Title — Artist" to confirm.</li>
     * </ol>
     * Cascade behaviour relies on DB constraints
     * (V1: links CASCADE, V48: dance_song NO ACTION, V77: event_setlist_items SET NULL,
     *  V86: dance_suggestion CASCADE).
     */
    @Transactional
    public void delete(Long id, String confirmationName, User admin) {
        Song song = songs.findById(id)
                .orElseThrow(() -> new NotFoundException("Song not found: " + id));

        String expected = song.getTitle() + " — " + song.getArtist();
        if (confirmationName == null || !confirmationName.trim().equals(expected)) {
            throw new ClaimException(
                    "El text escrit per confirmar no coincideix amb «" + expected + "».",
                    ErrorCode.SONG_DELETE_CONFIRMATION_MISMATCH);
        }

        DeleteSongPreviewDto preview = previewDelete(id);
        if (!preview.canDelete()) {
            String reasons = preview.blockers().stream()
                    .map(DeleteDancePreviewDto.DeleteBlocker::message)
                    .collect(java.util.stream.Collectors.joining(" "));
            throw new ClaimException(
                    "No es pot eliminar la cançó: " + reasons,
                    ErrorCode.SONG_IN_USE);
        }

        log.info("ADMIN_DELETE_SONG id={} title='{}' artist='{}' by admin={} cascadeSummary={}",
                id, song.getTitle(), song.getArtist(),
                admin == null ? "?" : admin.getEmail(), preview.willCascade());

        songs.delete(song);
    }

    /**
     * Sanititza la llista de gèneres eliminant nulls, espais i duplicats.
     */
    private String[] sanitizeGenres(List<String> genres) {
        if (genres == null) {
            return null;
        }
        List<String> cleaned = genres.stream()
                .filter(Objects::nonNull)
                .map(String::trim)
                .filter(str -> !str.isEmpty())
                .distinct()
                .limit(20)
                .toList();
        return cleaned.isEmpty() ? null : cleaned.toArray(String[]::new);
    }

    /**
     * Excepció per indicar que una cançó no es pot eliminar perquè està en ús.
     */
    public static class SongInUseException extends RuntimeException {

        public SongInUseException(String message) {
            super(message);
        }
    }
}
