package com.pandoli365.bibimbap.jam;

import com.pandoli365.bibimbap.data.JamAwardData;
import com.pandoli365.bibimbap.mapper.JamAwardsMapper;
import com.pandoli365.bibimbap.mapper.JamReviewRatingMapper;
import com.pandoli365.bibimbap.mapper.JamScoreStatsMapper;
import com.pandoli365.bibimbap.mapper.JamVotesMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import java.math.BigDecimal;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

/**
 * W2-? 시상 산정 코어 단위 테스트 (plain Mockito, MockitoExtension — SpringBootTest 미사용).
 *
 * <p>4개 소스 매퍼를 모킹하고 {@link JamAwardService#recompute(long)} 가 jam_awards 에
 * INSERT 하는 {@link JamAwardData} 를 ArgumentCaptor 로 포착해 트랙별 rank/gameId/scoreValue
 * 불변식을 검증한다. 산정 코어(AC-1~5), NULL/미달 제외(AC-6), 동점 competition rank(AC-5),
 * 멱등 선행 초기화(AC-8) 를 커버한다.
 *
 * <p>VP-1~VP-4 매핑은 메서드 주석 참조.
 */
@ExtendWith(MockitoExtension.class)
class JamAwardServiceTest {

    private static final long JAM_ID = 42L;

    @Mock
    private JamAwardsMapper jamAwardsMapper;

    @Mock
    private JamScoreStatsMapper jamScoreStatsMapper;

    @Mock
    private JamReviewRatingMapper jamReviewRatingMapper;

    @Mock
    private JamVotesMapper jamVotesMapper;

    // ==== VP-1: 산정 코어(AC-1~5) ====

    /**
     * VP-1/AC-1·5: 세 트랙을 raw 점수 DESC competition rank 로 산정. JUDGE 모집단
     * weightedTotal 4.5/3.0/4.5 (game 10/20/30) → game10·30 동점 rank1, game20 rank3.
     * USER_RATING·POPULAR 도 DESC rank 로 모킹·검증.
     */
    @Test
    void recompute_ranksThreeTracksByScoreDesc() {
        when(jamScoreStatsMapper.listStatsByJam(JAM_ID)).thenReturn(List.of(
                Map.of("gameId", 10L, "weightedTotal", 4.5),
                Map.of("gameId", 20L, "weightedTotal", 3.0),
                Map.of("gameId", 30L, "weightedTotal", 4.5)
        ));
        when(jamReviewRatingMapper.listAvgByJam(JAM_ID)).thenReturn(List.of(
                Map.of("gameId", 10L, "avgRating", 4.2),
                Map.of("gameId", 20L, "avgRating", 3.1)
        ));
        when(jamVotesMapper.listCountsByJam(JAM_ID)).thenReturn(List.of(
                Map.of("gameId", 10L, "voteCount", 5L),
                Map.of("gameId", 20L, "voteCount", 9L)
        ));

        Map<String, Integer> counts = service().recompute(JAM_ID);

        assertThat(counts).containsEntry("JUDGE", 3);
        assertThat(counts).containsEntry("USER_RATING", 2);
        assertThat(counts).containsEntry("POPULAR", 2);

        List<JamAwardData> judge = insertsForTrack(captureInserts(), "JUDGE");
        // 동점(4.5) game10·30 → rank1, game20(3.0) → rank3 (competition: 2 건너뜀).
        assertThat(rankOf(judge, 10L)).isEqualTo(1);
        assertThat(rankOf(judge, 30L)).isEqualTo(1);
        assertThat(rankOf(judge, 20L)).isEqualTo(3);

        // USER_RATING DESC: game10(4.2) rank1, game20(3.1) rank2.
        List<JamAwardData> rating = insertsForTrack(captureInserts(), "USER_RATING");
        assertThat(rankOf(rating, 10L)).isEqualTo(1);
        assertThat(rankOf(rating, 20L)).isEqualTo(2);

        // POPULAR DESC: game20(9) rank1, game10(5) rank2.
        List<JamAwardData> popular = insertsForTrack(captureInserts(), "POPULAR");
        assertThat(rankOf(popular, 20L)).isEqualTo(1);
        assertThat(rankOf(popular, 10L)).isEqualTo(2);
    }

    /**
     * VP-1/AC-2·3: 두 게임이 3트랙 모두 가용 → GRAND 는 가용트랙 rankScore 가중평균.
     * GRAND insert 의 rank 와 scoreValue([0,1] 범위) 검증.
     */
    @Test
    void recompute_grandIsWeightedRankScoreOfAvailableTracks() {
        when(jamScoreStatsMapper.listStatsByJam(JAM_ID)).thenReturn(List.of(
                Map.of("gameId", 10L, "weightedTotal", 4.5),
                Map.of("gameId", 20L, "weightedTotal", 3.0)
        ));
        when(jamReviewRatingMapper.listAvgByJam(JAM_ID)).thenReturn(List.of(
                Map.of("gameId", 10L, "avgRating", 4.2),
                Map.of("gameId", 20L, "avgRating", 3.1)
        ));
        when(jamVotesMapper.listCountsByJam(JAM_ID)).thenReturn(List.of(
                Map.of("gameId", 10L, "voteCount", 9L),
                Map.of("gameId", 20L, "voteCount", 5L)
        ));

        Map<String, Integer> counts = service().recompute(JAM_ID);
        assertThat(counts).containsEntry("GRAND", 2);

        List<JamAwardData> grand = insertsForTrack(captureInserts(), "GRAND");
        // game10 이 3트랙 모두 1위 → rankScore 1.0 가중평균 = 1.0 → GRAND rank1.
        assertThat(rankOf(grand, 10L)).isEqualTo(1);
        assertThat(rankOf(grand, 20L)).isEqualTo(2);
        for (JamAwardData a : grand) {
            assertThat(a.getScoreValue()).isNotNull();
            assertThat(a.getScoreValue().doubleValue()).isBetween(0.0, 1.0);
        }
        // 2게임 모집단에서 1위 rankScore=(2-1+1)/2=1.0.
        assertThat(scoreOf(grand, 10L)).isEqualByComparingTo(BigDecimal.valueOf(1.0));
    }

    /**
     * VP-1/AC-3: gameA 는 JUDGE 만, gameB 는 3트랙 가용. GRAND 모집단은 최소 1트랙
     * 가용 union → gameA 도 포함(분모=JUDGE weight 만). GRAND insert 에 gameA 존재 검증.
     */
    @Test
    void recompute_grandIncludesSingleTrackGame() {
        long gameA = 10L;
        long gameB = 20L;
        when(jamScoreStatsMapper.listStatsByJam(JAM_ID)).thenReturn(List.of(
                Map.of("gameId", gameA, "weightedTotal", 4.5),
                Map.of("gameId", gameB, "weightedTotal", 3.0)
        ));
        when(jamReviewRatingMapper.listAvgByJam(JAM_ID)).thenReturn(List.of(
                Map.of("gameId", gameB, "avgRating", 4.0)
        ));
        when(jamVotesMapper.listCountsByJam(JAM_ID)).thenReturn(List.of(
                Map.of("gameId", gameB, "voteCount", 7L)
        ));

        service().recompute(JAM_ID);

        List<JamAwardData> grand = insertsForTrack(captureInserts(), "GRAND");
        assertThat(grand).extracting(JamAwardData::getGameId)
                .contains(gameA, gameB);
    }

    // ==== VP-2: NULL/미달 제외(AC-6) ====

    /**
     * VP-2/AC-6: JUDGE 행 중 weightedTotal null 게임은 JUDGE 모집단에서 제외 →
     * 해당 gameId 로 JUDGE insert 0. (USER_RATING 임계는 매퍼가 처리 — 서비스 테스트는
     * 매퍼가 그 게임을 빼고 반환했다고 모킹 → USER_RATING insert 안 됨.)
     */
    @Test
    void recompute_excludesNullWeightedTotalFromJudge() {
        long scored = 10L;
        long unscored = 20L; // weightedTotal NULL → JUDGE 제외
        Map<String, Object> nullRow = new HashMap<>();
        nullRow.put("gameId", unscored);
        nullRow.put("weightedTotal", null);
        when(jamScoreStatsMapper.listStatsByJam(JAM_ID)).thenReturn(List.of(
                Map.of("gameId", scored, "weightedTotal", 4.5),
                nullRow
        ));
        // USER_RATING 매퍼가 임계 미달 게임(unscored)을 이미 필터해 scored 만 반환.
        when(jamReviewRatingMapper.listAvgByJam(JAM_ID)).thenReturn(List.of(
                Map.of("gameId", scored, "avgRating", 4.0)
        ));
        when(jamVotesMapper.listCountsByJam(JAM_ID)).thenReturn(List.of());

        Map<String, Integer> counts = service().recompute(JAM_ID);
        assertThat(counts).containsEntry("JUDGE", 1);

        List<JamAwardData> all = captureInserts();
        List<JamAwardData> judge = insertsForTrack(all, "JUDGE");
        assertThat(judge).extracting(JamAwardData::getGameId).containsExactly(scored);
        assertThat(judge).extracting(JamAwardData::getGameId).doesNotContain(unscored);

        // USER_RATING 에 임계 미달 게임(unscored) insert 없음.
        List<JamAwardData> rating = insertsForTrack(all, "USER_RATING");
        assertThat(rating).extracting(JamAwardData::getGameId).doesNotContain(unscored);
    }

    /**
     * VP-2: 모든 소스 빈 리스트 → 4트랙 insert 0, counts 전부 0, deleteByJamTrack 4회
     * 호출(멱등 초기화). insert 미발생.
     */
    @Test
    void recompute_emptySourcesProduceNoAwards() {
        when(jamScoreStatsMapper.listStatsByJam(JAM_ID)).thenReturn(List.of());
        when(jamReviewRatingMapper.listAvgByJam(JAM_ID)).thenReturn(List.of());
        when(jamVotesMapper.listCountsByJam(JAM_ID)).thenReturn(List.of());

        Map<String, Integer> counts = service().recompute(JAM_ID);

        assertThat(counts).containsEntry("JUDGE", 0);
        assertThat(counts).containsEntry("USER_RATING", 0);
        assertThat(counts).containsEntry("POPULAR", 0);
        assertThat(counts).containsEntry("GRAND", 0);

        verify(jamAwardsMapper, org.mockito.Mockito.never()).insert(org.mockito.ArgumentMatchers.any());
        verify(jamAwardsMapper).deleteByJamTrack(JAM_ID, "JUDGE");
        verify(jamAwardsMapper).deleteByJamTrack(JAM_ID, "USER_RATING");
        verify(jamAwardsMapper).deleteByJamTrack(JAM_ID, "POPULAR");
        verify(jamAwardsMapper).deleteByJamTrack(JAM_ID, "GRAND");
    }

    // ==== VP-3: 동점 competition rank(AC-5) ====

    /**
     * VP-3/AC-5: voteCount 동일 2게임 → POPULAR 같은 rank, 다음 순위 건너뜀(competition).
     * game_id ASC tiebreak 로 결정적. game10·20 동점(rank1), game30(낮음) rank3.
     */
    @Test
    void recompute_tiesGetSameRank() {
        when(jamScoreStatsMapper.listStatsByJam(JAM_ID)).thenReturn(List.of());
        when(jamReviewRatingMapper.listAvgByJam(JAM_ID)).thenReturn(List.of());
        when(jamVotesMapper.listCountsByJam(JAM_ID)).thenReturn(List.of(
                Map.of("gameId", 10L, "voteCount", 7L),
                Map.of("gameId", 20L, "voteCount", 7L),
                Map.of("gameId", 30L, "voteCount", 2L)
        ));

        service().recompute(JAM_ID);

        List<JamAwardData> popular = insertsForTrack(captureInserts(), "POPULAR");
        assertThat(rankOf(popular, 10L)).isEqualTo(1);
        assertThat(rankOf(popular, 20L)).isEqualTo(1);
        // competition: 2위 건너뛰고 3위.
        assertThat(rankOf(popular, 30L)).isEqualTo(3);
    }

    // ==== VP-4: 멱등 선행 초기화(AC-8) ====

    /**
     * VP-4/AC-8: recompute 1회에서 deleteByJamTrack 가 4트랙 전부 호출(멱등 선행 초기화).
     * delete 선행으로 재산정 누적 회피 — delete 호출 검증으로 대리.
     */
    @Test
    void recompute_deletesEachTrackBeforeInsert() {
        when(jamScoreStatsMapper.listStatsByJam(JAM_ID)).thenReturn(List.of(
                Map.of("gameId", 10L, "weightedTotal", 4.0)
        ));
        when(jamReviewRatingMapper.listAvgByJam(JAM_ID)).thenReturn(List.of());
        when(jamVotesMapper.listCountsByJam(JAM_ID)).thenReturn(List.of());

        service().recompute(JAM_ID);

        verify(jamAwardsMapper).deleteByJamTrack(JAM_ID, "JUDGE");
        verify(jamAwardsMapper).deleteByJamTrack(JAM_ID, "USER_RATING");
        verify(jamAwardsMapper).deleteByJamTrack(JAM_ID, "POPULAR");
        verify(jamAwardsMapper).deleteByJamTrack(JAM_ID, "GRAND");
    }

    // ==== helpers ====

    private JamAwardService service() {
        return new JamAwardService(jamAwardsMapper, jamScoreStatsMapper,
                jamReviewRatingMapper, jamVotesMapper);
    }

    /** jam_awards.insert 로 전달된 모든 JamAwardData 포착. */
    private List<JamAwardData> captureInserts() {
        ArgumentCaptor<JamAwardData> captor = ArgumentCaptor.forClass(JamAwardData.class);
        verify(jamAwardsMapper, org.mockito.Mockito.atLeastOnce()).insert(captor.capture());
        return captor.getAllValues();
    }

    private List<JamAwardData> insertsForTrack(List<JamAwardData> all, String track) {
        return all.stream().filter(a -> track.equals(a.getAwardTrack())).toList();
    }

    private int rankOf(List<JamAwardData> awards, long gameId) {
        return awards.stream()
                .filter(a -> a.getGameId() != null && a.getGameId() == gameId)
                .map(JamAwardData::getRank)
                .findFirst()
                .orElseThrow(() -> new AssertionError("no award for gameId=" + gameId));
    }

    private BigDecimal scoreOf(List<JamAwardData> awards, long gameId) {
        return awards.stream()
                .filter(a -> a.getGameId() != null && a.getGameId() == gameId)
                .map(JamAwardData::getScoreValue)
                .findFirst()
                .orElseThrow(() -> new AssertionError("no award for gameId=" + gameId));
    }
}
