package com.example.ldp.service;

import java.time.Instant;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;

import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import com.example.ldp.api.dto.BulkListingStatusResultDto;
import com.example.ldp.api.dto.DanceAdminRowDto;
import com.example.ldp.api.dto.DanceAdminRowDto.ChoreographerSummary;
import com.example.ldp.api.dto.DanceAdminRowDto.PrimarySongSummary;
import com.example.ldp.api.dto.DeleteDancePreviewDto;
import com.example.ldp.api.dto.PageResponse;
import com.example.ldp.config.ErrorCode;
import com.example.ldp.domain.Choreographer;
import com.example.ldp.domain.Dance;
import com.example.ldp.domain.DanceListingStatus;
import com.example.ldp.domain.DanceSong;
import com.example.ldp.domain.Link;
import com.example.ldp.domain.Song;
import com.example.ldp.domain.enums.DanceSongRole;
import com.example.ldp.domain.enums.TargetType;
import com.example.ldp.repo.ChannelVideoRepository;
import com.example.ldp.repo.CurriculumItemRepository;
import com.example.ldp.repo.DanceMergeHistoryRepository;
import com.example.ldp.repo.DanceRepository;
import com.example.ldp.repo.DanceSongRepository;
import com.example.ldp.repo.DanceSuggestionRepository;
import com.example.ldp.repo.EventSetlistItemRepository;
import com.example.ldp.repo.LineDanceSpecRepository;
import com.example.ldp.repo.LinkRepository;
import com.example.ldp.repo.SongRepository;
import com.example.ldp.repo.spec.DanceSpecs;
import com.example.ldp.service.exception.BadRequestException;
import com.example.ldp.service.exception.ClaimException;
import com.example.ldp.service.exception.ConflictException;
import com.example.ldp.service.exception.NotFoundException;
import com.example.ldp.user.User;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

/**
 * Service for admin-only dance operations.
 */
@Service
@RequiredArgsConstructor
@Slf4j
@Transactional(readOnly = true)
public class AdminDanceService {

    private final DanceRepository danceRepository;
    private final LinkRepository linkRepository;
    private final LineDanceSpecRepository lineDanceSpecRepository;
    private final DanceSongRepository danceSongRepository;
    private final SongRepository songRepository;
    private final EventSetlistItemRepository eventSetlistItemRepository;
    private final DanceMergeHistoryRepository danceMergeHistoryRepository;
    private final DanceSuggestionRepository danceSuggestionRepository;
    private final ChannelVideoRepository channelVideoRepository;
    private final CurriculumItemRepository curriculumItemRepository;
    private final LineDanceSpecService lineDanceSpecService;
    private final List<UserMarkMergeTransferer> markTransferers;

    /**
     * Whitelist de camps ordenables (nom públic → propietat JPA). Bloqueja
     * Sort per propietats arbitràries (PropertyReferenceException) i
     * documenta el contracte amb el frontend.
     */
    private static final Map<String, String> SORTABLE_FIELDS = Map.of(
            "id", "id",
            "name", "name",
            "level", "level.code",
            "counts", "counts",
            "walls", "walls",
            "originYear", "originYear"
    );
    private static final String DEFAULT_SORT = "name";

    /**
     * List all dances with optional filtering and pagination for admin
     * management. Fetches all related data efficiently to avoid N+1.
     *
     * @param page 0-indexed page number
     * @param size page size (already capped by controller)
     * @return PageResponse with dances and pagination metadata
     */
    public PageResponse<DanceAdminRowDto> listDances(
            String q,
            Long id,
            String level,
            Integer counts,
            Integer walls,
            String danceType,
            Integer yearFrom,
            Integer yearTo,
            Boolean hasPrimarySong,
            String listingStatus,
            Boolean hasSpec,
            Boolean hasPrimaryVideo,
            Boolean unknownYear,
            Boolean unknownCounts,
            Boolean hasChoreographer,
            String choreographerCountry,
            String sortBy,
            String sortDir,
            int page,
            int size
    ) {
        // 1. Compose filters as a JPA Specification — DB does the work.
        Specification<Dance> spec = Specification
                .where(DanceSpecs.idEquals(id))
                .and(DanceSpecs.nameOrPrimarySongContains(q))
                .and(DanceSpecs.levelEquals(level))
                .and(DanceSpecs.countsEquals(counts))
                .and(DanceSpecs.wallsEquals(walls))
                .and(DanceSpecs.danceTypeEquals(danceType))
                .and(DanceSpecs.yearBetween(yearFrom, yearTo))
                .and(DanceSpecs.listingStatusEquals(listingStatus))
                .and(DanceSpecs.unknownYear(unknownYear))
                .and(DanceSpecs.unknownCounts(unknownCounts))
                .and(DanceSpecs.hasPrimarySong(hasPrimarySong))
                .and(DanceSpecs.hasSpec(hasSpec))
                .and(DanceSpecs.hasPrimaryVideo(hasPrimaryVideo))
                .and(DanceSpecs.hasChoreographer(hasChoreographer))
                .and(DanceSpecs.choreographerCountryContains(choreographerCountry));

        // 2. Build pageable with whitelisted Sort. ORDER BY + LIMIT pushed to the DB.
        Pageable pageable = PageRequest.of(page, size, buildSort(sortBy, sortDir));
        Page<Dance> idPage = danceRepository.findAll(spec, pageable);

        List<Long> pagedIds = idPage.getContent().stream().map(Dance::getId).toList();
        if (pagedIds.isEmpty()) {
            return new PageResponse<>(List.of(), page, size, idPage.getTotalElements(), idPage.getTotalPages());
        }

        // 3. Batch-load associations for the page only — no N+1, no JOIN FETCH+Pageable trap.
        Map<Long, Dance> byId = danceRepository.findWithDetailsByIdIn(pagedIds).stream()
                .collect(Collectors.toMap(Dance::getId, d -> d));
        List<Dance> ordered = pagedIds.stream().map(byId::get).filter(Objects::nonNull).toList();

        // 4. Reuse existing batch-load helpers, scoped to the page.
        Set<Long> danceIdsWithSpec = new HashSet<>(lineDanceSpecRepository.findDanceIdsByDanceIdIn(pagedIds));
        Map<Long, String> primaryVideoIdByDanceId = linkRepository.findPrimaryByDanceIds(pagedIds).stream()
                .collect(Collectors.toMap(
                        link -> link.getDance().getId(),
                        Link::getVideoId,
                        (v1, v2) -> v1
                ));

        List<DanceAdminRowDto> content = ordered.stream()
                .map(d -> toAdminRowDto(d, primaryVideoIdByDanceId, danceIdsWithSpec))
                .toList();

        return new PageResponse<>(content, page, size, idPage.getTotalElements(), idPage.getTotalPages());
    }

    /**
     * Construeix un Sort validat contra la whitelist {@link #SORTABLE_FIELDS}.
     * Camps numèrics nullables (counts, walls, originYear) van amb NULLS LAST
     * per preservar la semàntica del comparator anterior; afegim un tie-breaker
     * estable per ID per evitar salts de pàgina entre requests.
     */
    private Sort buildSort(String sortBy, String sortDir) {
        String property = SORTABLE_FIELDS.getOrDefault(sortBy, SORTABLE_FIELDS.get(DEFAULT_SORT));
        Sort.Direction direction = "desc".equalsIgnoreCase(sortDir) ? Sort.Direction.DESC : Sort.Direction.ASC;
        Sort.Order primary = new Sort.Order(direction, property).nullsLast();
        if ("id".equals(property)) {
            return Sort.by(primary);
        }
        return Sort.by(primary).and(Sort.by(Sort.Direction.ASC, "id"));
    }

    /**
     * Canvia l'estat de publicació d'un ball. Transicions vàlides:
     * DRAFT→REVIEW→PUBLISHED, PUBLISHED→ARCHIVED, qualsevol→DRAFT
     */
    @Transactional
    public void updateListingStatus(Long danceId, String newStatusStr) {
        DanceListingStatus newStatus = parseListingStatus(newStatusStr);
        Dance dance = danceRepository.findById(danceId)
                .orElseThrow(() -> new NotFoundException("Dance not found: " + danceId));
        applyListingStatus(dance, newStatus);
    }

    /**
     * T-409: canvi massiu d'estat de publicació (admin balls, cas d'ús real:
     * filtrar per DRAFT i publicar-los tots de cop).
     *
     * <p>Validació PER FILA dins d'UNA sola transacció: un id inexistent es
     * recull a {@code failures} i la resta continua — no és un tot-o-res, ni
     * un bucle de crides HTTP des del client. L'estat destí es valida un únic
     * cop (és comú a tot el lot); si és invàlid, tota la petició falla amb
     * {@link BadRequestException} abans de tocar cap fila.
     */
    @Transactional
    public BulkListingStatusResultDto bulkUpdateListingStatus(List<Long> ids, String newStatusStr) {
        DanceListingStatus newStatus = parseListingStatus(newStatusStr);

        List<Long> updated = new ArrayList<>();
        List<BulkListingStatusResultDto.Failure> failures = new ArrayList<>();

        for (Long id : ids) {
            try {
                Dance dance = danceRepository.findById(id)
                        .orElseThrow(() -> new NotFoundException("Dance not found: " + id));
                applyListingStatus(dance, newStatus);
                updated.add(id);
            } catch (NotFoundException e) {
                failures.add(new BulkListingStatusResultDto.Failure(id, "No trobat"));
            }
        }

        return new BulkListingStatusResultDto(updated, failures);
    }

    private DanceListingStatus parseListingStatus(String newStatusStr) {
        if (newStatusStr == null || newStatusStr.isBlank()) {
            throw new BadRequestException("listingStatus is required");
        }
        try {
            return DanceListingStatus.valueOf(newStatusStr.trim().toUpperCase());
        } catch (IllegalArgumentException e) {
            throw new BadRequestException(
                    "Invalid listing status: " + newStatusStr
                    + ". Valid values: DRAFT, REVIEW, PUBLISHED, ARCHIVED");
        }
    }

    private void applyListingStatus(Dance dance, DanceListingStatus newStatus) {
        // Registra la PRIMERA publicació; es preserva en re-publicacions posteriors.
        if (newStatus == DanceListingStatus.PUBLISHED && dance.getPublishedAt() == null) {
            dance.setPublishedAt(Instant.now());
        }
        dance.setListingStatus(newStatus);
        danceRepository.save(dance);
    }

    private DanceAdminRowDto toAdminRowDto(Dance d, Map<Long, String> primaryVideoIdByDanceId, Set<Long> danceIdsWithSpec) {
        // Find PRIMARY song
        PrimarySongSummary primarySong = d.getDanceSongs().stream()
                .filter(ds -> ds.getRole() == DanceSongRole.PRIMARY)
                .findFirst()
                .map(DanceSong::getSong)
                .map(this::toPrimarySongSummary)
                .orElse(null);

        // Map choreographers
        List<ChoreographerSummary> choreographers = d.getChoreographers().stream()
                .map(this::toChoreographerSummary)
                .collect(Collectors.toList());

        // Get PRIMARY video link from preloaded map (avoids N+1)
        String primaryVideoId = primaryVideoIdByDanceId.get(d.getId());

        boolean hasSpec = danceIdsWithSpec.contains(d.getId());
        boolean hasPrimaryChoreographer = !choreographers.isEmpty();

        return new DanceAdminRowDto(
                d.getId(),
                d.getName(),
                d.getLevel() != null ? d.getLevel().getCode() : null,
                d.getCounts(),
                d.getWalls(),
                d.getDanceType() != null ? d.getDanceType().name() : null,
                d.getOriginYear(),
                primarySong,
                choreographers,
                primaryVideoId,
                d.getListingStatus() != null ? d.getListingStatus().name() : null,
                hasSpec,
                hasPrimaryChoreographer,
                d.isOriginYearIsEstimated()
        );
    }

    private PrimarySongSummary toPrimarySongSummary(Song s) {
        return new PrimarySongSummary(
                s.getId(),
                s.getTitle(),
                s.getArtist(),
                s.getBpm(),
                s.getYear()
        );
    }

    private ChoreographerSummary toChoreographerSummary(Choreographer c) {
        return new ChoreographerSummary(
                c.getId(),
                c.getName(),
                c.getCountry()
        );
    }

    /**
     * Links a song to a dance as PRIMARY. Fails if the dance already has a
     * PRIMARY song (use the full dance edit to replace it).
     */
    @Transactional
    public void linkSongToDance(Long danceId, Long songId) {
        Dance dance = danceRepository.findById(danceId)
                .orElseThrow(() -> new NotFoundException("Dance not found: " + danceId));

        Song song = songRepository.findById(songId)
                .orElseThrow(() -> new NotFoundException("Song not found: " + songId));

        boolean alreadyHasPrimary = dance.getDanceSongs().stream()
                .anyMatch(ds -> ds.getRole() == DanceSongRole.PRIMARY);
        if (alreadyHasPrimary) {
            throw new ConflictException("Dance already has a PRIMARY song. Use the edit page to replace it.");
        }

        if (danceSongRepository.existsByDanceIdAndSongId(danceId, songId)) {
            throw new ConflictException("This song is already linked to this dance.");
        }

        DanceSong danceSong = DanceSong.builder()
                .dance(dance)
                .song(song)
                .role(DanceSongRole.PRIMARY)
                .sortOrder(1)
                .build();
        danceSongRepository.save(danceSong);
    }

    /**
     * Get dances by choreographer ID with pagination. Used for the public
     * choreographer detail page.
     *
     * @param choreographerId choreographer ID
     * @param page 0-indexed page number
     * @param size page size
     * @return PageResponse with dances for this choreographer
     */
    public PageResponse<DanceAdminRowDto> getDancesByChoreographer(Long choreographerId, int page, int size) {
        List<Dance> allDances = danceRepository.findByChoreographerId(choreographerId);

        // Calculate pagination metadata
        long totalElements = allDances.size();
        int totalPages = (int) Math.ceil((double) totalElements / size);

        // Apply pagination manually
        int fromIndex = page * size;
        int toIndex = Math.min(fromIndex + size, allDances.size());

        List<Dance> pagedDances = fromIndex >= allDances.size()
                ? List.of()
                : allDances.subList(fromIndex, toIndex);

        // Preload PRIMARY links only for paged dances
        List<Long> danceIds = pagedDances.stream().map(Dance::getId).collect(Collectors.toList());
        Map<Long, String> primaryVideoIdByDanceId = danceIds.isEmpty() ? Map.of()
                : linkRepository.findPrimaryByDanceIds(danceIds).stream()
                        .collect(Collectors.toMap(
                                link -> link.getDance().getId(),
                                Link::getVideoId,
                                (v1, v2) -> v1
                        ));

        Set<Long> choreoDanceIdsWithSpec = new HashSet<>(lineDanceSpecRepository.findAllDanceIds());
        List<DanceAdminRowDto> content = pagedDances.stream()
                .map(d -> toAdminRowDto(d, primaryVideoIdByDanceId, choreoDanceIdsWithSpec))
                .collect(Collectors.toList());

        return new PageResponse<>(content, page, size, totalElements, totalPages);
    }

    // ==================== DELETE DANCE ====================

    /**
     * Pre-flight summary for hard-deleting a dance. Returns the cascade
     * footprint and any blockers (events using the dance, merge history rows
     * where this dance is the survivor). The admin can use this to decide
     * whether to delete or archive.
     */
    public DeleteDancePreviewDto previewDelete(Long id) {
        Dance dance = danceRepository.findById(id)
                .orElseThrow(() -> new NotFoundException("Dance not found: " + id));

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

        List<Long> blockingEventIds = eventSetlistItemRepository.findDistinctEventIdsByDanceId(id);
        if (!blockingEventIds.isEmpty()) {
            blockers.add(new DeleteDancePreviewDto.DeleteBlocker(
                    "EVENT_SETLIST",
                    "El ball apareix al setlist de " + blockingEventIds.size() + " event(s).",
                    blockingEventIds
            ));
        }

        // T-487: el blocador que faltava. curriculum_items.dance_id és RESTRICT (V144) i
        // el CHECK chk_curriculum_item_type_fk exigeix que no sigui NULL quan l'ítem és de
        // tipus DANCE: sense això, esborrar un ball que un professor tingui al temari
        // petava amb un error de constraint en brut. Aquí no es reubica res (a diferència
        // de la fusió) perquè no hi ha survivor on portar-ho: la resposta correcta és
        // dir-li a l'admin qui el bloqueja.
        List<Long> blockingCourseIds = curriculumItemRepository.findDistinctCourseIdsByDanceId(id);
        if (!blockingCourseIds.isEmpty()) {
            blockers.add(new DeleteDancePreviewDto.DeleteBlocker(
                    "COURSE_CURRICULUM",
                    "El ball és al temari de " + blockingCourseIds.size() + " classe(s).",
                    blockingCourseIds
            ));
        }

        long survivorMergeCount = danceMergeHistoryRepository.countBySurvivorId(id);
        if (survivorMergeCount > 0) {
            blockers.add(new DeleteDancePreviewDto.DeleteBlocker(
                    "MERGE_SURVIVOR",
                    "Aquest ball és el survivor de " + survivorMergeCount
                            + " fusió(ns); eliminar-lo trencaria l'historial.",
                    List.of()
            ));
        }

        DeleteDancePreviewDto.CascadeSummary summary = new DeleteDancePreviewDto.CascadeSummary(
                danceSongRepository.countByDanceId(id),
                dance.getChoreographers() == null ? 0L : dance.getChoreographers().size(),
                linkRepository.countByDance_Id(id),
                lineDanceSpecRepository.existsByDanceId(id),
                channelVideoRepository.countByDance_Id(id),
                danceSuggestionRepository.countByCreatedDance_Id(id),
                0L, // merge history rows where dance is the loser → SET NULL; not currently counted (low value, optional)
                linkRepository.countOrphanLinksAfterDanceDelete(id)
        );

        return new DeleteDancePreviewDto(id, dance.getName(), blockers, summary);
    }

    /**
     * Hard-delete a dance with full safeguards.
     * <ol>
     *   <li>Refuses if any blocker is present (events using it, merge survivor).</li>
     *   <li>Requires the admin to type the exact dance name to confirm.</li>
     *   <li>Deletes any user marks pointing at this dance (T-482: no survivor
     *       to retarget them to, unlike a merge — {@code user_interest} has no
     *       physical FK so DB-level CASCADE can't do this for us).</li>
     *   <li>Deletes the line dance spec EXPLICITLY beforehand, in the order V46's
     *       RESTRICT diamond demands (T-507/F2) — leaving it to the DB cascade fails
     *       with {@code SQLState 23503} on {@code line_dance_sequence_items_component_id_fkey}
     *       when the spec has a real sequence, exactly like the two merge-time cases
     *       T-492 already fixed.</li>
     *   <li>Cleans up orphan links (decision D: links that lose their only
     *       attachment when dance_song rows are CASCADE-deleted).</li>
     * </ol>
     * The rest of the delete relies on DB-level CASCADE / SET NULL constraints
     * (V1, V71, V72, V75, V77, V86).
     */
    @Transactional
    public void deleteDance(Long id, String confirmationName, User admin) {
        Dance dance = danceRepository.findById(id)
                .orElseThrow(() -> new NotFoundException("Dance not found: " + id));

        if (confirmationName == null || !confirmationName.trim().equals(dance.getName())) {
            throw new ClaimException(
                    "El nom escrit per confirmar no coincideix amb el nom del ball.",
                    ErrorCode.DANCE_DELETE_CONFIRMATION_MISMATCH);
        }

        DeleteDancePreviewDto preview = previewDelete(id);
        if (!preview.canDelete()) {
            String reasons = preview.blockers().stream()
                    .map(DeleteDancePreviewDto.DeleteBlocker::message)
                    .collect(Collectors.joining(" "));
            throw new ClaimException(
                    "No es pot eliminar el ball: " + reasons,
                    ErrorCode.DANCE_IN_USE);
        }

        // Decision D: pre-collect orphan link IDs before CASCADE clears
        // their dance_song_id (which would make the predicate moot).
        List<Long> orphanLinkIds = linkRepository.findOrphanLinkIdsAfterDanceDelete(id);

        log.info("ADMIN_DELETE_DANCE id={} name='{}' by adminUserId={} cascadeSummary={} orphanLinks={}",
                id, dance.getName(), admin.getId(), preview.willCascade(), orphanLinkIds.size());

        for (UserMarkMergeTransferer transferer : markTransferers) {
            transferer.deleteMarks(TargetType.DANCE, List.of(id));
        }

        // T-507/F2: el temari de passos, si en té, s'esborra EXPLÍCITAMENT — deixar-ho a la
        // cascada de la BD peta amb el RESTRICT de V46 quan la spec té una seqüència de veritat.
        lineDanceSpecService.deleteSpecIfExists(id);

        danceRepository.delete(dance);
        danceRepository.flush(); // force CASCADE/SET NULL before orphan cleanup

        if (!orphanLinkIds.isEmpty()) {
            linkRepository.deleteAllById(orphanLinkIds);
        }
    }
}
