package com.eactive.eai.adapter.handler;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;

import java.lang.reflect.Field;
import java.util.LinkedHashMap;
import java.util.Properties;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.mockito.InOrder;
import org.mockito.Mockito;
import org.springframework.context.ApplicationContext;

import com.eactive.eai.adapter.AdapterGroupVO;
import com.eactive.eai.adapter.AdapterManager;
import com.eactive.eai.adapter.AdapterPropManager;
import com.eactive.eai.adapter.AdapterVO;
import com.eactive.eai.adapter.http.HttpStatusException;
import com.eactive.eai.adapter.http.dynamic.filter.JwtAuthException;
import com.eactive.eai.common.message.EAIMessage;
import com.eactive.eai.common.property.PropManager;
import com.eactive.eai.common.util.ApplicationContextProvider;
import com.eactive.eai.message.StandardItem;
import com.eactive.eai.message.StandardMessage;
import com.eactive.eai.message.StandardType;

/**
 * TemplateAdapterErrorMsgHandler 단위테스트
 *
 * - render() : package-private 이므로 같은 패키지에서 직접 호출
 * - generateNonStandard*() : ApplicationContextProvider 에
 *   mock ApplicationContext 를 리플렉션으로 주입하여 PropManager 격리
 */
class TemplateAdapterErrorMsgHandlerTest {

    private TemplateAdapterErrorMsgHandler handler;

    @BeforeEach
    void setUp() {
        handler = new TemplateAdapterErrorMsgHandler();
    }

    // ================================================================
    // 공통 헬퍼
    // ================================================================

    /** FIELD 아이템 (type=2) */
    private static StandardItem field(String name, String value) {
        return new StandardItem(name, 3, StandardType.FIELD, 1, 0, 300, 1, "", "", value, name);
    }

    /** GROUP 아이템 (type=3, size=1 → 활성) */
    private static StandardItem group(String name) {
        return new StandardItem(name, 2, StandardType.GROUP, 1, 0, 0, 1, "", "", null, name);
    }

    /**
     * DJB 표준전문 MSG 영역을 모사한 StandardMessage 생성.
     *
     * 구조:
     *   MSG (GROUP)
     *     MAIN_MSG (GROUP)
     *       outp_msg_cd, outp_msg_ctnt, outp_msg_desc
     *     MSG_LIST (GRID)
     *       row[n]: outp_msg_cd, outp_msg_ctnt, outp_msg_desc, err_occu_item_nm
     *
     * @param listRows null 또는 빈 배열이면 MSG_LIST 행 없음
     */
    private StandardMessage buildMsg(String errCode, String errMsg, String errDesc,
                                     String[][] listRows) {
        StandardMessage msg = new StandardMessage();

        StandardItem msgGroup = group("MSG");

        StandardItem mainMsg = group("MAIN_MSG");
        mainMsg.addItem(field("outp_msg_cd",   errCode));
        mainMsg.addItem(field("outp_msg_ctnt", errMsg));
        mainMsg.addItem(field("outp_msg_desc", errDesc));
        msgGroup.addItem(mainMsg);

        StandardItem msgList = new StandardItem(
                "MSG_LIST", 2, StandardType.GRID, 0, 0, 0, 1, "", "", null, "MSG_LIST");
        msgList.addItem(field("outp_msg_cd",      ""));
        msgList.addItem(field("outp_msg_ctnt",    ""));
        msgList.addItem(field("outp_msg_desc",    ""));
        msgList.addItem(field("err_occu_item_nm", ""));

        if (listRows != null) {
            for (String[] row : listRows) {
                LinkedHashMap<String, StandardItem> rowMap = new LinkedHashMap<>();
                rowMap.put("outp_msg_cd",      field("outp_msg_cd",      get(row, 0)));
                rowMap.put("outp_msg_ctnt",    field("outp_msg_ctnt",    get(row, 1)));
                rowMap.put("outp_msg_desc",    field("outp_msg_desc",    get(row, 2)));
                rowMap.put("err_occu_item_nm", field("err_occu_item_nm", get(row, 3)));
                msgList.addArray(rowMap);
            }
        }
        msgGroup.addItem(msgList);
        msg.addItem(msgGroup);
        return msg;
    }

    private static String get(String[] arr, int idx) {
        return (arr != null && arr.length > idx) ? arr[idx] : "";
    }

    private static Properties props(String... keyValues) {
        Properties p = new Properties();
        for (int i = 0; i < keyValues.length - 1; i += 2) {
            p.setProperty(keyValues[i], keyValues[i + 1]);
        }
        return p;
    }

    /** ApplicationContextProvider 에 mock PropManager 를 주입한다. */
    private void injectMockPropManager(PropManager mockPropManager) throws Exception {
        ApplicationContext mockCtx = Mockito.mock(ApplicationContext.class);
        Mockito.when(mockCtx.getBean(PropManager.class)).thenReturn(mockPropManager);
        Field ctxField = ApplicationContextProvider.class.getDeclaredField("context");
        ctxField.setAccessible(true);
        ctxField.set(null, mockCtx);
    }

    /** EAIMessage 에 StandardMessage 를 주입해 반환한다. */
    private EAIMessage toEaiMessage(StandardMessage msg) {
        EAIMessage eaiMessage = new EAIMessage();
        eaiMessage.setStandardMessage(msg);
        return eaiMessage;
    }

    // ================================================================
    // 스칼라 변수 치환 ${path}
    // ================================================================

    @Nested
    @DisplayName("StandardMessage 스칼라 변수 치환 ${path}")
    class ScalarSubstitution {

        @Test
        @DisplayName("단순 JSON 템플릿 치환")
        void 단순_치환() {
            StandardMessage msg = buildMsg("E001", "오류발생", "상세설명", null);
            String template = "{\"code\":\"${MSG.MAIN_MSG.outp_msg_cd}\",\"msg\":\"${MSG.MAIN_MSG.outp_msg_ctnt}\"}";

            String result = handler.render(template, msg, null);

            assertEquals("{\"code\":\"E001\",\"msg\":\"오류발생\"}", result);
        }

        @Test
        @DisplayName("동일 변수 중복 치환")
        void 동일_변수_중복_치환() {
            StandardMessage msg = buildMsg("E001", "오류", "", null);
            String template = "${MSG.MAIN_MSG.outp_msg_cd}/${MSG.MAIN_MSG.outp_msg_cd}";

            assertEquals("E001/E001", handler.render(template, msg, null));
        }

        @Test
        @DisplayName("존재하지 않는 경로는 빈 문자열로 치환")
        void 없는_경로_빈_문자열() {
            StandardMessage msg = buildMsg("E001", "오류", "", null);

            assertEquals("", handler.render("${NONE.PATH}", msg, null));
        }

        @Test
        @DisplayName("변수 없는 템플릿은 원본 그대로 반환")
        void 변수_없는_템플릿_원본() {
            StandardMessage msg = buildMsg("E001", "오류", "", null);

            assertEquals("{\"static\":\"value\"}", handler.render("{\"static\":\"value\"}", msg, null));
        }

        @Test
        @DisplayName("outp_msg_desc 필드 치환")
        void outp_msg_desc_치환() {
            StandardMessage msg = buildMsg("E001", "오류", "상세설명입니다", null);

            assertEquals("상세설명입니다", handler.render("${MSG.MAIN_MSG.outp_msg_desc}", msg, null));
        }

        @Test
        @DisplayName("경로:기본값 – 경로 존재 시 값 반환")
        void 표준전문_기본값_경로존재() {
            StandardMessage msg = buildMsg("E001", "오류", "", null);

            assertEquals("E001", handler.render("${MSG.MAIN_MSG.outp_msg_cd:DEFAULT}", msg, null));
        }

        @Test
        @DisplayName("경로:기본값 – 경로 없으면 기본값 반환")
        void 표준전문_기본값_경로없음() {
            StandardMessage msg = buildMsg("E001", "오류", "", null);

            assertEquals("UNKNOWN", handler.render("${NONE.PATH:UNKNOWN}", msg, null));
        }

        @Test
        @DisplayName("경로:기본값 – 기본값이 빈 문자열")
        void 표준전문_기본값_빈문자열() {
            StandardMessage msg = buildMsg("E001", "오류", "", null);

            assertEquals("", handler.render("${NONE.PATH:}", msg, null));
        }
    }

    // ================================================================
    // callProp 변수 치환 ${callprop.키}
    // ================================================================

    @Nested
    @DisplayName("callProp 변수 치환 ${callprop.키}")
    class CallPropSubstitution {

        @Test
        @DisplayName("callProp 값을 템플릿에서 참조")
        void callProp_단순_참조() {
            StandardMessage msg = buildMsg("E001", "오류", "", null);
            Properties callProp = props("ADAPTER_NAME", "MY_ADAPTER", "IF_ID", "SVC001");
            String template = "{\"adapter\":\"${callprop.ADAPTER_NAME}\",\"ifId\":\"${callprop.IF_ID}\"}";

            String result = handler.render(template, msg, callProp);

            assertEquals("{\"adapter\":\"MY_ADAPTER\",\"ifId\":\"SVC001\"}", result);
        }

        @Test
        @DisplayName("callProp 과 StandardMessage 혼합 참조")
        void callProp_stdmsg_혼합() {
            StandardMessage msg = buildMsg("E001", "오류발생", "", null);
            Properties callProp = props("ADAPTER_NAME", "MY_ADAPTER");
            String template = "{\"adapter\":\"${callprop.ADAPTER_NAME}\",\"code\":\"${MSG.MAIN_MSG.outp_msg_cd}\"}";

            String result = handler.render(template, msg, callProp);

            assertEquals("{\"adapter\":\"MY_ADAPTER\",\"code\":\"E001\"}", result);
        }

        @Test
        @DisplayName("callProp 에 없는 키는 빈 문자열")
        void callProp_없는_키_빈문자열() {
            StandardMessage msg = buildMsg("E001", "오류", "", null);
            Properties callProp = new Properties();

            assertEquals("", handler.render("${callprop.NONE_KEY}", msg, callProp));
        }

        @Test
        @DisplayName("callProp 이 null 이면 빈 문자열")
        void callProp_null_빈문자열() {
            StandardMessage msg = buildMsg("E001", "오류", "", null);

            assertEquals("", handler.render("${callprop.ADAPTER_NAME}", msg, null));
        }

        @Test
        @DisplayName("callProp 값이 Properties 인 경우 중첩 탐색")
        void callProp_중첩_Properties() {
            Properties sub = new Properties();
            sub.setProperty("IF_ID", "SVC001");
            sub.setProperty("SVC_NM", "조회서비스");

            Properties callProp = new Properties();
            callProp.put("OUTBOUND", sub);
            callProp.setProperty("ADAPTER_NAME", "MY_ADAPTER");

            StandardMessage msg = buildMsg("E001", "오류", "", null);
            String template = "{\"adapter\":\"${callprop.ADAPTER_NAME}\",\"ifId\":\"${callprop.OUTBOUND.IF_ID}\",\"svcNm\":\"${callprop.OUTBOUND.SVC_NM}\"}";

            String result = handler.render(template, msg, callProp);

            assertEquals("{\"adapter\":\"MY_ADAPTER\",\"ifId\":\"SVC001\",\"svcNm\":\"조회서비스\"}", result);
        }

        @Test
        @DisplayName("중첩 Properties 3단계 탐색")
        void callProp_3단계_중첩() {
            Properties level2 = new Properties();
            level2.setProperty("CODE", "L2CODE");

            Properties level1 = new Properties();
            level1.put("LEVEL2", level2);

            Properties callProp = new Properties();
            callProp.put("LEVEL1", level1);

            StandardMessage msg = buildMsg("E001", "오류", "", null);

            assertEquals("L2CODE", handler.render("${callprop.LEVEL1.LEVEL2.CODE}", msg, callProp));
        }

        @Test
        @DisplayName("중간 값이 Properties 가 아니면 전체 keyPath 로 폴백 조회")
        void callProp_중간값_비Properties_폴백() {
            Properties callProp = new Properties();
            callProp.setProperty("A.B", "FALLBACK_VALUE");
            callProp.setProperty("A", "NOT_PROPS");

            StandardMessage msg = buildMsg("E001", "오류", "", null);

            assertEquals("FALLBACK_VALUE", handler.render("${callprop.A.B}", msg, callProp));
        }

        @Test
        @DisplayName("중첩 Properties 에서 없는 키는 빈 문자열")
        void callProp_중첩_없는키() {
            Properties sub = new Properties();
            sub.setProperty("EXIST", "VALUE");

            Properties callProp = new Properties();
            callProp.put("SUB", sub);

            StandardMessage msg = buildMsg("E001", "오류", "", null);

            assertEquals("", handler.render("${callprop.SUB.NONE_KEY}", msg, callProp));
        }

        @Test
        @DisplayName("callprop.키:기본값 – 키 존재 시 값 반환")
        void callProp_직접_기본값_키존재() {
            StandardMessage msg = buildMsg("E001", "오류", "", null);
            Properties callProp = props("ADAPTER_NAME", "MY_ADAPTER");

            assertEquals("MY_ADAPTER", handler.render("${callprop.ADAPTER_NAME:DEFAULT}", msg, callProp));
        }

        @Test
        @DisplayName("callprop.키:기본값 – 키 없으면 기본값 반환")
        void callProp_직접_기본값_키없음() {
            StandardMessage msg = buildMsg("E001", "오류", "", null);
            Properties callProp = new Properties();

            assertEquals("DEFAULT_VAL", handler.render("${callprop.NONE_KEY:DEFAULT_VAL}", msg, callProp));
        }

        @Test
        @DisplayName("callprop.키:기본값 – callProp null 이면 기본값 반환")
        void callProp_직접_기본값_null() {
            StandardMessage msg = buildMsg("E001", "오류", "", null);

            assertEquals("FALLBACK", handler.render("${callprop.ADAPTER_NAME:FALLBACK}", msg, null));
        }
    }

    // ================================================================
    // callProp 간접 참조 ${callprop[표준전문경로]} / ${callprop[경로]:기본값}
    // ================================================================

    @Nested
    @DisplayName("callProp 간접 참조 ${callprop[경로]}")
    class IndirectCallPropSubstitution {

        private StandardMessage buildMsgWithKey(String cpKey, String errCode) {
            StandardMessage msg = buildMsg(errCode, "오류", "", null);
            StandardItem msgGroup = msg.findItem("MSG");
            if (msgGroup != null) {
                msgGroup.addItem(field("cp_key", cpKey));
            }
            return msg;
        }

        @Test
        @DisplayName("표준전문 경로 값으로 callProp 키를 결정해 값 조회")
        void 간접_참조_정상() {
            StandardMessage msg = buildMsgWithKey("ADAPTER_NAME", "E001");
            Properties callProp = props("ADAPTER_NAME", "MY_ADAPTER");
            String template = "${callprop[MSG.cp_key]}";

            assertEquals("MY_ADAPTER", handler.render(template, msg, callProp));
        }

        @Test
        @DisplayName("표준전문 경로 값이 없으면 기본값 반환")
        void 표준전문_경로_없으면_기본값() {
            StandardMessage msg = buildMsg("E001", "오류", "", null);
            Properties callProp = props("ADAPTER_NAME", "MY_ADAPTER");
            String template = "${callprop[MSG.NONE_KEY]:DEFAULT_ADAPTER}";

            assertEquals("DEFAULT_ADAPTER", handler.render(template, msg, callProp));
        }

        @Test
        @DisplayName("callProp 에 해당 키 없으면 기본값 반환")
        void callProp_키_없으면_기본값() {
            StandardMessage msg = buildMsgWithKey("MISSING_KEY", "E001");
            Properties callProp = props("OTHER_KEY", "OTHER_VALUE");
            String template = "${callprop[MSG.cp_key]:FALLBACK}";

            assertEquals("FALLBACK", handler.render(template, msg, callProp));
        }

        @Test
        @DisplayName("기본값 미지정 시 빈 문자열")
        void 기본값_없으면_빈문자열() {
            StandardMessage msg = buildMsg("E001", "오류", "", null);
            Properties callProp = props("ADAPTER_NAME", "MY_ADAPTER");
            String template = "${callprop[MSG.NONE_KEY]}";

            assertEquals("", handler.render(template, msg, callProp));
        }

        @Test
        @DisplayName("callProp 이 null 이면 기본값 반환")
        void callProp_null_기본값() {
            StandardMessage msg = buildMsgWithKey("ADAPTER_NAME", "E001");
            String template = "${callprop[MSG.cp_key]:NO_PROP}";

            assertEquals("NO_PROP", handler.render(template, msg, null));
        }

        @Test
        @DisplayName("기본값이 빈 문자열인 경우")
        void 기본값이_빈문자열() {
            StandardMessage msg = buildMsg("E001", "오류", "", null);
            Properties callProp = new Properties();
            String template = "${callprop[MSG.NONE_KEY]:}";

            assertEquals("", handler.render(template, msg, callProp));
        }

        @Test
        @DisplayName("간접 참조와 직접 참조 혼합")
        void 간접_직접_혼합() {
            StandardMessage msg = buildMsgWithKey("IF_ID_KEY", "E001");
            Properties callProp = props("IF_ID_KEY", "SVC_001", "ADAPTER_NAME", "MY_ADAPTER");
            String template = "{\"adapter\":\"${callprop.ADAPTER_NAME}\",\"ifId\":\"${callprop[MSG.cp_key]}\"}";

            assertEquals("{\"adapter\":\"MY_ADAPTER\",\"ifId\":\"SVC_001\"}", handler.render(template, msg, callProp));
        }
    }

    // ================================================================
    // DATA 영역 변수 치환 ${DATA.xxx}
    // ================================================================

    @Nested
    @DisplayName("DATA 영역 변수 치환 ${DATA.xxx}")
    class DataSectionSubstitution {

        private StandardMessage buildMsgWithBizData(String bizDataJson) {
            StandardMessage msg = buildMsg("E001", "오류", "", null);
            StandardItem dataGroup = group("DATA");
            dataGroup.addItem(field("BIZDATA", bizDataJson));
            msg.addItem(dataGroup);
            return msg;
        }

        @Test
        @DisplayName("DATA.BIZDATA JSON 필드 치환 – 단일 depth")
        void DATA_BIZDATA_JSON_필드_치환() {
            StandardMessage msg = buildMsgWithBizData("{\"acctNo\":\"1234567890\",\"custNm\":\"홍길동\"}");
            String template = "{\"acctNo\":\"${DATA.BIZDATA.acctNo}\",\"custNm\":\"${DATA.BIZDATA.custNm}\"}";

            assertEquals("{\"acctNo\":\"1234567890\",\"custNm\":\"홍길동\"}",
                    handler.render(template, msg, null));
        }

        @Test
        @DisplayName("DATA.BIZDATA JSON 필드 치환 – 중첩 depth")
        void DATA_BIZDATA_JSON_중첩_필드_치환() {
            StandardMessage msg = buildMsgWithBizData("{\"addr\":{\"city\":\"서울\",\"zip\":\"12345\"}}");

            assertEquals("서울", handler.render("${DATA.BIZDATA.addr.city}", msg, null));
        }

        @Test
        @DisplayName("DATA.BIZDATA 원본 JSON 문자열 치환")
        void DATA_BIZDATA_원본_치환() {
            String rawJson = "{\"acctNo\":\"1234567890\"}";
            StandardMessage msg = buildMsgWithBizData(rawJson);

            assertEquals(rawJson, handler.render("${DATA.BIZDATA}", msg, null));
        }

        @Test
        @DisplayName("DATA.BIZDATA 와 MSG 영역 혼합 치환")
        void DATA_MSG_혼합_치환() {
            StandardMessage msg = buildMsgWithBizData("{\"acctNo\":\"9876543210\"}");
            String template = "{\"code\":\"${MSG.MAIN_MSG.outp_msg_cd}\",\"acctNo\":\"${DATA.BIZDATA.acctNo}\"}";

            assertEquals("{\"code\":\"E001\",\"acctNo\":\"9876543210\"}",
                    handler.render(template, msg, null));
        }

        @Test
        @DisplayName("DATA.BIZDATA JSON 에 없는 필드는 빈 문자열")
        void DATA_BIZDATA_없는_JSON_필드_빈문자열() {
            StandardMessage msg = buildMsgWithBizData("{\"acctNo\":\"1234567890\"}");

            assertEquals("", handler.render("${DATA.BIZDATA.noneField}", msg, null));
        }

        @Test
        @DisplayName("DATA.BIZDATA JSON 에 없는 필드에 기본값 지정")
        void DATA_BIZDATA_없는_JSON_필드_기본값() {
            StandardMessage msg = buildMsgWithBizData("{\"acctNo\":\"1234567890\"}");

            assertEquals("UNKNOWN", handler.render("${DATA.BIZDATA.noneField:UNKNOWN}", msg, null));
        }

        @Test
        @DisplayName("DATA 영역 미설정 시 기본값 반환")
        void DATA_없으면_기본값() {
            StandardMessage msg = buildMsg("E001", "오류", "", null);

            assertEquals("{}", handler.render("${DATA.BIZDATA:{}}", msg, null));
        }
    }

    // ================================================================
    // 배열 반복 {{#foreach path}}...{{/foreach}}
    // ================================================================

    @Nested
    @DisplayName("배열 반복 블록 {{#foreach}}")
    class ForeachBlock {

        @Test
        @DisplayName("MSG_LIST 1건 렌더링")
        void 단건_렌더링() {
            StandardMessage msg = buildMsg("E001", "대표오류", "",
                    new String[][]{{"E001", "메시지A", "설명A", "fieldA"}});
            String template = "[{{#foreach MSG.MSG_LIST}}{\"c\":\"${outp_msg_cd}\",\"m\":\"${outp_msg_ctnt}\"}{{/foreach}}]";

            assertEquals("[{\"c\":\"E001\",\"m\":\"메시지A\"}]", handler.render(template, msg, null));
        }

        @Test
        @DisplayName("MSG_LIST 2건 → 쉼표로 연결")
        void 다건_쉼표_연결() {
            StandardMessage msg = buildMsg("E001", "대표오류", "",
                    new String[][]{
                        {"E001", "오류A", "설명A", "fieldA"},
                        {"E002", "오류B", "설명B", "fieldB"}
                    });
            String template = "[{{#foreach MSG.MSG_LIST}}{\"c\":\"${outp_msg_cd}\"}{{/foreach}}]";

            assertEquals("[{\"c\":\"E001\"},{\"c\":\"E002\"}]", handler.render(template, msg, null));
        }

        @Test
        @DisplayName("MSG_LIST 가 비어있으면 foreach 결과는 빈 문자열")
        void 빈_배열() {
            StandardMessage msg = buildMsg("E001", "오류", "", new String[][]{});
            String template = "[{{#foreach MSG.MSG_LIST}}{\"c\":\"${outp_msg_cd}\"}{{/foreach}}]";

            assertEquals("[]", handler.render(template, msg, null));
        }

        @Test
        @DisplayName("&#35; 이 HTML 인코딩된 foreach 도 정상 처리")
        void HTML_인코딩된_foreach() {
            StandardMessage msg = buildMsg("E001", "대표오류", "",
                    new String[][]{{"E001", "오류A", "", ""}});
            // 관리 UI 가 # 을 &#35; 로 저장한 경우
            String template = "[{{&#35;foreach MSG.MSG_LIST}}{\"c\":\"${outp_msg_cd}\"}{{/foreach}}]";

            assertEquals("[{\"c\":\"E001\"}]", handler.render(template, msg, null));
        }

        @Test
        @DisplayName("foreach 경로가 없으면 빈 문자열")
        void 없는_경로() {
            StandardMessage msg = buildMsg("E001", "오류", "", null);
            String template = "[{{#foreach NONE.LIST}}{\"c\":\"${outp_msg_cd}\"}{{/foreach}}]";

            assertEquals("[]", handler.render(template, msg, null));
        }

        @Test
        @DisplayName("err_occu_item_nm 필드 포함 렌더링")
        void err_occu_item_nm_포함() {
            StandardMessage msg = buildMsg("E001", "오류", "",
                    new String[][]{{"E001", "오류A", "설명A", "accountNo"}});
            String template = "{{#foreach MSG.MSG_LIST}}{\"field\":\"${err_occu_item_nm}\"}{{/foreach}}";

            assertEquals("{\"field\":\"accountNo\"}", handler.render(template, msg, null));
        }

        @Test
        @DisplayName("스칼라 → foreach → 스칼라 순서 처리 – 단일 패스 검증 (사용자 템플릿 형식)")
        void 스칼라_foreach_스칼라_단일패스() {
            StandardMessage msg = buildMsg("E001", "대표오류", "",
                    new String[][]{{"E001", "오류A", "설명A", "fieldA"}});
            // 스칼라 앞, foreach 중간, 스칼라 뒤 순서로 배치
            String template =
                "{\"mainCode\":\"${MSG.MAIN_MSG.outp_msg_cd}\"," +
                "{{#foreach MSG.MSG_LIST}}{\"code\":\"${outp_msg_cd}\"}{{/foreach}}," +
                "\"mainMsg\":\"${MSG.MAIN_MSG.outp_msg_ctnt}\"}";

            String result = handler.render(template, msg, null);

            assertEquals(
                "{\"mainCode\":\"E001\"," +
                "{\"code\":\"E001\"}," +
                "\"mainMsg\":\"대표오류\"}",
                result);
        }

        @Test
        @DisplayName("foreach 앞뒤 스칼라 + 2건 리스트 – 단일 패스 검증")
        void 스칼라_foreach_스칼라_다건() {
            StandardMessage msg = buildMsg("E001", "대표오류", "",
                    new String[][]{
                        {"E001", "오류A", "", ""},
                        {"E002", "오류B", "", ""}
                    });
            String template =
                "{\"code\":\"${MSG.MAIN_MSG.outp_msg_cd}\"," +
                "\"errors\":[{{#foreach MSG.MSG_LIST}}{\"c\":\"${outp_msg_cd}\"}{{/foreach}}]," +
                "\"msg\":\"${MSG.MAIN_MSG.outp_msg_ctnt}\"}";

            String result = handler.render(template, msg, null);

            assertEquals(
                "{\"code\":\"E001\"," +
                "\"errors\":[{\"c\":\"E001\"},{\"c\":\"E002\"}]," +
                "\"msg\":\"대표오류\"}",
                result);
        }
    }

    // ================================================================
    // 예외 객체 필드 치환 ${exception.필드명}
    // ================================================================

    @Nested
    @DisplayName("예외 객체 필드 치환 ${exception.필드명}")
    class ExceptionSubstitution {

        @Test
        @DisplayName("exception.message → getMessage() 반환")
        void exception_message() {
            StandardMessage msg = buildMsg("E001", "오류", "", null);
            RuntimeException e = new RuntimeException("처리 중 오류 발생");

            String result = handler.render("${exception.message}", msg, null, e);

            assertEquals("처리 중 오류 발생", result);
        }

        @Test
        @DisplayName("HttpStatusException.status → getStatus() 반환")
        void HttpStatusException_status() {
            HttpStatusException e = new HttpStatusException("Bad Request", 400);

            assertEquals("400", handler.render("${exception.status}", null, null, e));
        }

        @Test
        @DisplayName("HttpStatusException.code → getCode() 반환")
        void HttpStatusException_code() {
            HttpStatusException e = new HttpStatusException("오류", "ERR_CODE", 400);

            assertEquals("ERR_CODE", handler.render("${exception.code}", null, null, e));
        }

        @Test
        @DisplayName("JwtAuthException.code → getCode() 반환")
        void JwtAuthException_code() {
            JwtAuthException e = new JwtAuthException("JWT_EXPIRED", "토큰 만료");

            assertEquals("JWT_EXPIRED", handler.render("${exception.code}", null, null, e));
        }

        @Test
        @DisplayName("JwtAuthException.message → getMessage() 반환")
        void JwtAuthException_message() {
            JwtAuthException e = new JwtAuthException("JWT_EXPIRED", "토큰이 만료되었습니다");

            assertEquals("토큰이 만료되었습니다", handler.render("${exception.message}", null, null, e));
        }

        @Test
        @DisplayName("존재하지 않는 필드는 빈 문자열")
        void 없는_필드_빈문자열() {
            RuntimeException e = new RuntimeException("오류");

            assertEquals("", handler.render("${exception.noneField}", null, null, e));
        }

        @Test
        @DisplayName("존재하지 않는 필드에 기본값 지정")
        void 없는_필드_기본값() {
            RuntimeException e = new RuntimeException("오류");

            assertEquals("UNKNOWN", handler.render("${exception.noneField:UNKNOWN}", null, null, e));
        }

        @Test
        @DisplayName("exception 이 null 이면 기본값 반환")
        void exception_null_기본값() {
            assertEquals("DEFAULT", handler.render("${exception.message:DEFAULT}", null, null, null));
        }

        @Test
        @DisplayName("exception + callProp + StandardMessage 혼합 치환")
        void exception_callProp_stdmsg_혼합() {
            StandardMessage msg = buildMsg("E001", "대표오류", "", null);
            Properties callProp = props("IF_ID", "SVC001");
            HttpStatusException e = new HttpStatusException("Bad Request", 400);
            String template =
                "{\"ifId\":\"${callprop.IF_ID}\"," +
                "\"code\":\"${MSG.MAIN_MSG.outp_msg_cd}\"," +
                "\"httpStatus\":\"${exception.status}\"," +
                "\"errMsg\":\"${exception.message}\"}";

            String result = handler.render(template, msg, callProp, e);

            assertEquals(
                "{\"ifId\":\"SVC001\"," +
                "\"code\":\"E001\"," +
                "\"httpStatus\":\"400\"," +
                "\"errMsg\":\"Bad Request\"}",
                result);
        }

        @Test
        @DisplayName("부모 클래스(Throwable) 필드도 리플렉션으로 접근")
        void 부모클래스_필드_접근() {
            // Throwable 의 detailMessage 필드 (getMessage() 로 접근됨)
            HttpStatusException e = new HttpStatusException("서비스 불가", 503);

            // getStatus 는 HttpStatusException 직접 정의 메서드
            assertEquals("503", handler.render("${exception.status}", null, null, e));
            // getMessage 는 Throwable 상속 메서드
            assertEquals("서비스 불가", handler.render("${exception.message}", null, null, e));
        }
    }

    // ================================================================
    // 배열 인덱스 직접 접근 ${path[n].field}
    // ================================================================

    @Nested
    @DisplayName("배열 인덱스 직접 접근 ${path[n].field}")
    class ArrayIndexSubstitution {

        @Test
        @DisplayName("첫 번째 행(인덱스 0) 필드 접근")
        void 첫번째_행_필드_접근() {
            StandardMessage msg = buildMsg("E001", "대표오류", "",
                    new String[][]{
                        {"E001", "오류A", "설명A", "fieldA"},
                        {"E002", "오류B", "설명B", "fieldB"}
                    });

            assertEquals("E001", handler.render("${MSG.MSG_LIST[0].outp_msg_cd}", msg, null));
        }

        @Test
        @DisplayName("두 번째 행(인덱스 1) 필드 접근")
        void 두번째_행_필드_접근() {
            StandardMessage msg = buildMsg("E001", "대표오류", "",
                    new String[][]{
                        {"E001", "오류A", "설명A", "fieldA"},
                        {"E002", "오류B", "설명B", "fieldB"}
                    });

            assertEquals("E002", handler.render("${MSG.MSG_LIST[1].outp_msg_cd}", msg, null));
        }

        @Test
        @DisplayName("여러 인덱스를 한 템플릿에서 동시 참조")
        void 여러_인덱스_동시_참조() {
            StandardMessage msg = buildMsg("E001", "대표오류", "",
                    new String[][]{
                        {"E001", "오류A", "", ""},
                        {"E002", "오류B", "", ""}
                    });
            String template = "{\"first\":\"${MSG.MSG_LIST[0].outp_msg_cd}\",\"second\":\"${MSG.MSG_LIST[1].outp_msg_cd}\"}";

            assertEquals("{\"first\":\"E001\",\"second\":\"E002\"}", handler.render(template, msg, null));
        }

        @Test
        @DisplayName("인덱스가 범위를 벗어나면 기본값 반환")
        void 인덱스_범위_초과_기본값() {
            StandardMessage msg = buildMsg("E001", "오류", "",
                    new String[][]{{"E001", "오류A", "", ""}});

            assertEquals("NONE", handler.render("${MSG.MSG_LIST[5].outp_msg_cd:NONE}", msg, null));
        }

        @Test
        @DisplayName("인덱스가 범위를 벗어나면 빈 문자열 (기본값 미지정)")
        void 인덱스_범위_초과_빈문자열() {
            StandardMessage msg = buildMsg("E001", "오류", "",
                    new String[][]{{"E001", "오류A", "", ""}});

            assertEquals("", handler.render("${MSG.MSG_LIST[9].outp_msg_cd}", msg, null));
        }

        @Test
        @DisplayName("MSG_LIST 가 비어있을 때 인덱스 접근은 빈 문자열")
        void 빈_배열_인덱스_접근() {
            StandardMessage msg = buildMsg("E001", "오류", "", new String[][]{});

            assertEquals("", handler.render("${MSG.MSG_LIST[0].outp_msg_cd}", msg, null));
        }

        @Test
        @DisplayName("배열 경로가 없으면 기본값 반환")
        void 없는_배열_경로() {
            StandardMessage msg = buildMsg("E001", "오류", "", null);

            assertEquals("DEFAULT", handler.render("${NONE.LIST[0].outp_msg_cd:DEFAULT}", msg, null));
        }

        @Test
        @DisplayName("err_occu_item_nm 필드 인덱스 접근")
        void err_occu_item_nm_인덱스_접근() {
            StandardMessage msg = buildMsg("E001", "오류", "",
                    new String[][]{
                        {"E001", "오류A", "", "accountNo"},
                        {"E002", "오류B", "", "amount"}
                    });

            assertEquals("accountNo", handler.render("${MSG.MSG_LIST[0].err_occu_item_nm}", msg, null));
            assertEquals("amount",    handler.render("${MSG.MSG_LIST[1].err_occu_item_nm}", msg, null));
        }

        @Test
        @DisplayName("인덱스 접근과 MAIN_MSG 스칼라 혼합")
        void 인덱스_접근_스칼라_혼합() {
            StandardMessage msg = buildMsg("E001", "대표오류", "",
                    new String[][]{{"E001", "오류A", "", "fieldA"}});
            String template =
                "{\"mainCode\":\"${MSG.MAIN_MSG.outp_msg_cd}\"," +
                "\"firstCode\":\"${MSG.MSG_LIST[0].outp_msg_cd}\"," +
                "\"firstField\":\"${MSG.MSG_LIST[0].err_occu_item_nm}\"}";

            assertEquals(
                "{\"mainCode\":\"E001\"," +
                "\"firstCode\":\"E001\"," +
                "\"firstField\":\"fieldA\"}",
                handler.render(template, msg, null));
        }
    }

    // ================================================================
    // 혼합 템플릿 (스칼라 + callProp + 배열)
    // ================================================================

    @Nested
    @DisplayName("혼합 템플릿")
    class MixedTemplate {

        @Test
        @DisplayName("JSON – MAIN_MSG + callProp + MSG_LIST 2건")
        void JSON_전체_혼합() {
            StandardMessage msg = buildMsg("E001", "대표오류", "대표설명",
                    new String[][]{
                        {"E001", "오류A", "설명A", "fieldA"},
                        {"E002", "오류B", "설명B", "fieldB"}
                    });
            Properties callProp = props("ADAPTER_NAME", "TEST_ADAPTER");

            String template =
                    "{" +
                    "\"adapter\":\"${callprop.ADAPTER_NAME}\"," +
                    "\"resultCode\":\"${MSG.MAIN_MSG.outp_msg_cd}\"," +
                    "\"resultMsg\":\"${MSG.MAIN_MSG.outp_msg_ctnt}\"," +
                    "\"errors\":[{{#foreach MSG.MSG_LIST}}" +
                    "{\"code\":\"${outp_msg_cd}\",\"msg\":\"${outp_msg_ctnt}\",\"field\":\"${err_occu_item_nm}\"}" +
                    "{{/foreach}}]" +
                    "}";

            String result = handler.render(template, msg, callProp);

            assertEquals(
                    "{\"adapter\":\"TEST_ADAPTER\"," +
                    "\"resultCode\":\"E001\",\"resultMsg\":\"대표오류\"," +
                    "\"errors\":[{\"code\":\"E001\",\"msg\":\"오류A\",\"field\":\"fieldA\"}," +
                    "{\"code\":\"E002\",\"msg\":\"오류B\",\"field\":\"fieldB\"}]}",
                    result);
        }

        @Test
        @DisplayName("XML – 스칼라 + callProp 치환")
        void XML_스칼라_callProp() {
            StandardMessage msg = buildMsg("E001", "오류발생", "", null);
            Properties callProp = props("IF_ID", "IF_001");
            String template = "<error><ifId>${callprop.IF_ID}</ifId><code>${MSG.MAIN_MSG.outp_msg_cd}</code></error>";

            assertEquals("<error><ifId>IF_001</ifId><code>E001</code></error>",
                    handler.render(template, msg, callProp));
        }

        @Test
        @DisplayName("XML – foreach 배열")
        void XML_foreach_배열() {
            StandardMessage msg = buildMsg("E001", "오류", "",
                    new String[][]{{"E001", "오류A", "", ""}, {"E002", "오류B", "", ""}});
            String template =
                    "<errors>{{#foreach MSG.MSG_LIST}}" +
                    "<item><code>${outp_msg_cd}</code><msg>${outp_msg_ctnt}</msg></item>" +
                    "{{/foreach}}</errors>";

            assertEquals(
                    "<errors>" +
                    "<item><code>E001</code><msg>오류A</msg></item>," +
                    "<item><code>E002</code><msg>오류B</msg></item>" +
                    "</errors>",
                    handler.render(template, msg, null));
        }
    }

    // ================================================================
    // generateNonStandardErrorResponseMessage
    // 템플릿 키: {adapterGroupId}.template
    // ================================================================

    @Nested
    @DisplayName("generateNonStandardErrorResponseMessage")
    class GenerateMethod {

        @Test
        @DisplayName("템플릿 미설정 시 null 반환")
        void 템플릿_없으면_null() throws Exception {
            PropManager mockProp = Mockito.mock(PropManager.class);
            Mockito.when(mockProp.getProperty(anyString(), anyString())).thenReturn(null);
            injectMockPropManager(mockProp);

            EAIMessage resEaiMsg = toEaiMessage(buildMsg("E001", "오류", "", null));
            Object result = handler.generateNonStandardErrorResponseMessage(
                    "TEST_GROUP", "TEST_ADAPTER", new Properties(), null, resEaiMsg);

            assertNull(result);
        }

        @Test
        @DisplayName("빈 템플릿 시 null 반환")
        void 빈_템플릿_null() throws Exception {
            PropManager mockProp = Mockito.mock(PropManager.class);
            Mockito.when(mockProp.getProperty(anyString(), anyString())).thenReturn("   ");
            injectMockPropManager(mockProp);

            EAIMessage resEaiMsg = toEaiMessage(buildMsg("E001", "오류", "", null));
            Object result = handler.generateNonStandardErrorResponseMessage(
                    "TEST_GROUP", "TEST_ADAPTER", new Properties(), null, resEaiMsg);

            assertNull(result);
        }

        @Test
        @DisplayName("템플릿 설정 시 StandardMessage 치환 결과 반환")
        void 템플릿_stdmsg_치환() throws Exception {
            String template = "{\"code\":\"${MSG.MAIN_MSG.outp_msg_cd}\"}";
            PropManager mockProp = Mockito.mock(PropManager.class);
            Mockito.when(mockProp.getProperty(
                    eq(TemplateAdapterErrorMsgHandler.PROP_GROUP),
                    eq("MY_GROUP.template"))).thenReturn(template);
            injectMockPropManager(mockProp);

            EAIMessage resEaiMsg = toEaiMessage(buildMsg("E999", "테스트오류", "", null));
            Object result = handler.generateNonStandardErrorResponseMessage(
                    "MY_GROUP", "MY_ADAPTER", new Properties(), null, resEaiMsg);

            assertEquals("{\"code\":\"E999\"}", result);
        }

        @Test
        @DisplayName("callProp 값이 템플릿에 치환되어 반환")
        void 템플릿_callProp_치환() throws Exception {
            String template = "{\"adapter\":\"${callprop.ADAPTER_NAME}\",\"code\":\"${MSG.MAIN_MSG.outp_msg_cd}\"}";
            PropManager mockProp = Mockito.mock(PropManager.class);
            Mockito.when(mockProp.getProperty(
                    eq(TemplateAdapterErrorMsgHandler.PROP_GROUP),
                    eq("MY_GROUP.template"))).thenReturn(template);
            injectMockPropManager(mockProp);

            EAIMessage resEaiMsg = toEaiMessage(buildMsg("E999", "테스트오류", "", null));
            Properties callProp = props("ADAPTER_NAME", "REAL_ADAPTER");
            Object result = handler.generateNonStandardErrorResponseMessage(
                    "MY_GROUP", "MY_ADAPTER", callProp, null, resEaiMsg);

            assertEquals("{\"adapter\":\"REAL_ADAPTER\",\"code\":\"E999\"}", result);
        }

        @Test
        @DisplayName("템플릿 키는 {adapterGroupId}.template 형식으로 조회")
        void 템플릿_키_형식_검증() throws Exception {
            PropManager mockProp = Mockito.mock(PropManager.class);
            Mockito.when(mockProp.getProperty(anyString(), anyString())).thenReturn(null);
            injectMockPropManager(mockProp);

            EAIMessage resEaiMsg = toEaiMessage(buildMsg("E001", "오류", "", null));
            handler.generateNonStandardErrorResponseMessage(
                    "SAMPLE_GROUP", "ANY_ADAPTER", new Properties(), null, resEaiMsg);

            Mockito.verify(mockProp).getProperty(
                    TemplateAdapterErrorMsgHandler.PROP_GROUP,
                    "SAMPLE_GROUP.template");
        }
    }

    // ================================================================
    // generateNonStandardInternalErrorResponseMessage (ExceptionHandler 내부 오류용)
    // 템플릿 키: {adapterGroupId}.sys.template
    // ================================================================

    @Nested
    @DisplayName("generateNonStandardInternalErrorResponseMessage")
    class GenerateInternalMethod {

        @Test
        @DisplayName("템플릿 미설정 시 null 반환")
        void 템플릿_없으면_null() throws Exception {
            PropManager mockProp = Mockito.mock(PropManager.class);
            Mockito.when(mockProp.getProperty(anyString(), anyString())).thenReturn(null);
            injectMockPropManager(mockProp);

            EAIMessage eaiMessage = toEaiMessage(buildMsg("E001", "오류", "", null));
            Object result = handler.generateNonStandardInternalErrorResponseMessage(
                    "TEST_GROUP", "TEST_ADAPTER", null, null, eaiMessage);

            assertNull(result);
        }

        @Test
        @DisplayName("빈 템플릿 시 null 반환")
        void 빈_템플릿_null() throws Exception {
            PropManager mockProp = Mockito.mock(PropManager.class);
            Mockito.when(mockProp.getProperty(anyString(), anyString())).thenReturn("   ");
            injectMockPropManager(mockProp);

            EAIMessage eaiMessage = toEaiMessage(buildMsg("E001", "오류", "", null));
            Object result = handler.generateNonStandardInternalErrorResponseMessage(
                    "TEST_GROUP", "TEST_ADAPTER", null, null, eaiMessage);

            assertNull(result);
        }

        @Test
        @DisplayName("템플릿 설정 시 EAIMessage 의 StandardMessage 로 치환")
        void 템플릿_stdmsg_치환() throws Exception {
            String template = "{\"code\":\"${MSG.MAIN_MSG.outp_msg_cd}\",\"msg\":\"${MSG.MAIN_MSG.outp_msg_ctnt}\"}";
            PropManager mockProp = Mockito.mock(PropManager.class);
            Mockito.when(mockProp.getProperty(
                    eq(TemplateAdapterErrorMsgHandler.PROP_GROUP),
                    eq("MY_GROUP.sys.template"))).thenReturn(template);
            injectMockPropManager(mockProp);

            EAIMessage eaiMessage = toEaiMessage(buildMsg("E999", "내부오류", "", null));
            Object result = handler.generateNonStandardInternalErrorResponseMessage(
                    "MY_GROUP", "MY_ADAPTER", null, null, eaiMessage);

            assertEquals("{\"code\":\"E999\",\"msg\":\"내부오류\"}", result);
        }

        @Test
        @DisplayName("callProp 이 null 이어도 스칼라 기본값 처리 (ExceptionHandler 에서 null 전달)")
        void callProp_null_기본값_처리() throws Exception {
            String template = "{\"code\":\"${MSG.MAIN_MSG.outp_msg_cd}\",\"adapter\":\"${callprop.ADAPTER_NAME:UNKNOWN}\"}";
            PropManager mockProp = Mockito.mock(PropManager.class);
            Mockito.when(mockProp.getProperty(
                    eq(TemplateAdapterErrorMsgHandler.PROP_GROUP),
                    eq("MY_GROUP.sys.template"))).thenReturn(template);
            injectMockPropManager(mockProp);

            EAIMessage eaiMessage = toEaiMessage(buildMsg("E500", "시스템오류", "", null));
            Object result = handler.generateNonStandardInternalErrorResponseMessage(
                    "MY_GROUP", "MY_ADAPTER", null, null, eaiMessage);

            // callProp null → 기본값 UNKNOWN 반환
            assertEquals("{\"code\":\"E500\",\"adapter\":\"UNKNOWN\"}", result);
        }

        @Test
        @DisplayName("MSG_LIST foreach 포함 템플릿 치환")
        void foreach_MSG_LIST_포함() throws Exception {
            String template =
                "{\"code\":\"${MSG.MAIN_MSG.outp_msg_cd}\"," +
                "\"errors\":[{{#foreach MSG.MSG_LIST}}" +
                "{\"c\":\"${outp_msg_cd}\",\"m\":\"${outp_msg_ctnt}\"}" +
                "{{/foreach}}]}";
            PropManager mockProp = Mockito.mock(PropManager.class);
            Mockito.when(mockProp.getProperty(
                    eq(TemplateAdapterErrorMsgHandler.PROP_GROUP),
                    eq("GW_GROUP.sys.template"))).thenReturn(template);
            injectMockPropManager(mockProp);

            EAIMessage eaiMessage = toEaiMessage(buildMsg("E001", "대표오류", "",
                    new String[][]{{"E001", "오류A", "", ""}, {"E002", "오류B", "", ""}}));
            Object result = handler.generateNonStandardInternalErrorResponseMessage(
                    "GW_GROUP", "GW_ADAPTER", null, null, eaiMessage);

            assertEquals(
                "{\"code\":\"E001\"," +
                "\"errors\":[{\"c\":\"E001\",\"m\":\"오류A\"}," +
                "{\"c\":\"E002\",\"m\":\"오류B\"}]}",
                result);
        }

        @Test
        @DisplayName("템플릿 키는 {adapterGroupId}.sys.template 형식으로 조회")
        void 템플릿_키_형식_검증() throws Exception {
            PropManager mockProp = Mockito.mock(PropManager.class);
            Mockito.when(mockProp.getProperty(anyString(), anyString())).thenReturn(null);
            injectMockPropManager(mockProp);

            EAIMessage eaiMessage = toEaiMessage(buildMsg("E001", "오류", "", null));
            handler.generateNonStandardInternalErrorResponseMessage(
                    "SAMPLE_GROUP", "ANY_ADAPTER", null, null, eaiMessage);

            Mockito.verify(mockProp).getProperty(
                    TemplateAdapterErrorMsgHandler.PROP_GROUP,
                    "SAMPLE_GROUP.sys.template");
        }

        @Test
        @DisplayName("스칼라 → foreach → 스칼라 순서 – 사용자 실제 템플릿 형식")
        void 사용자_실제_템플릿_형식() throws Exception {
            // ExceptionHandler 에서 사용되는 실제 템플릿 패턴: 스칼라, foreach, 스칼라 순서
            String template =
                "{\"aeh-sys-resultCode1\":\"${MSG.MAIN_MSG.outp_msg_cd}\"," +
                "{{#foreach MSG.MSG_LIST}}" +
                "\"aeh-sys-resultCode1\":\"${outp_msg_cd}\"" +
                "{{/foreach}}," +
                "\"aeh-sys-resultMsg1\":\"${MSG.MAIN_MSG.outp_msg_ctnt}\"}";
            PropManager mockProp = Mockito.mock(PropManager.class);
            Mockito.when(mockProp.getProperty(
                    eq(TemplateAdapterErrorMsgHandler.PROP_GROUP),
                    eq("GW_GROUP.sys.template"))).thenReturn(template);
            injectMockPropManager(mockProp);

            EAIMessage eaiMessage = toEaiMessage(buildMsg("E001", "오류메시지", "",
                    new String[][]{{"E001", "오류A", "", ""}}));
            Object result = handler.generateNonStandardInternalErrorResponseMessage(
                    "GW_GROUP", "GW_ADAPTER", null, null, eaiMessage);

            assertEquals(
                "{\"aeh-sys-resultCode1\":\"E001\"," +
                "\"aeh-sys-resultCode1\":\"E001\"," +
                "\"aeh-sys-resultMsg1\":\"오류메시지\"}",
                result);
        }
    }

    // ================================================================
    // generateNonStandardInboundErrorResponseMessage (인바운드 오류 응답)
    // 템플릿 키: {adapterGroupId}.in.{httpCode}.template → {adapterGroupId}.in.template 순서로 조회
    // ================================================================

    @Nested
    @DisplayName("generateNonStandardInboundErrorResponseMessage")
    class GenerateInboundMethod {

        private AdapterManager      mockAdapterManager;
        private AdapterPropManager  mockAdapterPropManager;
        private PropManager         mockPropManager;
        private AdapterGroupVO      mockAdapterGroupVO;
        private AdapterVO           mockAdapterVO;

        @BeforeEach
        void setUpInbound() throws Exception {
            mockAdapterManager     = Mockito.mock(AdapterManager.class);
            mockAdapterPropManager = Mockito.mock(AdapterPropManager.class);
            mockPropManager        = Mockito.mock(PropManager.class);
            mockAdapterGroupVO     = Mockito.mock(AdapterGroupVO.class);
            mockAdapterVO          = Mockito.mock(AdapterVO.class);

            Mockito.when(mockAdapterGroupVO.getMessageType()).thenReturn("JSON");
            Mockito.when(mockAdapterGroupVO.getMessageEncode()).thenReturn("UTF-8");
            Mockito.when(mockAdapterVO.getPropGroupName()).thenReturn("TEST_PROP");
            Mockito.when(mockAdapterVO.getAdapterGroupVO()).thenReturn(mockAdapterGroupVO);
            Mockito.when(mockAdapterManager.getAdapterGroupVO("TEST_GROUP")).thenReturn(mockAdapterGroupVO);
            Mockito.when(mockAdapterManager.getAdapterVO("TEST_GROUP", "TEST_ADAPTER")).thenReturn(mockAdapterVO);

            Properties httpProp = new Properties();
            httpProp.setProperty("ERROR_RESPONSE_FORMAT", "");
            Mockito.when(mockAdapterPropManager.getProperties("TEST_PROP")).thenReturn(httpProp);
            Mockito.when(mockPropManager.getProperty(anyString(), anyString())).thenReturn(null);
            // 3-인자 getProperty(group, key, defaultValue) → defaultValue 반환
            // (MessageUtil.makeJsonErrorMessage 에서 ERROR_MESSAGE_DEFAULT_FORMAT 폴백 시 사용)
            Mockito.when(mockPropManager.getProperty(anyString(), anyString(), anyString()))
                    .thenAnswer(invocation -> invocation.getArgument(2));

            ApplicationContext mockCtx = Mockito.mock(ApplicationContext.class);
            Mockito.when(mockCtx.getBean(AdapterManager.class)).thenReturn(mockAdapterManager);
            Mockito.when(mockCtx.getBean(AdapterPropManager.class)).thenReturn(mockAdapterPropManager);
            Mockito.when(mockCtx.getBean(PropManager.class)).thenReturn(mockPropManager);
            Field ctxField = ApplicationContextProvider.class.getDeclaredField("context");
            ctxField.setAccessible(true);
            ctxField.set(null, mockCtx);
        }

        @Test
        @DisplayName("HttpStatusException → httpCode 전용 템플릿 우선 사용")
        void HttpStatusException_전용_템플릿() throws Exception {
            Mockito.when(mockPropManager.getProperty(
                    eq(TemplateAdapterErrorMsgHandler.PROP_GROUP),
                    eq("TEST_GROUP.in.400.template")))
                .thenReturn("{\"status\":\"400\",\"msg\":\"Bad Request\"}");

            Object result = handler.generateNonStandardInboundErrorResponseMessage(
                    "TEST_GROUP", "TEST_ADAPTER", null, null, null,
                    new HttpStatusException("Bad Request", 400));

            assertEquals("{\"status\":\"400\",\"msg\":\"Bad Request\"}", result);
        }

        @Test
        @DisplayName("HttpStatusException → 전용 템플릿 없으면 공통 in.template 사용")
        void HttpStatusException_공통_템플릿_폴백() throws Exception {
            Mockito.when(mockPropManager.getProperty(
                    eq(TemplateAdapterErrorMsgHandler.PROP_GROUP),
                    eq("TEST_GROUP.in.404.template"))).thenReturn(null);
            Mockito.when(mockPropManager.getProperty(
                    eq(TemplateAdapterErrorMsgHandler.PROP_GROUP),
                    eq("TEST_GROUP.in.template")))
                .thenReturn("{\"error\":\"not found\"}");

            Object result = handler.generateNonStandardInboundErrorResponseMessage(
                    "TEST_GROUP", "TEST_ADAPTER", null, null, null,
                    new HttpStatusException("Not Found", 404));

            assertEquals("{\"error\":\"not found\"}", result);
        }

        @Test
        @DisplayName("JwtAuthException → 401 전용 템플릿 사용")
        void JwtAuthException_401_전용_템플릿() throws Exception {
            Mockito.when(mockPropManager.getProperty(
                    eq(TemplateAdapterErrorMsgHandler.PROP_GROUP),
                    eq("TEST_GROUP.in.401.template")))
                .thenReturn("{\"error\":\"unauthorized\"}");

            Object result = handler.generateNonStandardInboundErrorResponseMessage(
                    "TEST_GROUP", "TEST_ADAPTER", null, null, null,
                    new JwtAuthException("AUTH_ERR", "토큰 인증 실패"));

            assertEquals("{\"error\":\"unauthorized\"}", result);
        }

        @Test
        @DisplayName("JwtAuthException → 401 전용 없으면 공통 in.template 사용")
        void JwtAuthException_공통_템플릿_폴백() throws Exception {
            Mockito.when(mockPropManager.getProperty(
                    eq(TemplateAdapterErrorMsgHandler.PROP_GROUP),
                    eq("TEST_GROUP.in.401.template"))).thenReturn(null);
            Mockito.when(mockPropManager.getProperty(
                    eq(TemplateAdapterErrorMsgHandler.PROP_GROUP),
                    eq("TEST_GROUP.in.template")))
                .thenReturn("{\"error\":\"auth failed\"}");

            Object result = handler.generateNonStandardInboundErrorResponseMessage(
                    "TEST_GROUP", "TEST_ADAPTER", null, null, null,
                    new JwtAuthException("AUTH_ERR", "토큰 만료"));

            assertEquals("{\"error\":\"auth failed\"}", result);
        }

        @Test
        @DisplayName("기타 예외 → 500 전용 템플릿 사용")
        void 기타예외_500_전용_템플릿() throws Exception {
            Mockito.when(mockPropManager.getProperty(
                    eq(TemplateAdapterErrorMsgHandler.PROP_GROUP),
                    eq("TEST_GROUP.in.500.template")))
                .thenReturn("{\"error\":\"internal server error\"}");

            Object result = handler.generateNonStandardInboundErrorResponseMessage(
                    "TEST_GROUP", "TEST_ADAPTER", null, null, null,
                    new RuntimeException("처리 중 오류"));

            assertEquals("{\"error\":\"internal server error\"}", result);
        }

        @Test
        @DisplayName("기타 예외 → 500 전용 없으면 공통 in.template 사용")
        void 기타예외_공통_템플릿_폴백() throws Exception {
            Mockito.when(mockPropManager.getProperty(
                    eq(TemplateAdapterErrorMsgHandler.PROP_GROUP),
                    eq("TEST_GROUP.in.500.template"))).thenReturn(null);
            Mockito.when(mockPropManager.getProperty(
                    eq(TemplateAdapterErrorMsgHandler.PROP_GROUP),
                    eq("TEST_GROUP.in.template")))
                .thenReturn("{\"error\":\"server error\"}");

            Object result = handler.generateNonStandardInboundErrorResponseMessage(
                    "TEST_GROUP", "TEST_ADAPTER", null, null, null,
                    new RuntimeException("처리 실패"));

            assertEquals("{\"error\":\"server error\"}", result);
        }

        @Test
        @DisplayName("callProp 값이 inbound 템플릿에 치환")
        void callProp_inbound_템플릿_치환() throws Exception {
            Mockito.when(mockPropManager.getProperty(
                    eq(TemplateAdapterErrorMsgHandler.PROP_GROUP),
                    eq("TEST_GROUP.in.500.template")))
                .thenReturn("{\"adapter\":\"${callprop.ADAPTER_NAME}\",\"ifId\":\"${callprop.IF_ID}\"}");

            Properties callProp = props("ADAPTER_NAME", "MY_ADAPTER", "IF_ID", "SVC001");

            Object result = handler.generateNonStandardInboundErrorResponseMessage(
                    "TEST_GROUP", "TEST_ADAPTER", callProp, null, null,
                    new RuntimeException("오류"));

            assertEquals("{\"adapter\":\"MY_ADAPTER\",\"ifId\":\"SVC001\"}", result);
        }

        @Test
        @DisplayName("inbound 템플릿은 msg=null 이므로 StandardMessage 경로는 빈 문자열")
        void inbound_템플릿_stdmsg_경로_빈문자열() throws Exception {
            Mockito.when(mockPropManager.getProperty(
                    eq(TemplateAdapterErrorMsgHandler.PROP_GROUP),
                    eq("TEST_GROUP.in.500.template")))
                .thenReturn("{\"code\":\"${MSG.MAIN_MSG.outp_msg_cd:NONE}\",\"fixed\":\"value\"}");

            Object result = handler.generateNonStandardInboundErrorResponseMessage(
                    "TEST_GROUP", "TEST_ADAPTER", null, null, null,
                    new RuntimeException("오류"));

            // render(template, null, callProp) → StandardMessage 경로는 기본값으로 처리
            assertEquals("{\"code\":\"NONE\",\"fixed\":\"value\"}", result);
        }

        @Test
        @DisplayName("inbound 템플릿에서 ${exception.xxx} 로 예외 필드 참조")
        void inbound_템플릿_exception_치환() throws Exception {
            Mockito.when(mockPropManager.getProperty(
                    eq(TemplateAdapterErrorMsgHandler.PROP_GROUP),
                    eq("TEST_GROUP.in.400.template")))
                .thenReturn("{\"status\":\"${exception.status}\",\"msg\":\"${exception.message}\",\"code\":\"${exception.code}\"}");

            Object result = handler.generateNonStandardInboundErrorResponseMessage(
                    "TEST_GROUP", "TEST_ADAPTER", null, null, null,
                    new HttpStatusException("Bad Request", "ERR_400", 400));

            assertEquals("{\"status\":\"400\",\"msg\":\"Bad Request\",\"code\":\"ERR_400\"}", result);
        }

        @Test
        @DisplayName("JwtAuthException 템플릿에서 ${exception.code} 참조")
        void inbound_jwt_exception_code_치환() throws Exception {
            Mockito.when(mockPropManager.getProperty(
                    eq(TemplateAdapterErrorMsgHandler.PROP_GROUP),
                    eq("TEST_GROUP.in.401.template")))
                .thenReturn("{\"code\":\"${exception.code}\",\"msg\":\"${exception.message}\"}");

            Object result = handler.generateNonStandardInboundErrorResponseMessage(
                    "TEST_GROUP", "TEST_ADAPTER", null, null, null,
                    new JwtAuthException("JWT_EXPIRED", "토큰이 만료되었습니다"));

            assertEquals("{\"code\":\"JWT_EXPIRED\",\"msg\":\"토큰이 만료되었습니다\"}", result);
        }

        @Test
        @DisplayName("템플릿 조회 순서: 전용(httpCode) → 공통(in) 순서로 조회")
        void 템플릿_조회_순서_검증() throws Exception {
            // 두 템플릿 모두 null → MessageUtil 폴백 (조회 순서만 검증)
            handler.generateNonStandardInboundErrorResponseMessage(
                    "TEST_GROUP", "TEST_ADAPTER", null, null, null,
                    new HttpStatusException("Service Unavailable", 503));

            InOrder inOrder = Mockito.inOrder(mockPropManager);
            inOrder.verify(mockPropManager).getProperty(
                    TemplateAdapterErrorMsgHandler.PROP_GROUP, "TEST_GROUP.in.503.template");
            inOrder.verify(mockPropManager).getProperty(
                    TemplateAdapterErrorMsgHandler.PROP_GROUP, "TEST_GROUP.in.template");
        }

        @Test
        @DisplayName("두 템플릿 모두 없으면 MessageUtil 폴백 결과가 non-null")
        void 템플릿_없으면_MessageUtil_폴백() throws Exception {
            // 모든 getProperty → null (기본 설정)
            Object result = handler.generateNonStandardInboundErrorResponseMessage(
                    "TEST_GROUP", "TEST_ADAPTER", null, null, null,
                    new RuntimeException("오류"));

            // MessageUtil.makeErrorMessageByMessageType 가 호출되어 non-null 반환
            assertNotNull(result);
        }
    }
}
