package com.eactive.eai.common.security;

import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;

import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;

import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;

import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;
import org.springframework.beans.factory.support.BeanDefinitionBuilder;
import org.springframework.context.support.GenericApplicationContext;

import com.eactive.eai.common.hsm.HsmCryptoService;
import com.eactive.eai.common.security.loader.CryptoModuleConfigLoader;
import com.eactive.eai.common.security.mapper.CryptoModuleConfigMapper;
import com.eactive.eai.common.util.ApplicationContextProvider;
import com.eactive.eai.data.entity.onl.security.CryptoModuleConfig;

/**
 * CryptoModuleManager 단위 테스트
 * DB / 실 Spring 컨텍스트 없이 Mock으로 동작한다.
 */
@TestMethodOrder(MethodOrderer.DisplayName.class)
class CryptoModuleManagerTest {

    private static final byte[] KEY_128 = "0123456789abcdef".getBytes(StandardCharsets.UTF_8);
    private static final byte[] IV_16   = "abcdef0123456789".getBytes(StandardCharsets.UTF_8);
    private static final String ENC_KEY_HEX = toHex(KEY_128);
    private static final String IV_HEX      = toHex(IV_16);

    private static GenericApplicationContext ctx;
    private static CryptoModuleConfigLoader  mockLoader;
    private static HsmCryptoService          mockHsmCryptoService;
    private static CryptoModuleManager       manager;

    @BeforeAll
    static void setUpClass() throws Exception {
        mockLoader           = mock(CryptoModuleConfigLoader.class);
        mockHsmCryptoService = mock(HsmCryptoService.class);

        SecretKey masterKey = new SecretKeySpec(KEY_128, "AES");
        when(mockHsmCryptoService.getSecretKey(anyString())).thenReturn(masterKey);
        when(mockLoader.findAll()).thenReturn(Collections.emptyList());

        Constructor<CryptoModuleManager> ctor = CryptoModuleManager.class.getDeclaredConstructor();
        ctor.setAccessible(true);
        manager = ctor.newInstance();

        ctx = new GenericApplicationContext();
        ctx.getBeanFactory().registerSingleton("cryptoModuleConfigLoader", mockLoader);
        ctx.getBeanFactory().registerSingleton("hsmCryptoService", mockHsmCryptoService);
        ctx.getBeanFactory().registerSingleton("cryptoModuleManager", manager);
        ctx.getBeanFactory().registerSingleton("cryptoModuleConfigMapper", testMapper());
        ctx.registerBeanDefinition("applicationContextProvider",
                BeanDefinitionBuilder.genericBeanDefinition(ApplicationContextProvider.class)
                        .getBeanDefinition());
        ctx.refresh();

        manager.start();
    }

    @AfterAll
    static void tearDownClass() {
        if (ctx != null) ctx.close();
    }

    @BeforeEach
    void resetMocks() {
        reset(mockLoader);
        when(mockLoader.findAll()).thenReturn(Collections.emptyList());
    }

    // =========================================================================
    // 1. start / stop Lifecycle
    // =========================================================================

    @Test
    @DisplayName("1-1. start() 후 isStarted() = true")
    void testIsStarted() {
        assertTrue(manager.isStarted());
    }

    // =========================================================================
    // 2. STATIC 키 — createExtension
    // =========================================================================

    @Test
    @DisplayName("2-1. STATIC AES/CBC — createExtension 반환 및 암복호화 동작")
    void testCreateExtension_staticAesCbc_roundTrip() throws Exception {
        CryptoModuleConfig config = buildStaticConfig("AES_CBC", "AES", "CBC", "PKCS5Padding");
        when(mockLoader.findAll()).thenReturn(Arrays.asList(config));
        reload();

        byte[] plain = "테스트 평문".getBytes(StandardCharsets.UTF_8);
        CryptoModuleExtension ext = manager.createExtension("AES_CBC");
        byte[] encrypted = ext.encrypt(plain, 0, plain.length);

        CryptoModuleExtension extDec = manager.createExtension("AES_CBC");
        byte[] decrypted = extDec.decrypt(encrypted, 0, encrypted.length);

        assertArrayEquals(plain, decrypted, "STATIC AES/CBC 암복호화 라운드트립 성공해야 한다");
    }

    @Test
    @DisplayName("2-2. STATIC ARIA/CBC — createExtension 반환 및 암복호화 동작")
    void testCreateExtension_staticAriaCbc_roundTrip() throws Exception {
        CryptoModuleConfig config = buildStaticConfig("ARIA_CBC", "ARIA", "CBC", "PKCS5Padding");
        when(mockLoader.findAll()).thenReturn(Arrays.asList(config));
        reload();

        byte[] plain = "ARIA 테스트".getBytes(StandardCharsets.UTF_8);
        CryptoModuleExtension ext = manager.createExtension("ARIA_CBC");
        byte[] encrypted = ext.encrypt(plain, 0, plain.length);

        CryptoModuleExtension extDec = manager.createExtension("ARIA_CBC");
        byte[] decrypted = extDec.decrypt(encrypted, 0, encrypted.length);

        assertArrayEquals(plain, decrypted);
    }

    @Test
    @DisplayName("2-3. 미등록 cryptoName — IllegalArgumentException")
    void testCreateExtension_unknownName_throwsException() {
        assertThrows(IllegalArgumentException.class,
                () -> manager.createExtension("UNKNOWN_MODULE"),
                "미등록 cryptoName은 IllegalArgumentException이어야 한다");
    }

    @Test
    @DisplayName("2-4. use_yn=N 설정 — configMap 미등록")
    void testLoadAll_disabledConfig_notRegistered() throws Exception {
        CryptoModuleConfig config = buildStaticConfig("DISABLED_MOD", "AES", "CBC", "PKCS5Padding");
        config.setUseYn("N");
        when(mockLoader.findAll()).thenReturn(Arrays.asList(config));
        reload();

        assertThrows(IllegalArgumentException.class,
                () -> manager.createExtension("DISABLED_MOD"),
                "use_yn=N 설정은 로드되지 않아야 한다");
    }

    // =========================================================================
    // 3. DYNAMIC 키 — createExtension with runtimeContext
    // =========================================================================

    @Test
    @DisplayName("3-1. DYNAMIC AES/CBC — runtimeContext 전달 시 암복호화 동작")
    void testCreateExtension_dynamicAesCbc_roundTrip() throws Exception {
        CryptoModuleConfig config = buildDynamicConfig("DYN_AES_CBC", "AES", "CBC", "PKCS5Padding");
        when(mockLoader.findAll()).thenReturn(Arrays.asList(config));
        reload();

        Map<String, String> ctx = new HashMap<>();
        ctx.put("X-Api-Enc-Key", "clientKeyValue01");

        byte[] plain = "DYNAMIC 키 테스트".getBytes(StandardCharsets.UTF_8);
        CryptoModuleExtension ext = manager.createExtension("DYN_AES_CBC", ctx);
        byte[] encrypted = ext.encrypt(plain, 0, plain.length);

        CryptoModuleExtension extDec = manager.createExtension("DYN_AES_CBC", ctx);
        byte[] decrypted = extDec.decrypt(encrypted, 0, encrypted.length);

        assertArrayEquals(plain, decrypted, "DYNAMIC AES/CBC 암복호화 라운드트립 성공해야 한다");
    }

    @Test
    @DisplayName("3-2. DYNAMIC — 동일 runtimeContext로 2회 호출 시 HSM은 1회만 호출 (캐시 적용)")
    void testCreateExtension_dynamicCached_hsmCalledOnce() throws Exception {
        CryptoModuleConfig config = buildDynamicConfig("DYN_CACHE", "AES", "CBC", "PKCS5Padding");
        when(mockLoader.findAll()).thenReturn(Arrays.asList(config));
        reload();

        Map<String, String> runtimeCtx = new HashMap<>();
        runtimeCtx.put("X-Api-Enc-Key", "cachedKeyValue01");

        manager.createExtension("DYN_CACHE", runtimeCtx);
        manager.createExtension("DYN_CACHE", runtimeCtx);

        verify(mockHsmCryptoService, times(1)).getSecretKey(anyString());
    }

    @Test
    @DisplayName("3-3. DYNAMIC — 잘못된 전략 FQCN — IllegalArgumentException")
    void testCreateExtension_invalidStrategyFqcn_throwsException() throws Exception {
        CryptoModuleConfig config = buildDynamicConfig("DYN_BAD_FQCN", "AES", "CBC", "PKCS5Padding");
        config.setKeyDerivStrategy("com.example.NonExistentStrategy");
        when(mockLoader.findAll()).thenReturn(Arrays.asList(config));
        reload();

        Map<String, String> runtimeCtx = new HashMap<>();
        runtimeCtx.put("X-Api-Enc-Key", "clientKeyValue01");

        assertThrows(IllegalArgumentException.class,
                () -> manager.createExtension("DYN_BAD_FQCN", runtimeCtx),
                "존재하지 않는 FQCN이면 IllegalArgumentException이 발생해야 한다");
    }

    @Test
    @DisplayName("3-4. DYNAMIC — 동일 FQCN 반복 호출 시 strategyCache에서 재사용")
    void testCreateExtension_sameFqcn_strategyCacheReused() throws Exception {
        CryptoModuleConfig config = buildDynamicConfig("DYN_CACHE_STRAT", "AES", "CBC", "PKCS5Padding");
        when(mockLoader.findAll()).thenReturn(Arrays.asList(config));
        reload();

        Map<String, String> runtimeCtx = new HashMap<>();
        runtimeCtx.put("X-Api-Enc-Key", "clientKeyValue01");

        manager.createExtension("DYN_CACHE_STRAT", runtimeCtx);
        manager.createExtension("DYN_CACHE_STRAT", runtimeCtx);

        Field stratCacheField = CryptoModuleManager.class.getDeclaredField("strategyCache");
        stratCacheField.setAccessible(true);
        Map<?, ?> stratCache = (Map<?, ?>) stratCacheField.get(manager);

        assertEquals(1, stratCache.size(), "동일 FQCN은 strategyCache에 하나만 존재해야 한다");
    }

    @Test
    @DisplayName("3-5. DYNAMIC — 다른 runtimeContext 값은 다른 키 도출 (동적 키 캐시 미사용)")
    void testCreateExtension_dynamicDifferentContext_differentKey() throws Exception {
        CryptoModuleConfig config = buildDynamicConfig("DYN_DIFF", "AES", "CBC", "PKCS5Padding");
        when(mockLoader.findAll()).thenReturn(Arrays.asList(config));
        reload();

        Map<String, String> ctx1 = new HashMap<>();
        ctx1.put("X-Api-Enc-Key", "keyValueAAA00001");

        Map<String, String> ctx2 = new HashMap<>();
        ctx2.put("X-Api-Enc-Key", "keyValueBBB00001");

        manager.createExtension("DYN_DIFF", ctx1);
        manager.createExtension("DYN_DIFF", ctx2);

        // 컨텍스트 값이 달라 동적 키 캐시 미사용 → HSM 2회 호출
        verify(mockHsmCryptoService, times(2)).getSecretKey(anyString());
    }

    // =========================================================================
    // 4. reload
    // =========================================================================

    @Test
    @DisplayName("4-1. reload(cryptoId) — 변경된 설정 반영")
    void testReload_specificId_configUpdated() throws Exception {
        CryptoModuleConfig config = buildStaticConfig("RELOAD_MOD", "AES", "CBC", "PKCS5Padding");
        config.setCryptoId("reload-id-001");
        when(mockLoader.findAll()).thenReturn(Arrays.asList(config));
        reload();

        CryptoModuleConfig updated = buildStaticConfig("RELOAD_MOD", "ARIA", "CBC", "PKCS5Padding");
        updated.setCryptoId("reload-id-001");
        when(mockLoader.findById("reload-id-001")).thenReturn(Optional.of(updated));

        manager.reload("reload-id-001");

        // 변경된 설정(ARIA)으로 암복호화 가능한지 확인
        byte[] plain = "리로드 테스트".getBytes(StandardCharsets.UTF_8);
        CryptoModuleExtension ext = manager.createExtension("RELOAD_MOD");
        assertNotNull(ext);
    }

    @Test
    @DisplayName("4-2. reload(cryptoId) use_yn=N — configMap에서 제거")
    void testReload_specificId_disabled_removedFromMap() throws Exception {
        CryptoModuleConfig config = buildStaticConfig("RM_MOD", "AES", "CBC", "PKCS5Padding");
        config.setCryptoId("rm-id-001");
        when(mockLoader.findAll()).thenReturn(Arrays.asList(config));
        reload();

        CryptoModuleConfig disabled = buildStaticConfig("RM_MOD", "AES", "CBC", "PKCS5Padding");
        disabled.setCryptoId("rm-id-001");
        disabled.setUseYn("N");
        when(mockLoader.findById("rm-id-001")).thenReturn(Optional.of(disabled));

        manager.reload("rm-id-001");

        assertThrows(IllegalArgumentException.class,
                () -> manager.createExtension("RM_MOD"),
                "비활성화된 모듈은 configMap에서 제거되어야 한다");
    }

    // =========================================================================
    // 5. 진단정보 — describe / describeAll / listDynamicCacheKeys
    // =========================================================================

    @Test
    @DisplayName("5-1. describe() STATIC — keyDerivStrategy 없음, 원본 키 필드 없음")
    void testDescribe_static_noStrategyInfo() throws Exception {
        CryptoModuleConfig config = buildStaticConfig("DESC_STATIC", "AES", "CBC", "PKCS5Padding");
        when(mockLoader.findAll()).thenReturn(Arrays.asList(config));
        reload();

        CryptoModuleDiagnosticDTO dto = manager.describe("DESC_STATIC");

        assertEquals("DESC_STATIC", dto.getCryptoName());
        assertEquals("STATIC", dto.getKeySourceType());
        assertTrue(dto.isHasIv());
        assertNull(dto.getKeyDerivStrategy(), "STATIC 모듈은 keyDerivStrategy가 없어야 한다");
        assertFalse(dto.isStrategyLoaded(), "STATIC 모듈은 전략을 로드하지 않는다");
        assertFalse(dto.toString().contains(ENC_KEY_HEX), "원본 키(hex)가 진단정보에 노출되면 안 된다");
    }

    @Test
    @DisplayName("5-2. describe() DYNAMIC 정상 FQCN — strategyLoaded=true, strategyClassName 존재")
    void testDescribe_dynamic_validFqcn_strategyLoaded() throws Exception {
        CryptoModuleConfig config = buildDynamicConfig("DESC_DYN", "AES", "CBC", "PKCS5Padding");
        when(mockLoader.findAll()).thenReturn(Arrays.asList(config));
        reload();

        CryptoModuleDiagnosticDTO dto = manager.describe("DESC_DYN");

        assertEquals("DYNAMIC", dto.getKeySourceType());
        assertEquals("com.eactive.eai.common.security.keyderiv.strategy.HsmContextXorKeyDerivationStrategy",
                dto.getKeyDerivStrategy());
        assertTrue(dto.isStrategyLoaded());
        assertEquals("com.eactive.eai.common.security.keyderiv.strategy.HsmContextXorKeyDerivationStrategy",
                dto.getStrategyClassName());
        assertNull(dto.getStrategyLoadError());
    }

    @Test
    @DisplayName("5-3. describe() DYNAMIC 잘못된 FQCN — strategyLoaded=false, strategyLoadError 존재")
    void testDescribe_dynamic_invalidFqcn_strategyLoadFailed() throws Exception {
        CryptoModuleConfig config = buildDynamicConfig("DESC_BAD_FQCN", "AES", "CBC", "PKCS5Padding");
        config.setKeyDerivStrategy("com.example.NonExistentStrategy");
        when(mockLoader.findAll()).thenReturn(Arrays.asList(config));
        reload();

        CryptoModuleDiagnosticDTO dto = manager.describe("DESC_BAD_FQCN");

        assertFalse(dto.isStrategyLoaded());
        assertNotNull(dto.getStrategyLoadError());
    }

    @Test
    @DisplayName("5-4. describe() 미등록 cryptoName — IllegalArgumentException")
    void testDescribe_unknownName_throwsException() {
        assertThrows(IllegalArgumentException.class, () -> manager.describe("UNKNOWN_MODULE"));
    }

    @Test
    @DisplayName("5-5. describeAll() — 등록된 모듈 수만큼 반환")
    void testDescribeAll_returnsAllRegisteredModules() throws Exception {
        CryptoModuleConfig c1 = buildStaticConfig("DESC_ALL_1", "AES", "CBC", "PKCS5Padding");
        CryptoModuleConfig c2 = buildDynamicConfig("DESC_ALL_2", "ARIA", "CBC", "PKCS5Padding");
        when(mockLoader.findAll()).thenReturn(Arrays.asList(c1, c2));
        reload();

        List<CryptoModuleDiagnosticDTO> all = manager.describeAll();

        assertEquals(2, all.size());
        assertTrue(all.stream().anyMatch(d -> "DESC_ALL_1".equals(d.getCryptoName())));
        assertTrue(all.stream().anyMatch(d -> "DESC_ALL_2".equals(d.getCryptoName())));
    }

    @Test
    @DisplayName("5-6. listDynamicCacheKeys() — DYNAMIC 키 생성 후 캐시키 노출 (원본 키 아님)")
    void testListDynamicCacheKeys_afterCreateExtension_containsCacheKey() throws Exception {
        CryptoModuleConfig config = buildDynamicConfig("DESC_CACHE_KEYS", "AES", "CBC", "PKCS5Padding");
        when(mockLoader.findAll()).thenReturn(Arrays.asList(config));
        reload();

        Map<String, String> runtimeCtx = new HashMap<>();
        runtimeCtx.put("X-Api-Enc-Key", "clientKeyValue01");
        manager.createExtension("DESC_CACHE_KEYS", runtimeCtx);

        List<String> cacheKeys = manager.listDynamicCacheKeys();

        assertEquals(1, cacheKeys.size());
        assertEquals("DESC_CACHE_KEYS:clientKeyValue01", cacheKeys.get(0));
    }

    // =========================================================================
    // 헬퍼
    // =========================================================================

    private void reload() throws Exception {
        clearField("configMap");
        clearField("dynamicKeyCache");
        clearField("strategyCache");

        Method loadAll = CryptoModuleManager.class.getDeclaredMethod("loadAll");
        loadAll.setAccessible(true);
        loadAll.invoke(manager);

        reset(mockHsmCryptoService);
        SecretKey masterKey = new SecretKeySpec(KEY_128, "AES");
        when(mockHsmCryptoService.getSecretKey(anyString())).thenReturn(masterKey);
    }

    private void clearField(String fieldName) throws Exception {
        Field field = CryptoModuleManager.class.getDeclaredField(fieldName);
        field.setAccessible(true);
        ((Map<?, ?>) field.get(manager)).clear();
    }

    private CryptoModuleConfig buildStaticConfig(String name, String alg, String mode, String padding) {
        CryptoModuleConfig c = new CryptoModuleConfig();
        c.setCryptoId(java.util.UUID.randomUUID().toString());
        c.setCryptoName(name);
        c.setAlgType(alg);
        c.setCipherMode(mode);
        c.setPadding(padding);
        c.setIvHex(IV_HEX);
        c.setKeySourceType("STATIC");
        c.setEncKeyHex(ENC_KEY_HEX);
        c.setDecKeyHex(ENC_KEY_HEX);
        c.setCacheYn("Y");
        c.setCacheTtlSec(300);
        c.setUseYn("Y");
        return c;
    }

    private CryptoModuleConfig buildDynamicConfig(String name, String alg, String mode, String padding) {
        CryptoModuleConfig c = new CryptoModuleConfig();
        c.setCryptoId(java.util.UUID.randomUUID().toString());
        c.setCryptoName(name);
        c.setAlgType(alg);
        c.setCipherMode(mode);
        c.setPadding(padding);
        c.setIvHex(IV_HEX);
        c.setKeySourceType("DYNAMIC");
        c.setKeyDerivStrategy("com.eactive.eai.common.security.keyderiv.strategy.HsmContextXorKeyDerivationStrategy");
        c.setKeyDerivParams("{\"hsmKeyAlias\":\"MASTER_KEY\",\"contextKey\":\"X-Api-Enc-Key\",\"offset\":\"0\",\"length\":\"16\"}");
        c.setCacheYn("Y");
        c.setCacheTtlSec(300);
        c.setUseYn("Y");
        return c;
    }

    private static String toHex(byte[] bytes) {
        StringBuilder sb = new StringBuilder();
        for (byte b : bytes) sb.append(String.format("%02x", b));
        return sb.toString();
    }

    /** MapStruct APT 없이도 동작하는 인라인 매퍼 구현체 */
    private static CryptoModuleConfigMapper testMapper() {
        return new CryptoModuleConfigMapper() {
            @Override
            public CryptoModuleConfigVO toVo(CryptoModuleConfig e) {
                CryptoModuleConfigVO vo = new CryptoModuleConfigVO();
                vo.setCryptoId(e.getCryptoId());
                vo.setCryptoName(e.getCryptoName());
                vo.setCryptoDesc(e.getCryptoDesc());
                vo.setAlgType(e.getAlgType());
                vo.setCipherMode(e.getCipherMode());
                vo.setPadding(e.getPadding());
                vo.setIvHex(e.getIvHex());
                vo.setKeySourceType(e.getKeySourceType());
                vo.setEncKeyHex(e.getEncKeyHex());
                vo.setDecKeyHex(e.getDecKeyHex());
                vo.setKeyDerivStrategy(e.getKeyDerivStrategy());
                vo.setKeyDerivParams(e.getKeyDerivParams());
                vo.setCacheYn(e.getCacheYn());
                vo.setCacheTtlSec(e.getCacheTtlSec());
                vo.setUseYn(e.getUseYn());
                return vo;
            }
            @Override
            public CryptoModuleConfig toEntity(CryptoModuleConfigVO vo) {
                return null;
            }
        };
    }
}
