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

import incheon.com.cmm.exception.BusinessException;
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.CctvService;
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.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.NullAndEmptySource;
import org.junit.jupiter.params.provider.ValueSource;
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.assertj.core.api.Assertions.assertThatThrownBy;
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.verify;
import static org.mockito.Mockito.when;

/**
 * CctvServiceImpl 단위 테스트.
 * URL 프로토콜 검증, stale 판정 로직을 검증한다. (P1)
 */
@ExtendWith(MockitoExtension.class)
class CctvServiceImplTest {

    @InjectMocks
    private CctvServiceImpl cctvService;

    @Mock
    private CctvService self;

    @Mock
    private TrafficMapper trafficMapper;

    @Mock
    private GeoView3DProperties properties;

    @Mock
    private ExternalApiClient externalApiClient;

    @Mock
    private TransactionTemplate transactionTemplate;

    // ========== getCctvRedirectUrl — URL 프로토콜 검증 ==========

    @Nested
    @DisplayName("getCctvRedirectUrl — URL 입력 검증")
    class CctvRedirectUrl {

        @ParameterizedTest
        @NullAndEmptySource
        @DisplayName("null 또는 빈 URL이면 BusinessException")
        void nullOrEmpty(String url) {
            assertThatThrownBy(() -> cctvService.getCctvRedirectUrl(url))
                    .isInstanceOf(BusinessException.class)
                    .hasMessageContaining("URL이 비어있습니다");
        }

        @ParameterizedTest
        @ValueSource(strings = {
                "ftp://example.com/cctv",
                "file:///etc/passwd",
                "javascript:alert(1)",
                "data:text/html,<script>alert(1)</script>",
                "gopher://malicious.com"
        })
        @DisplayName("http/https 외 프로토콜이면 BusinessException")
        void nonHttpProtocol(String url) {
            assertThatThrownBy(() -> cctvService.getCctvRedirectUrl(url))
                    .isInstanceOf(BusinessException.class)
                    .hasMessageContaining("허용되지 않는 프로토콜");
        }

        @Test
        @DisplayName("http:// 프로토콜은 허용")
        void httpAllowed() {
            // 실제 HTTP 연결은 수행되나 테스트 환경에서는 예외가 발생하더라도
            // 프로토콜 검증 이후이므로 BusinessException이 아닌 다른 결과
            // getCctvRedirectUrl은 catch(Exception)으로 원본 URL을 반환하므로 성공
            String result = cctvService.getCctvRedirectUrl("http://localhost:9999/nonexistent");
            assertThat(result).isEqualTo("http://localhost:9999/nonexistent");
        }

        @Test
        @DisplayName("https:// 프로토콜은 허용")
        void httpsAllowed() {
            String result = cctvService.getCctvRedirectUrl("https://localhost:9999/nonexistent");
            assertThat(result).isEqualTo("https://localhost:9999/nonexistent");
        }
    }

    // ========== getCctvList — stale 데이터 판정 ==========

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

        @Test
        @DisplayName("최신 타임스탬프가 null이면 비동기 갱신 트리거")
        void nullTimestampTriggersUpdate() {
            when(trafficMapper.findLatestCreatedCctvTs()).thenReturn(null);
            when(trafficMapper.selectCctvList(any(), anyDouble(), anyDouble(), anyDouble(), anyDouble()))
                    .thenReturn(List.of());

            List<Map<String, Object>> result = cctvService.getCctvList(null, 0, 0, 1, 1);
            assertThat(result).isEmpty();
            verify(self).updateCctvInfoAsync();
        }

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

            cctvService.getCctvList(null, 0, 0, 1, 1);
            verify(self).updateCctvInfoAsync();
        }

        @Test
        @DisplayName("5분 미만 타임스탬프면 비동기 갱신 미수행")
        void freshTimestampNoUpdate() {
            long oneMinuteAgo = System.currentTimeMillis() - (1 * 60 * 1000L);
            when(trafficMapper.findLatestCreatedCctvTs()).thenReturn(new Timestamp(oneMinuteAgo));
            when(trafficMapper.selectCctvList(any(), anyDouble(), anyDouble(), anyDouble(), anyDouble()))
                    .thenReturn(List.of(Map.of("gid", 1)));

            List<Map<String, Object>> result = cctvService.getCctvList(null, 0, 0, 1, 1);
            assertThat(result).hasSize(1);
            verify(self, never()).updateCctvInfoAsync();
        }
    }

    // ========== updateCctvInfoAsync — 중복 실행 방지 ==========

    @Nested
    @DisplayName("updateCctvInfoAsync — in-flight 중복 방지")
    class InFlightDedup {

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

            cctvService.updateCctvInfoAsync();

            verify(trafficMapper, never()).upsertCctvSnapshotBatch(any(), anyString());
        }

        @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);

            cctvService.updateCctvInfoAsync();

            verify(trafficMapper, never()).upsertCctvSnapshotBatch(any(), anyString());
        }

        @Test
        @DisplayName("유효한 JSON 응답이면 upsert 수행")
        void validResponseUpserts() {
            GeoView3DProperties.TrafficApi apiConfig = new GeoView3DProperties.TrafficApi();
            when(properties.getTrafficApi()).thenReturn(apiConfig);
            String jsonResponse = "{\"body\":[{\"cctvname\":\"test\",\"cctvurl\":\"http://a\",\"coordx\":\"126.0\",\"coordy\":\"37.0\",\"baseDate\":\"2024-01-01\",\"roadsectionId\":1}]}";
            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());

            cctvService.updateCctvInfoAsync();

            verify(trafficMapper).upsertCctvSnapshotBatch(any(), eq("SYSTEM"));
            verify(trafficMapper).upsertCctvLatestBatch(any(), eq("SYSTEM"));
            verify(trafficMapper).deleteOldCctvSnapshots();
        }
    }
}
