package incheon.product.geoview3d.traffic.service.impl;

import incheon.product.common.geo3d.ExternalApiClient;
import incheon.product.common.geo3d.GeoView3DProperties;
import incheon.product.geoview3d.traffic.mapper.TrafficMapper;
import incheon.product.geoview3d.traffic.service.TrafficService;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.transaction.support.TransactionTemplate;

import java.sql.Timestamp;
import java.util.List;
import java.util.Map;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyDouble;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

/**
 * TrafficServiceImpl 단위 테스트.
 * stale 데이터 판정, 비동기 갱신, 도로명 페이징을 검증한다.
 */
@ExtendWith(MockitoExtension.class)
class TrafficServiceImplTest {

    @InjectMocks
    private TrafficServiceImpl trafficService;

    @Mock
    private TrafficService self;

    @Mock
    private TrafficMapper trafficMapper;

    @Mock
    private GeoView3DProperties properties;

    @Mock
    private ExternalApiClient externalApiClient;

    @Mock
    private TransactionTemplate transactionTemplate;

    @Nested
    @DisplayName("getTrafficInfo — stale 데이터 갱신 트리거")
    class TrafficInfoStale {

        @Test
        @DisplayName("최신 타임스탬프 null이면 비동기 갱신 트리거")
        void nullTimestamp() {
            when(trafficMapper.findLatestCreatedTs()).thenReturn(null);
            when(trafficMapper.selectLinkGradesInBbox(anyString(), anyDouble(), anyDouble(), anyDouble(), anyDouble()))
                    .thenReturn(List.of());

            trafficService.getTrafficInfo("highway", 0, 0, 1, 1);
            verify(self).updateTrafficInfoAsync();
        }

        @Test
        @DisplayName("5분 미만 타임스탬프면 비동기 갱신 미수행")
        void freshTimestamp() {
            long oneMinuteAgo = System.currentTimeMillis() - (60 * 1000L);
            when(trafficMapper.findLatestCreatedTs()).thenReturn(new Timestamp(oneMinuteAgo));
            when(trafficMapper.selectLinkGradesInBbox(anyString(), anyDouble(), anyDouble(), anyDouble(), anyDouble()))
                    .thenReturn(List.of(Map.of("link_id", "L1")));

            List<Map<String, Object>> result = trafficService.getTrafficInfo("highway", 0, 0, 1, 1);
            assertThat(result).hasSize(1);
            verify(externalApiClient, never()).getForIts(anyString(), any());
        }
    }

    @Nested
    @DisplayName("getRoadNames — 페이징 조회")
    class RoadNamesPaging {

        @Test
        @DisplayName("도로명 검색 결과와 페이징 정보를 반환")
        void roadNamesWithPaging() {
            when(trafficMapper.selectRoadNames("highway", "인천", 0, 10))
                    .thenReturn(List.of(Map.of("road_name", "인천대로")));
            when(trafficMapper.selectRoadNameCount("highway", "인천")).thenReturn(1);

            Map<String, Object> result = trafficService.getRoadNames("highway", "인천", 1, 10);
            assertThat(result).containsKey("items");
            assertThat(result).containsKey("totalCount");
        }
    }

    @Nested
    @DisplayName("getLinks — 링크 조회")
    class Links {

        @Test
        @DisplayName("도로명으로 링크 목록 조회")
        void linksByRoadName() {
            when(trafficMapper.selectLinksByRoadName("인천대로"))
                    .thenReturn(List.of(Map.of("link_id", "L1")));

            List<Map<String, Object>> result = trafficService.getLinks("인천대로");
            assertThat(result).hasSize(1);
            verify(trafficMapper).selectLinksByRoadName("인천대로");
        }
    }

    @Nested
    @DisplayName("updateTrafficInfoAsync — 빈 응답 처리")
    class AsyncUpdate {

        @Test
        @DisplayName("null 응답이면 upsert 미수행")
        void nullResponseSkipsUpsert() {
            GeoView3DProperties.TrafficApi apiConfig = new GeoView3DProperties.TrafficApi();
            when(properties.getTrafficApi()).thenReturn(apiConfig);
            when(externalApiClient.getForIts(anyString(), eq(String.class))).thenReturn(null);

            trafficService.updateTrafficInfoAsync();
            verify(trafficMapper, never()).upsertSnapshotBatch(any(), anyString());
        }

        @Test
        @DisplayName("유효한 JSON 응답이면 upsert 수행")
        void validResponseUpserts() {
            GeoView3DProperties.TrafficApi apiConfig = new GeoView3DProperties.TrafficApi();
            when(properties.getTrafficApi()).thenReturn(apiConfig);
            String jsonResponse = "{\"body\":[{\"linkId\":\"L1\",\"speed\":60.5,\"travelTime\":120.0,\"grade\":\"A\"}]}";
            when(externalApiClient.getForIts(anyString(), eq(String.class))).thenReturn(jsonResponse);
            doAnswer(inv -> {
                java.util.function.Consumer<org.springframework.transaction.TransactionStatus> action = inv.getArgument(0);
                action.accept(null);
                return null;
            }).when(transactionTemplate).executeWithoutResult(any());

            trafficService.updateTrafficInfoAsync();
            verify(trafficMapper).upsertSnapshotBatch(any(), eq("SYSTEM"));
            verify(trafficMapper).upsertLatestBatch(any(), eq("SYSTEM"));
            verify(trafficMapper).deleteOldSnapshots();
        }
    }

    @Nested
    @DisplayName("getTrafficInfo — stale 타임스탬프 경계")
    class TrafficInfoStaleBoundary {

        @Test
        @DisplayName("5분 이상 경과한 타임스탬프면 비동기 갱신 트리거")
        void staleTimestampTriggersAsync() {
            long sixMinutesAgo = System.currentTimeMillis() - (6 * 60 * 1000L);
            when(trafficMapper.findLatestCreatedTs()).thenReturn(new Timestamp(sixMinutesAgo));
            when(trafficMapper.selectLinkGradesInBbox(anyString(), anyDouble(), anyDouble(), anyDouble(), anyDouble()))
                    .thenReturn(List.of());

            trafficService.getTrafficInfo("highway", 0, 0, 1, 1);
            verify(self).updateTrafficInfoAsync();
        }
    }

    @Nested
    @DisplayName("updateTrafficInfoAsync — 엣지케이스")
    class AsyncUpdateEdgeCases {

        @Test
        @DisplayName("빈 문자열 응답이면 upsert 미수행")
        void emptyStringResponseSkipsUpsert() {
            GeoView3DProperties.TrafficApi apiConfig = new GeoView3DProperties.TrafficApi();
            when(properties.getTrafficApi()).thenReturn(apiConfig);
            when(externalApiClient.getForIts(anyString(), eq(String.class))).thenReturn("");

            trafficService.updateTrafficInfoAsync();
            verify(trafficMapper, never()).upsertSnapshotBatch(any(), anyString());
        }

        @Test
        @DisplayName("잘못된 JSON 응답이면 파싱 예외 후 upsert 미수행")
        void malformedJsonResponseSkipsUpsert() {
            GeoView3DProperties.TrafficApi apiConfig = new GeoView3DProperties.TrafficApi();
            when(properties.getTrafficApi()).thenReturn(apiConfig);
            when(externalApiClient.getForIts(anyString(), eq(String.class)))
                    .thenReturn("{invalid json!!!}");

            trafficService.updateTrafficInfoAsync();
            verify(trafficMapper, never()).upsertSnapshotBatch(any(), anyString());
        }

        @Test
        @DisplayName("body.items 중첩 구조 응답도 정상 파싱")
        void bodyItemsNestedStructure() {
            GeoView3DProperties.TrafficApi apiConfig = new GeoView3DProperties.TrafficApi();
            when(properties.getTrafficApi()).thenReturn(apiConfig);
            String nestedJson = "{\"body\":{\"items\":[{\"linkId\":\"L1\",\"speed\":55.0}]}}";
            when(externalApiClient.getForIts(anyString(), eq(String.class))).thenReturn(nestedJson);
            doAnswer(inv -> {
                java.util.function.Consumer<org.springframework.transaction.TransactionStatus> action = inv.getArgument(0);
                action.accept(null);
                return null;
            }).when(transactionTemplate).executeWithoutResult(any());

            trafficService.updateTrafficInfoAsync();
            verify(trafficMapper).upsertSnapshotBatch(any(), eq("SYSTEM"));
        }

        @Test
        @DisplayName("예외 발생 후에도 inFlight 정리되어 재시도 가능")
        void exceptionClearsInFlightForRetry() {
            GeoView3DProperties.TrafficApi apiConfig = new GeoView3DProperties.TrafficApi();
            when(properties.getTrafficApi()).thenReturn(apiConfig);
            when(externalApiClient.getForIts(anyString(), eq(String.class)))
                    .thenThrow(new RuntimeException("API timeout"))
                    .thenReturn("{\"body\":[{\"linkId\":\"L2\",\"speed\":40.0}]}");
            doAnswer(inv -> {
                java.util.function.Consumer<org.springframework.transaction.TransactionStatus> action = inv.getArgument(0);
                action.accept(null);
                return null;
            }).when(transactionTemplate).executeWithoutResult(any());

            // 첫 번째 호출: 예외 발생 → finally에서 inFlight 정리
            trafficService.updateTrafficInfoAsync();
            verify(trafficMapper, never()).upsertSnapshotBatch(any(), anyString());

            // 두 번째 호출: inFlight 정리 확인 → 정상 처리
            trafficService.updateTrafficInfoAsync();
            verify(trafficMapper).upsertSnapshotBatch(any(), eq("SYSTEM"));
            verify(externalApiClient, times(2)).getForIts(anyString(), eq(String.class));
        }
    }
}
