package incheon.product.geoview2d.search.service.impl;

import incheon.com.cmm.exception.BusinessException;
import incheon.product.geoview2d.search.mapper.SearchMapper;
import incheon.product.geoview2d.search.vo.AdmResultVO;
import incheon.product.geoview2d.search.vo.AdmSearchVO;
import incheon.product.geoview2d.search.vo.JibunSearchResultVO;
import incheon.product.geoview2d.search.vo.PoiVO;
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 java.util.List;

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.Mockito.verify;
import static org.mockito.Mockito.when;

/**
 * SearchServiceImpl 단위 테스트.
 * 입력 검증, 예외 발생, SQL Injection 방어를 검증한다. (P1)
 */
@ExtendWith(MockitoExtension.class)
class SearchServiceImplTest {

    @InjectMocks
    private SearchServiceImpl searchService;

    @Mock
    private SearchMapper searchMapper;

    // ========== 지번 검색 ==========

    @Nested
    @DisplayName("getJibunSearchInfo — 입력 검증")
    class JibunSearch {

        @ParameterizedTest
        @NullAndEmptySource
        @DisplayName("keyword가 null 또는 빈 문자열이면 BusinessException")
        void nullOrEmptyKeyword(String keyword) {
            assertThatThrownBy(() -> searchService.getJibunSearchInfo(keyword, 1, 10))
                    .isInstanceOf(BusinessException.class)
                    .hasMessageContaining("검색어를 입력해주세요");
        }

        @Test
        @DisplayName("공백만 있는 keyword도 BusinessException")
        void blankKeyword() {
            assertThatThrownBy(() -> searchService.getJibunSearchInfo("   ", 1, 10))
                    .isInstanceOf(BusinessException.class)
                    .hasMessageContaining("검색어를 입력해주세요");
        }

        @Test
        @DisplayName("지번 패턴 없는 keyword면 BusinessException")
        void noJibunPattern() {
            assertThatThrownBy(() -> searchService.getJibunSearchInfo("인천 남동구 구월동", 1, 10))
                    .isInstanceOf(BusinessException.class)
                    .hasMessageContaining("지번 번호를 포함해주세요");
        }

        @Test
        @DisplayName("유효한 지번 keyword면 정상 처리 (숫자)")
        void validJibunSingleNumber() {
            when(searchMapper.getJibunSearchInfo(any())).thenReturn(List.of());

            List<JibunSearchResultVO> result = searchService.getJibunSearchInfo("구월동 123", 1, 10);
            assertThat(result).isEmpty();
            verify(searchMapper).getJibunSearchInfo(any());
        }

        @Test
        @DisplayName("유효한 지번 keyword면 정상 처리 (번-호)")
        void validJibunWithSub() {
            when(searchMapper.getJibunSearchInfo(any())).thenReturn(List.of());

            List<JibunSearchResultVO> result = searchService.getJibunSearchInfo("인천 남동구 구월동 123-45", 1, 10);
            assertThat(result).isEmpty();
            verify(searchMapper).getJibunSearchInfo(any());
        }
    }

    // ========== 행정구역 검색 ==========

    @Nested
    @DisplayName("getAdmList — 행정구역 유형 분기")
    class AdmList {

        @ParameterizedTest
        @ValueSource(strings = {"CTP", "ctp"})
        @DisplayName("CTP 유형이면 selectCtpList 호출")
        void ctpType(String type) {
            AdmSearchVO vo = createAdmSearchVO(type);
            when(searchMapper.selectCtpList(any())).thenReturn(List.of());

            searchService.getAdmList(vo);
            verify(searchMapper).selectCtpList(any());
        }

        @ParameterizedTest
        @ValueSource(strings = {"SIG", "sig"})
        @DisplayName("SIG 유형이면 selectSigList 호출")
        void sigType(String type) {
            AdmSearchVO vo = createAdmSearchVO(type);
            when(searchMapper.selectSigList(any())).thenReturn(List.of());

            searchService.getAdmList(vo);
            verify(searchMapper).selectSigList(any());
        }

        @ParameterizedTest
        @ValueSource(strings = {"EMD", "emd"})
        @DisplayName("EMD 유형이면 selectEmdList 호출")
        void emdType(String type) {
            AdmSearchVO vo = createAdmSearchVO(type);
            when(searchMapper.selectEmdList(any())).thenReturn(List.of());

            searchService.getAdmList(vo);
            verify(searchMapper).selectEmdList(any());
        }

        @ParameterizedTest
        @ValueSource(strings = {"LI", "li"})
        @DisplayName("LI 유형이면 selectLiList 호출")
        void liType(String type) {
            AdmSearchVO vo = createAdmSearchVO(type);
            when(searchMapper.selectLiList(any())).thenReturn(List.of());

            searchService.getAdmList(vo);
            verify(searchMapper).selectLiList(any());
        }

        @Test
        @DisplayName("유효하지 않은 유형이면 BusinessException")
        void invalidType() {
            AdmSearchVO vo = createAdmSearchVO("INVALID");

            assertThatThrownBy(() -> searchService.getAdmList(vo))
                    .isInstanceOf(BusinessException.class)
                    .hasMessageContaining("유효하지 않은 행정구역 유형");
        }

        private AdmSearchVO createAdmSearchVO(String type) {
            AdmSearchVO vo = new AdmSearchVO();
            vo.setType(type);
            return vo;
        }
    }

    // ========== sanitizeSortParams — SQL Injection 방어 ==========

    @Nested
    @DisplayName("sanitizeSortParams — SQL Injection 방어")
    class SanitizeSortParams {

        @Test
        @DisplayName("SQL Injection 패턴의 sortColumn은 null로 치환")
        void invalidSortColumnNullified() {
            AdmSearchVO vo = new AdmSearchVO();
            vo.setType("CTP");
            vo.setSortColumn("col; DROP TABLE x");
            when(searchMapper.selectCtpList(any())).thenReturn(List.of());

            searchService.getAdmList(vo);
            assertThat(vo.getSortColumn()).isNull();
        }

        @Test
        @DisplayName("유효한 sortColumn은 유지")
        void validSortColumnPreserved() {
            AdmSearchVO vo = new AdmSearchVO();
            vo.setType("CTP");
            vo.setSortColumn("ctprvn_cd");
            when(searchMapper.selectCtpList(any())).thenReturn(List.of());

            searchService.getAdmList(vo);
            assertThat(vo.getSortColumn()).isEqualTo("ctprvn_cd");
        }

        @Test
        @DisplayName("유효하지 않은 sortDirection은 ASC로 치환")
        void invalidSortDirectionDefaultsToAsc() {
            AdmSearchVO vo = new AdmSearchVO();
            vo.setType("CTP");
            vo.setSortDirection("INVALID");
            when(searchMapper.selectCtpList(any())).thenReturn(List.of());

            searchService.getAdmList(vo);
            assertThat(vo.getSortDirection()).isEqualTo("ASC");
        }

        @ParameterizedTest
        @ValueSource(strings = {"ASC", "DESC", "asc", "desc"})
        @DisplayName("유효한 sortDirection은 유지")
        void validSortDirectionPreserved(String direction) {
            AdmSearchVO vo = new AdmSearchVO();
            vo.setType("CTP");
            vo.setSortDirection(direction);
            when(searchMapper.selectCtpList(any())).thenReturn(List.of());

            searchService.getAdmList(vo);
            // ALLOWED_SORT_DIRECTIONS.contains(upper)로 체크하므로 소문자도 통과
            assertThat(vo.getSortDirection()).isEqualTo(direction);
        }

        @Test
        @DisplayName("sortColumn이 null이면 그대로 유지(nullify 불필요)")
        void nullSortColumnStaysNull() {
            AdmSearchVO vo = new AdmSearchVO();
            vo.setType("CTP");
            vo.setSortColumn(null);
            when(searchMapper.selectCtpList(any())).thenReturn(List.of());

            searchService.getAdmList(vo);
            assertThat(vo.getSortColumn()).isNull();
        }
    }

    // ========== parseJibunKeyword — 지번 파싱 ==========

    @Nested
    @DisplayName("parseJibunKeyword — 행정구역 유형 추정")
    class ParseJibun {

        @Test
        @DisplayName("끝 토큰이 '리'로 끝나면 LI 타입으로 파싱")
        void liSuffix() {
            when(searchMapper.getJibunSearchInfo(any())).thenAnswer(inv -> {
                var req = inv.getArguments()[0];
                // parseJibunKeyword 내부에서 admType을 설정함
                return List.of();
            });

            searchService.getJibunSearchInfo("인천 남동구 만수리 123", 1, 10);
            // 정상적으로 호출되면 파싱 성공
            verify(searchMapper).getJibunSearchInfo(any());
        }

        @Test
        @DisplayName("끝 토큰이 '동'으로 끝나면 EMD 타입으로 파싱")
        void dongSuffix() {
            when(searchMapper.getJibunSearchInfo(any())).thenReturn(List.of());

            searchService.getJibunSearchInfo("남동구 구월동 123-4", 1, 10);
            verify(searchMapper).getJibunSearchInfo(any());
        }

        @Test
        @DisplayName("끝 토큰이 '읍'으로 끝나면 EMD 타입으로 파싱")
        void eupSuffix() {
            when(searchMapper.getJibunSearchInfo(any())).thenReturn(List.of());

            searchService.getJibunSearchInfo("강화군 강화읍 100", 1, 10);
            verify(searchMapper).getJibunSearchInfo(any());
        }

        @Test
        @DisplayName("끝 토큰이 '면'으로 끝나면 EMD 타입으로 파싱")
        void myeonSuffix() {
            when(searchMapper.getJibunSearchInfo(any())).thenReturn(List.of());

            searchService.getJibunSearchInfo("강화군 하점면 200", 1, 10);
            verify(searchMapper).getJibunSearchInfo(any());
        }

        @Test
        @DisplayName("행정구역 접미사가 없으면 SIG 타입으로 파싱")
        void noSuffix() {
            when(searchMapper.getJibunSearchInfo(any())).thenReturn(List.of());

            searchService.getJibunSearchInfo("남동구 123", 1, 10);
            verify(searchMapper).getJibunSearchInfo(any());
        }
    }

    // ========== POI 위임 ==========

    @Nested
    @DisplayName("POI CRUD — Mapper 위임 확인")
    class PoiDelegation {

        @Test
        @DisplayName("createPoi는 mapper에 위임")
        void createPoi() {
            PoiVO poi = new PoiVO();
            searchService.createPoi(poi);
            verify(searchMapper).insertPoi(poi);
        }

        @Test
        @DisplayName("updatePoi는 mapper에 위임")
        void updatePoi() {
            PoiVO poi = new PoiVO();
            searchService.updatePoi(poi);
            verify(searchMapper).updatePoi(poi);
        }

        @Test
        @DisplayName("deletePoi는 mapper에 위임")
        void deletePoi() {
            searchService.deletePoi("nf-001");
            verify(searchMapper).deletePoi("nf-001");
        }
    }
}
