package com.erp.service;

import com.erp.common.BaseService;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

@Service
@RequiredArgsConstructor
@Slf4j
public class ScreenManagementService extends BaseService {

    private static final String NS = "screenManagement.";
    private final ObjectMapper objectMapper;
    private final CommonService commonService;

    // ═══════════════════════════════════════════════════════════
    // Screen CRUD
    // ═══════════════════════════════════════════════════════════

    /**
     * 화면 목록 조회 (페이지네이션 + 필터 + 테이블 레이블 병합)
     */
    public Map<String, Object> getScreens(Map<String, Object> params) {
        int page = toInt(params.getOrDefault("page", 1), 1);
        int size = toInt(params.getOrDefault("size", 20), 20);

        params.put("page",   page);
        params.put("size",   size);
        params.put("offset", (page - 1) * size);

        // searchTerm → searchLike (ILIKE 용 와일드카드)
        Object searchTerm = params.get("search_term");
        if (searchTerm != null && !searchTerm.toString().isBlank()) {
            params.put("search_like", "%" + searchTerm + "%");
        }

        // excludePop: String "true" → Boolean
        Object epRaw = params.get("exclude_pop");
        if (epRaw != null) {
            params.put("exclude_pop", "true".equalsIgnoreCase(epRaw.toString()));
        }

        List<Map<String, Object>> data  = sqlSession.selectList(NS + "selectScreenList", params);
        int total = sqlSession.selectOne(NS + "countScreenList", params);

        // 테이블 레이블 병합
        enrichWithTableLabels(data);

        Map<String, Object> result = new LinkedHashMap<>();
        result.put("data",       data);
        result.put("total",      total);
        result.put("page",       page);
        result.put("size",       size);
        result.put("total_pages", (int) Math.ceil((double) total / size));
        return result;
    }

    /** 단건 조회 (companyCode 무관) */
    public Map<String, Object> getScreenById(Integer screenId) {
        Map<String, Object> params = new HashMap<>();
        params.put("screen_id", screenId);
        return sqlSession.selectOne(NS + "selectScreenById", params);
    }

    /** 화면에 할당된 메뉴 조회 */
    public Map<String, Object> getMenuByScreen(Integer screenId, String companyCode) {
        Map<String, Object> params = new HashMap<>();
        params.put("screen_id",   screenId);
        params.put("company_code", companyCode);
        return sqlSession.selectOne(NS + "selectMenuByScreen", params);
    }

    /** 화면 생성 */
    @Transactional
    public Map<String, Object> createScreen(Map<String, Object> body, String companyCode, String userId) {
        // 화면 코드 중복 체크
        String screenCode = (String) bp(body, "screen_code", "screenCode");
        if (screenCode != null) {
            Map<String, Object> ckParams = new HashMap<>();
            ckParams.put("screen_code", screenCode);
            int cnt = sqlSession.selectOne(NS + "checkScreenCodeExists", ckParams);
            if (cnt > 0) {
                throw new IllegalStateException("DUPLICATE_SCREEN_CODE");
            }
        }

        Map<String, Object> params = new HashMap<>();
        params.put("screen_name",           bp(body, "screen_name", "screenName"));
        params.put("screen_code",           screenCode);
        params.put("table_name",            bp(body, "table_name", "tableName"));
        params.put("company_code",          companyCode);
        params.put("description",          body.get("description"));
        params.put("created_by",            userId);
        params.put("db_source_type",         bp(body, "db_source_type", "dbSourceType"));
        params.put("db_connection_id",       bp(body, "db_connection_id", "dbConnectionId"));
        params.put("data_source_type",       bp(body, "data_source_type", "dataSourceType"));
        params.put("rest_api_connection_id",  bp(body, "rest_api_connection_id", "restApiConnectionId"));
        params.put("rest_api_endpoint",      bp(body, "rest_api_endpoint", "restApiEndpoint"));
        params.put("rest_api_json_path",      bp(body, "rest_api_json_path", "restApiJsonPath"));

        return sqlSession.selectOne(NS + "insertScreen", params);
    }

    /** 화면 수정 */
    @Transactional
    public Map<String, Object> updateScreen(Integer screenId, Map<String, Object> body,
                                            String companyCode, String userId) {
        Map<String, Object> params = new HashMap<>();
        params.put("screen_id",   screenId);
        params.put("screen_name", body.get("screen_name"));
        params.put("description",body.get("description"));
        params.put("is_active",   body.getOrDefault("is_active", "Y"));
        params.put("updated_by",  userId);

        int updated = sqlSession.update(NS + "updateScreen", params);
        if (updated == 0) return null;
        return getScreenById(screenId);
    }

    /** 화면 정보 수정 (확장) */
    @Transactional
    public Map<String, Object> updateScreenInfo(Integer screenId, Map<String, Object> body,
                                                String companyCode, String userId) {
        Map<String, Object> params = new HashMap<>();
        params.put("screen_id",             screenId);
        params.put("screen_name",           body.get("screen_name"));
        params.put("table_name",            body.get("table_name"));
        params.put("description",          body.get("description"));
        params.put("is_active",             body.getOrDefault("is_active", "Y"));
        params.put("data_source_type",       body.get("data_source_type"));
        params.put("db_source_type",         body.get("db_source_type"));
        params.put("db_connection_id",       body.get("db_connection_id"));
        params.put("rest_api_connection_id",  body.get("rest_api_connection_id"));
        params.put("rest_api_endpoint",      body.get("rest_api_endpoint"));
        params.put("rest_api_json_path",      body.get("rest_api_json_path"));

        int updated = sqlSession.update(NS + "updateScreenInfo", params);
        if (updated == 0) return null;
        return getScreenById(screenId);
    }

    /** 화면 테이블명 변경 */
    @Transactional
    public Map<String, Object> updateScreenTableName(Integer screenId, String tableName, String userId) {
        Map<String, Object> params = new HashMap<>();
        params.put("screen_id",  screenId);
        params.put("table_name", tableName);
        int updated = sqlSession.update(NS + "updateScreenTableName", params);
        if (updated == 0) return null;
        return getScreenById(screenId);
    }

    /** 의존성 체크 */
    public Map<String, Object> checkScreenDependencies(Integer screenId, String companyCode) {
        Map<String, Object> params = new HashMap<>();
        params.put("screen_id",    screenId);
        params.put("company_code", companyCode);

        List<Map<String, Object>> menuDeps   = sqlSession.selectList(NS + "selectMenuAssignmentDeps", params);
        List<Map<String, Object>> layoutDeps = sqlSession.selectList(NS + "selectScreensWithLayouts", params);

        // 이 화면을 모달로 사용하는 레이아웃 탐색
        List<Map<String, Object>> linkedScreens = detectLinkedScreensInternal(screenId, companyCode);

        boolean hasDependencies = !menuDeps.isEmpty() || !linkedScreens.isEmpty();

        Map<String, Object> result = new LinkedHashMap<>();
        result.put("has_dependencies", hasDependencies);

        Map<String, Object> deps = new LinkedHashMap<>();
        deps.put("menu_assignments", menuDeps);
        deps.put("linked_screens",   linkedScreens);
        result.put("dependencies", deps);
        return result;
    }

    /** 연결된 모달 화면 감지 */
    public List<Map<String, Object>> detectLinkedScreens(Integer screenId, String companyCode) {
        return detectLinkedScreensInternal(screenId, companyCode);
    }

    /** 화면 소프트 삭제 (휴지통으로 이동) */
    @Transactional
    public void softDeleteScreen(Integer screenId, String userId, String companyCode,
                                  String deleteReason) {
        // flow cleanup: 레이아웃 V2에서 flowId 수집 후 다른 화면에서 미사용 시 삭제
        Map<String, Object> layoutParams = new HashMap<>();
        layoutParams.put("screen_id",    screenId);
        layoutParams.put("company_code", companyCode);
        List<Map<String, Object>> layouts = sqlSession.selectList(NS + "selectLayoutsV2ForFlowCleanup", layoutParams);
        if (!layouts.isEmpty()) {
            Object layoutDataRaw = layouts.get(0).get("layout_data");
            Set<Integer> flowIds = collectFlowIds(layoutDataRaw);
            for (Integer flowId : flowIds) {
                Map<String, Object> usageParams = new HashMap<>();
                usageParams.put("screen_id",    screenId);
                usageParams.put("company_code", companyCode);
                usageParams.put("flow_id",      flowId);
                int usageCount = sqlSession.selectOne(NS + "countFlowUsageInOtherScreens", usageParams);
                if (usageCount == 0) {
                    Map<String, Object> fp = new HashMap<>();
                    fp.put("flow_id",      flowId);
                    fp.put("company_code", companyCode);
                    sqlSession.delete(NS + "deleteFlowStepConnections", fp);
                    sqlSession.delete(NS + "deleteFlowSteps", fp);
                    sqlSession.delete(NS + "deleteFlowDefinition", fp);
                    sqlSession.delete(NS + "deleteNodeFlowsByFlowId", fp);
                    log.info("화면 삭제 시 플로우 삭제: screenId={}, flowId={}", screenId, flowId);
                }
            }
        }

        Map<String, Object> params = new HashMap<>();
        params.put("screen_id",     screenId);
        params.put("deleted_by",    userId);
        params.put("delete_reason", deleteReason);
        sqlSession.update(NS + "softDeleteScreen", params);
        sqlSession.update(NS + "deactivateMenuAssignmentsByScreen", params);
        sqlSession.delete(NS + "deleteScreenGroupLinks", params);
    }

    /** 화면 일괄 소프트 삭제 */
    @Transactional
    public void bulkSoftDeleteScreens(List<Integer> screenIds, String userId, String companyCode) {
        for (Integer screenId : screenIds) {
            softDeleteScreen(screenId, userId, companyCode, null);
        }
    }

    /** 화면명 중복 체크 */
    public boolean checkDuplicateScreenName(String screenName, String companyCode) {
        Map<String, Object> params = new HashMap<>();
        params.put("screen_name",  screenName);
        params.put("company_code", companyCode);
        int cnt = sqlSession.selectOne(NS + "checkDuplicateScreenName", params);
        return cnt > 0;
    }

    // ═══════════════════════════════════════════════════════════
    // 휴지통 (Trash)
    // ═══════════════════════════════════════════════════════════

    /** 삭제된 화면 목록 (휴지통) */
    public Map<String, Object> getDeletedScreens(Map<String, Object> params) {
        int page = toInt(params.getOrDefault("page", 1), 1);
        int size = toInt(params.getOrDefault("size", 20), 20);
        params.put("size",   size);
        params.put("offset", (page - 1) * size);

        List<Map<String, Object>> data  = sqlSession.selectList(NS + "selectDeletedScreenList", params);
        int total = sqlSession.selectOne(NS + "countDeletedScreenList", params);
        // snake_case 통일: camelCase 변환 제거

        Map<String, Object> result = new LinkedHashMap<>();
        result.put("data",       data);
        result.put("total",      total);
        result.put("page",       page);
        result.put("size",       size);
        result.put("total_pages", (int) Math.ceil((double) total / size));
        return result;
    }

    /** 화면 복원 */
    @Transactional
    public Map<String, Object> restoreScreen(Integer screenId, String userId, String companyCode) {
        Map<String, Object> screen = getScreenById(screenId);
        if (screen == null) throw new IllegalArgumentException("화면을 찾을 수 없습니다.");

        // 동일 screen_code가 이미 활성인지 체크
        String screenCode = (String) screen.get("screen_code");
        if (screenCode != null) {
            Map<String, Object> ckParams = new HashMap<>();
            ckParams.put("screen_code", screenCode);
            ckParams.put("screen_id",   screenId);
            int cnt = sqlSession.selectOne(NS + "checkScreenCodeForRestore", ckParams);
            if (cnt > 0) {
                throw new IllegalStateException("SCREEN_CODE_CONFLICT");
            }
        }

        Map<String, Object> params = new HashMap<>();
        params.put("screen_id",    screenId);
        params.put("restored_by",  userId);
        sqlSession.update(NS + "restoreScreen", params);
        sqlSession.update(NS + "reactivateMenuAssignmentsByScreen", params);
        return getScreenById(screenId);
    }

    /** 영구 삭제 (단건) */
    @Transactional
    public void permanentDeleteScreen(Integer screenId) {
        Map<String, Object> params = new HashMap<>();
        params.put("screen_id", screenId);
        sqlSession.delete(NS + "permanentDeleteLayoutV1", params);
        sqlSession.delete(NS + "permanentDeleteMenuAssignmentsByScreen", params);
        sqlSession.delete(NS + "permanentDeleteScreen", params);
    }

    /** 영구 삭제 (일괄) */
    @Transactional
    public void bulkPermanentDeleteScreens(List<Integer> screenIds) {
        for (Integer screenId : screenIds) {
            permanentDeleteScreen(screenId);
        }
    }

    // ═══════════════════════════════════════════════════════════
    // 화면 코드 생성
    // ═══════════════════════════════════════════════════════════

    /** 화면 코드 단건 생성 */
    @Transactional
    public String generateScreenCode(String companyCode) {
        // Advisory lock
        int lockId = companyCode.chars().sum();
        Map<String, Object> lockParams = new HashMap<>();
        lockParams.put("lock_id", (long) lockId);
        sqlSession.selectOne(NS + "pgAdvisoryXactLock", lockParams);

        return calcNextScreenCode(companyCode);
    }

    /** 화면 코드 일괄 생성 */
    @Transactional
    public List<String> generateMultipleScreenCodes(String companyCode, int count) {
        int lockId = companyCode.chars().sum();
        Map<String, Object> lockParams = new HashMap<>();
        lockParams.put("lock_id", (long) lockId);
        sqlSession.selectOne(NS + "pgAdvisoryXactLock", lockParams);

        Map<String, Object> params = new HashMap<>();
        params.put("pattern", companyCode + "_%");
        List<Map<String, Object>> existing = sqlSession.selectList(NS + "selectScreenCodesByPattern", params);

        Pattern numPattern = Pattern.compile("^" + Pattern.quote(companyCode) + "_(\\d+)$");
        int maxNumber = existing.stream()
            .map(row -> String.valueOf(row.get("screen_code")))
            .mapToInt(code -> {
                Matcher m = numPattern.matcher(code);
                return m.matches() ? Integer.parseInt(m.group(1)) : 0;
            })
            .max()
            .orElse(0);

        List<String> codes = new ArrayList<>();
        for (int i = 1; i <= count; i++) {
            codes.add(companyCode + "_" + (maxNumber + i));
        }
        return codes;
    }

    // ═══════════════════════════════════════════════════════════
    // 화면 복사
    // ═══════════════════════════════════════════════════════════

    /** 화면 단건 복사 */
    @Transactional
    public Map<String, Object> copyScreen(Integer screenId, String newName,
                                          String companyCode, String userId) {
        Map<String, Object> original = sqlSession.selectOne(NS + "selectScreenForCopy", Map.of("screen_id", screenId));
        if (original == null) throw new IllegalArgumentException("복사할 화면을 찾을 수 없습니다.");

        String newCode = generateScreenCode(companyCode);

        Map<String, Object> insertParams = new HashMap<>();
        insertParams.put("screen_name",          newName != null ? newName : original.get("screen_name") + " Copy");
        insertParams.put("screen_code",          newCode);
        insertParams.put("table_name",           original.get("table_name"));
        insertParams.put("company_code",         companyCode);
        insertParams.put("description",         original.get("description"));
        insertParams.put("created_by",           userId);
        insertParams.put("db_source_type",        original.get("db_source_type"));
        insertParams.put("db_connection_id",      original.get("db_connection_id"));
        insertParams.put("data_source_type",      original.get("data_source_type"));
        insertParams.put("rest_api_connection_id", original.get("rest_api_connection_id"));
        insertParams.put("rest_api_endpoint",     original.get("rest_api_endpoint"));
        insertParams.put("rest_api_json_path",     original.get("rest_api_json_path"));

        Map<String, Object> newScreen = sqlSession.selectOne(NS + "insertScreenCopy", insertParams);
        Integer newScreenId = toInteger(newScreen.get("screen_id"));

        // Layout V1 복사
        List<Map<String, Object>> v1Rows = sqlSession.selectList(NS + "selectLayoutV1ForCopy", Map.of("screen_id", screenId));
        for (Map<String, Object> row : v1Rows) {
            Map<String, Object> v1p = new HashMap<>(row);
            v1p.put("screen_id", newScreenId);
            sqlSession.insert(NS + "insertLayoutV1Copy", v1p);
        }

        // Layout V2 복사
        List<Map<String, Object>> v2Rows = sqlSession.selectList(NS + "selectLayoutV2ForCopy", Map.of("screen_id", screenId));
        for (Map<String, Object> row : v2Rows) {
            Map<String, Object> v2p = new HashMap<>(row);
            v2p.put("screen_id",    newScreenId);
            v2p.put("company_code", companyCode);
            sqlSession.insert(NS + "insertLayoutV2Copy", v2p);
        }

        return newScreen;
    }

    /** 메인 화면 + 모달 화면 일괄 복사 */
    @Transactional
    public Map<String, Object> copyScreenWithModals(Integer screenId, Map<String, Object> body,
                                                    String companyCode, String userId) {
        String newBaseName = (String) body.get("new_base_name");

        // 메인 화면 복사
        Map<String, Object> mainScreen = copyScreen(screenId, newBaseName, companyCode, userId);
        Integer newMainScreenId = toInteger(mainScreen.get("screen_id"));

        // 연결된 모달 화면들 탐색 후 복사
        List<Map<String, Object>> linkedScreens = detectLinkedScreensInternal(screenId, companyCode);
        Map<Integer, Integer> idMapping = new LinkedHashMap<>();
        idMapping.put(screenId, newMainScreenId);

        List<Map<String, Object>> copiedScreens = new ArrayList<>();
        for (Map<String, Object> linked : linkedScreens) {
            Integer linkedId = toInteger(linked.get("screen_id"));
            String linkedName = (String) linked.get("screen_name");
            Map<String, Object> copied = copyScreen(linkedId, linkedName + " Copy", companyCode, userId);
            idMapping.put(linkedId, toInteger(copied.get("screen_id")));
            copiedScreens.add(copied);
        }

        // 탭 컴포넌트 내 screenId 참조 업데이트
        if (!idMapping.isEmpty()) {
            updateTabReferencesForScreens(new ArrayList<>(idMapping.values()), idMapping, companyCode);
        }

        Map<String, Object> result = new LinkedHashMap<>();
        result.put("main_screen",    mainScreen);
        result.put("copied_screens", copiedScreens);
        result.put("id_mapping",     idMapping);
        return result;
    }

    // ═══════════════════════════════════════════════════════════
    // 테이블 조회
    // ═══════════════════════════════════════════════════════════

    public List<Map<String, Object>> getTables() {
        return sqlSession.selectList(NS + "selectPublicTables", new HashMap<>());
    }

    public Map<String, Object> getTableInfo(String tableName) {
        Map<String, Object> params = new HashMap<>();
        params.put("table_name", tableName);
        return sqlSession.selectOne(NS + "selectTableInfoByName", params);
    }

    public Map<String, Object> getTableColumns(String tableName, String companyCode) {
        Map<String, Object> params = new HashMap<>();
        params.put("table_name",   tableName);
        params.put("company_code", companyCode);

        List<Map<String, Object>> columns     = sqlSession.selectList(NS + "selectTableColumnsInfo", params);
        List<Map<String, Object>> typeColumns = sqlSession.selectList(NS + "selectTableTypeColumnsInfo", params);

        Map<String, Object> result = new LinkedHashMap<>();
        result.put("columns",     columns);
        result.put("type_columns", typeColumns);
        return result;
    }

    // ═══════════════════════════════════════════════════════════
    // Layout V1
    // ═══════════════════════════════════════════════════════════

    @Transactional
    public void saveLayout(Integer screenId, List<Map<String, Object>> components, String userId) {
        Map<String, Object> params = new HashMap<>();
        params.put("screen_id", screenId);
        sqlSession.delete(NS + "deleteAllLayoutV1", params);

        for (int i = 0; i < components.size(); i++) {
            Map<String, Object> comp = components.get(i);
            Map<String, Object> ip = new HashMap<>(comp);
            ip.put("screen_id",     screenId);
            ip.put("display_order", toInt(comp.getOrDefault("display_order", i), i));
            if (ip.get("properties") != null && !(ip.get("properties") instanceof String)) {
                ip.put("properties", toJsonString(ip.get("properties")));
            }
            sqlSession.insert(NS + "insertLayoutV1Component", ip);
        }
    }

    public List<Map<String, Object>> getLayoutV1(Integer screenId) {
        Map<String, Object> params = new HashMap<>();
        params.put("screen_id", screenId);
        return sqlSession.selectList(NS + "selectLayoutV1ByScreen", params);
    }

    /** GET /layout-v1 — SCREEN_LAYOUTS_V1 조회, component_url/custom_config 변환 후 반환 */
    @SuppressWarnings("unchecked")
    public Map<String, Object> getLayoutV1WithTransform(Integer screenId, String companyCode) {
        Map<String, Object> screen = getScreenById(screenId);
        if (screen == null) return null;

        Map<String, Object> params = new HashMap<>();
        params.put("screen_id",    screenId);
        params.put("company_code", companyCode);
        List<Map<String, Object>> layouts = sqlSession.selectList(NS + "selectLayoutV1FromV1Table", params);

        List<Map<String, Object>> components = new ArrayList<>();
        for (Map<String, Object> layout : layouts) {
            String componentUrl = layout.getOrDefault("component_url", "").toString();
            String componentType = componentUrl.isEmpty() ? "unknown"
                    : componentUrl.substring(componentUrl.lastIndexOf('/') + 1);

            Object rawConfig = layout.get("custom_config");
            Map<String, Object> componentConfig;
            if (rawConfig == null) {
                componentConfig = new HashMap<>();
            } else if (rawConfig instanceof Map) {
                componentConfig = (Map<String, Object>) rawConfig;
            } else {
                try {
                    componentConfig = objectMapper.readValue(rawConfig.toString(), Map.class);
                } catch (Exception e) {
                    componentConfig = new HashMap<>();
                }
            }

            Map<String, Object> position = new LinkedHashMap<>();
            position.put("x", toInt(layout.getOrDefault("position_x", 0), 0));
            position.put("y", toInt(layout.getOrDefault("position_y", 0), 0));
            position.put("z", 1);

            Map<String, Object> size = new LinkedHashMap<>();
            size.put("width",  toInt(layout.getOrDefault("width", 200), 200));
            size.put("height", toInt(layout.getOrDefault("height", 100), 100));

            Map<String, Object> comp = new LinkedHashMap<>();
            comp.put("id",              layout.get("component_id"));
            comp.put("type",            componentType);
            comp.put("component_type",   componentType);
            comp.put("component_url",    componentUrl);
            comp.put("position",        position);
            comp.put("size",            size);
            comp.put("parent_id",        layout.get("parent_id"));
            comp.put("component_config", componentConfig);
            components.add(comp);
        }

        Map<String, Object> result = new LinkedHashMap<>();
        result.put("components",       components);
        result.put("grid_settings",     buildDefaultGridSettings());
        result.put("screen_resolution", null);
        result.put("table_name",        screen.get("table_name"));
        return result;
    }

    /** GET /layout — V2 3단계 fallback 조회, 없으면 V1을 { components: [...] } 로 감싸서 반환 */
    @SuppressWarnings("unchecked")
    public Map<String, Object> getLayout(Integer screenId, String companyCode) {
        // Stage 1: 정확한 company_code 매칭
        Map<String, Object> v2 = getLayoutV2Exact(screenId, companyCode, 1);

        // Stage 2: 슈퍼어드민일 때 화면 소유 회사 코드로 재조회
        if (v2 == null && "*".equals(companyCode)) {
            Map<String, Object> screen = getScreenById(screenId);
            if (screen != null) {
                String screenCompanyCode = (String) screen.get("company_code");
                if (screenCompanyCode != null && !"*".equals(screenCompanyCode)) {
                    v2 = getLayoutV2Exact(screenId, screenCompanyCode, 1);
                }
            }
        }

        // Stage 3: 일반 사용자일 때 공통('*') 레이아웃 조회
        if (v2 == null && !"*".equals(companyCode)) {
            v2 = getLayoutV2Exact(screenId, "*", 1);
        }

        if (v2 != null) {
            if (!v2.containsKey("grid_settings") || v2.get("grid_settings") == null) {
                v2.put("grid_settings", buildDefaultGridSettings());
            }
            if (!v2.containsKey("screen_resolution") || v2.get("screen_resolution") == null) {
                Object compsObj = v2.get("components");
                if (compsObj instanceof List && !((List<?>) compsObj).isEmpty()) {
                    List<Map<String, Object>> comps = (List<Map<String, Object>>) compsObj;
                    int maxRight = 0, maxBottom = 0;
                    for (Map<String, Object> comp : comps) {
                        Object posObj = comp.get("position");
                        Object szObj  = comp.get("size");
                        if (posObj instanceof Map && szObj instanceof Map) {
                            Map<String, Object> pos = (Map<String, Object>) posObj;
                            Map<String, Object> sz  = (Map<String, Object>) szObj;
                            int right  = toInt(pos.getOrDefault("x", 0), 0) + toInt(sz.getOrDefault("width", 200), 200);
                            int bottom = toInt(pos.getOrDefault("y", 0), 0) + toInt(sz.getOrDefault("height", 100), 100);
                            maxRight  = Math.max(maxRight, right);
                            maxBottom = Math.max(maxBottom, bottom);
                        }
                    }
                    Map<String, Object> res = new LinkedHashMap<>();
                    res.put("width",  Math.max(1200, maxRight  + 100));
                    res.put("height", Math.max(800,  maxBottom + 100));
                    v2.put("screen_resolution", res);
                }
            }
            return v2;
        }

        List<Map<String, Object>> v1 = getLayoutV1(screenId);
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("components",       v1 != null ? v1 : Collections.emptyList());
        result.put("grid_settings",     buildDefaultGridSettings());
        result.put("screen_resolution", null);
        return result;
    }

    private Map<String, Object> buildDefaultGridSettings() {
        Map<String, Object> gs = new LinkedHashMap<>();
        gs.put("columns",    12);
        gs.put("gap",        16);
        gs.put("padding",    16);
        gs.put("snap_to_grid", true);
        gs.put("show_grid",   true);
        return gs;
    }

    // ═══════════════════════════════════════════════════════════
    // Layout V2
    // ═══════════════════════════════════════════════════════════

    @SuppressWarnings("unchecked")
    public Map<String, Object> getLayoutV2(Integer screenId, String companyCode, Integer layerId) {
        Map<String, Object> params = new HashMap<>();
        params.put("screen_id",    screenId);
        params.put("company_code", companyCode);
        if (layerId != null) params.put("layer_id", layerId);

        Map<String, Object> row = sqlSession.selectOne(NS + "selectLayoutV2", params);
        if (row == null) return null;

        Object layoutDataRaw = row.get("layout_data");
        if (layoutDataRaw == null) return null;

        try {
            return objectMapper.readValue(layoutDataRaw.toString(), Map.class);
        } catch (Exception e) {
            log.warn("layout_data JSON 파싱 실패: {}", e.getMessage());
            return null;
        }
    }

    @SuppressWarnings("unchecked")
    private Map<String, Object> getLayoutV2Exact(Integer screenId, String companyCode, Integer layerId) {
        Map<String, Object> params = new HashMap<>();
        params.put("screen_id",    screenId);
        params.put("company_code", companyCode);
        if (layerId != null) params.put("layer_id", layerId);

        Map<String, Object> row = sqlSession.selectOne(NS + "selectLayoutV2Exact", params);
        if (row == null) return null;

        Object layoutDataRaw = row.get("layout_data");
        if (layoutDataRaw == null) return null;

        try {
            return objectMapper.readValue(layoutDataRaw.toString(), Map.class);
        } catch (Exception e) {
            log.warn("layout_data JSON 파싱 실패 (exact): {}", e.getMessage());
            return null;
        }
    }

    @Transactional
    public void saveLayoutV2(Integer screenId, Map<String, Object> body,
                             String companyCode, String userId) {
        String layoutData = toJsonString(body.get("layout_data"));
        Integer layerId   = toInt(body.getOrDefault("layer_id", 1), 1);
        String layerName  = (String) body.getOrDefault("layer_name", "기본 레이어");

        Map<String, Object> params = new HashMap<>();
        params.put("screen_id",   screenId);
        params.put("company_code", companyCode);
        params.put("layer_id",    layerId);
        params.put("layer_name",  layerName);
        params.put("layout_data", layoutData);

        Object conditionConfig = body.get("condition_config");
        if (conditionConfig != null) {
            params.put("condition_config", toJsonString(conditionConfig));
            sqlSession.insert(NS + "upsertLayoutV2WithCondition", params);
        } else {
            sqlSession.insert(NS + "upsertLayoutV2", params);
        }

        // 메인 테이블 추출
        Object tableName = body.get("main_table_name");
        if (tableName != null) {
            Map<String, Object> tp = new HashMap<>();
            tp.put("screen_id",  screenId);
            tp.put("table_name", tableName);
            sqlSession.update(NS + "updateScreenMainTable", tp);
        }
    }

    // ═══════════════════════════════════════════════════════════
    // POP Layout
    // ═══════════════════════════════════════════════════════════

    public Map<String, Object> getLayoutPop(Integer screenId, String companyCode) {
        Map<String, Object> params = new HashMap<>();
        params.put("screen_id",    screenId);
        params.put("company_code", companyCode);
        return sqlSession.selectOne(NS + "selectLayoutPop", params);
    }

    @Transactional
    public void saveLayoutPop(Integer screenId, Map<String, Object> body,
                              String companyCode, String userId) {
        String layoutData = toJsonString(body.get("layout_data"));
        Map<String, Object> params = new HashMap<>();
        params.put("screen_id",    screenId);
        params.put("company_code", companyCode);
        params.put("layout_data",  layoutData);
        params.put("user_id",      userId);
        sqlSession.insert(NS + "upsertLayoutPop", params);
    }

    @Transactional
    public void deleteLayoutPop(Integer screenId, String companyCode) {
        Map<String, Object> params = new HashMap<>();
        params.put("screen_id",    screenId);
        params.put("company_code", companyCode);
        sqlSession.delete(NS + "deleteLayoutPopByScreen", params);
    }

    public List<Map<String, Object>> getScreenIdsWithPopLayout(String companyCode) {
        Map<String, Object> params = new HashMap<>();
        params.put("company_code", companyCode);
        return sqlSession.selectList(NS + "selectScreenIdsWithPopLayout", params);
    }

    // ═══════════════════════════════════════════════════════════
    // Layer management
    // ═══════════════════════════════════════════════════════════

    public List<Map<String, Object>> getScreenLayers(Integer screenId, String companyCode) {
        Map<String, Object> params = new HashMap<>();
        params.put("screen_id",    screenId);
        params.put("company_code", companyCode);
        return sqlSession.selectList(NS + "selectLayersByScreen", params);
    }

    public Map<String, Object> getLayerLayout(Integer screenId, Integer layerId, String companyCode) {
        Map<String, Object> params = new HashMap<>();
        params.put("screen_id",    screenId);
        params.put("layer_id",     layerId);
        params.put("company_code", companyCode);
        return sqlSession.selectOne(NS + "selectLayerLayout", params);
    }

    @Transactional
    public boolean deleteLayer(Integer screenId, Integer layerId, String companyCode) {
        Map<String, Object> params = new HashMap<>();
        params.put("screen_id",    screenId);
        params.put("layer_id",     layerId);
        params.put("company_code", companyCode);
        return sqlSession.delete(NS + "deleteLayerById", params) > 0;
    }

    @Transactional
    public void updateLayerCondition(Integer screenId, Integer layerId,
                                     Map<String, Object> body, String companyCode) {
        Map<String, Object> params = new HashMap<>();
        params.put("screen_id",       screenId);
        params.put("layer_id",        layerId);
        params.put("company_code",    companyCode);
        params.put("condition_config", toJsonString(body.get("condition_config")));
        if (body.containsKey("layer_name")) params.put("layer_name", body.get("layer_name"));
        sqlSession.update(NS + "updateLayerCondition", params);
    }

    // ═══════════════════════════════════════════════════════════
    // Zone management
    // ═══════════════════════════════════════════════════════════

    public List<Map<String, Object>> getScreenZones(Integer screenId, String companyCode) {
        Map<String, Object> params = new HashMap<>();
        params.put("screen_id",    screenId);
        params.put("company_code", companyCode);
        return sqlSession.selectList(NS + "selectZonesByScreen", params);
    }

    @Transactional
    public Map<String, Object> createZone(Integer screenId, Map<String, Object> body, String companyCode) {
        Map<String, Object> params = new HashMap<>();
        params.put("screen_id",           screenId);
        params.put("company_code",        companyCode);
        params.put("zone_name",           body.get("zone_name"));
        params.put("x",                  body.get("x"));
        params.put("y",                  body.get("y"));
        params.put("width",              body.get("width"));
        params.put("height",             body.get("height"));
        params.put("trigger_component_id", body.get("trigger_component_id"));
        params.put("trigger_operator",    body.get("trigger_operator"));

        Map<String, Object> zone = sqlSession.selectOne(NS + "insertZone", params);

        // 새 레이어 생성 (zone에 연결)
        Integer newLayerId = calcNextLayerId(screenId, companyCode);
        Map<String, Object> layerParams = new HashMap<>();
        layerParams.put("screen_id",       screenId);
        layerParams.put("company_code",    companyCode);
        layerParams.put("layer_id",        newLayerId);
        layerParams.put("layer_name",      body.getOrDefault("zone_name", "새 레이어"));
        layerParams.put("layout_data",     "{\"components\":[]}");
        layerParams.put("condition_config", "{}");
        sqlSession.insert(NS + "insertLayerForZone", layerParams);

        Map<String, Object> result = new LinkedHashMap<>();
        result.put("zone",    zone);
        result.put("layer_id", newLayerId);
        return result;
    }

    @Transactional
    public boolean updateZone(Integer zoneId, Map<String, Object> body, String companyCode) {
        Map<String, Object> params = new HashMap<>(body);
        params.put("zone_id",      zoneId);
        params.put("company_code", companyCode);
        return sqlSession.update(NS + "updateZone", params) > 0;
    }

    @Transactional
    public boolean deleteZone(Integer zoneId, String companyCode) {
        // zone_id를 참조하는 condition_config 초기화
        Map<String, Object> clearParams = new HashMap<>();
        clearParams.put("zone_id_str",  String.valueOf(zoneId));
        clearParams.put("company_code", companyCode);
        sqlSession.update(NS + "clearZoneConditionConfigs", clearParams);

        Map<String, Object> params = new HashMap<>();
        params.put("zone_id",      zoneId);
        params.put("company_code", companyCode);
        return sqlSession.delete(NS + "deleteZoneById", params) > 0;
    }

    @Transactional
    public Map<String, Object> addLayerToZone(Integer screenId, Integer zoneId,
                                               Map<String, Object> body, String companyCode) {
        Integer newLayerId = calcNextLayerId(screenId, companyCode);
        Map<String, Object> params = new HashMap<>();
        params.put("screen_id",       screenId);
        params.put("company_code",    companyCode);
        params.put("layer_id",        newLayerId);
        params.put("layer_name",      body.getOrDefault("layer_name", "새 레이어"));
        params.put("layout_data",     "{\"components\":[]}");
        params.put("condition_config", toJsonString(body.getOrDefault("condition_config", new HashMap<>())));
        sqlSession.insert(NS + "insertLayerForZone", params);

        Map<String, Object> result = new LinkedHashMap<>();
        result.put("layer_id", newLayerId);
        result.put("zone_id",  zoneId);
        return result;
    }

    // ═══════════════════════════════════════════════════════════
    // Menu assignment
    // ═══════════════════════════════════════════════════════════

    @Transactional
    public Map<String, Object> assignScreenToMenu(Integer screenId, Map<String, Object> body,
                                                   String companyCode, String userId) {
        String menuObjid = String.valueOf(body.get("menu_objid"));

        // 기존 할당 체크
        Map<String, Object> ckParams = new HashMap<>();
        ckParams.put("screen_id",    screenId);
        ckParams.put("menu_objid",   menuObjid);
        ckParams.put("company_code", companyCode);
        Map<String, Object> existing = sqlSession.selectOne(NS + "checkMenuAssignmentExists", ckParams);
        if (existing != null) throw new IllegalStateException("ALREADY_ASSIGNED");

        Map<String, Object> params = new HashMap<>();
        params.put("screen_id",     screenId);
        params.put("menu_objid",    menuObjid);
        params.put("company_code",  companyCode);
        params.put("display_order", body.getOrDefault("display_order", 0));
        params.put("created_by",    userId);
        sqlSession.insert(NS + "insertMenuAssignment", params);

        // screen_code 조회 → menu_info 업데이트
        Map<String, Object> screenCodeRow = sqlSession.selectOne(NS + "selectScreenCode", Map.of("screen_id", screenId));
        String screenCode = screenCodeRow != null ? (String) screenCodeRow.get("screen_code") : null;

        Map<String, Object> menuTypeRow = sqlSession.selectOne(NS + "selectMenuType", Map.of("menu_objid", menuObjid));
        String menuType = menuTypeRow != null ? (String) menuTypeRow.get("menu_type") : null;

        if (screenCode != null) {
            String menuUrl = "/screen/" + screenCode;
            Map<String, Object> updateParams = new HashMap<>();
            updateParams.put("menu_objid",   menuObjid);
            updateParams.put("menu_url",     menuUrl);
            updateParams.put("screen_code",  screenCode);
            sqlSession.update(NS + "updateMenuInfoAssign", updateParams);
        }

        Map<String, Object> result = new LinkedHashMap<>();
        result.put("screen_id",    screenId);
        result.put("menu_objid",   menuObjid);
        result.put("company_code", companyCode);
        result.put("screen_code",  screenCode);
        return result;
    }

    public List<Map<String, Object>> getScreensByMenu(String menuObjid, String companyCode) {
        Map<String, Object> params = new HashMap<>();
        params.put("menu_objid",   menuObjid);
        params.put("company_code", companyCode);
        return sqlSession.selectList(NS + "selectScreensByMenu", params);
    }

    @Transactional
    public void unassignScreenFromMenu(Integer screenId, String menuObjid, String companyCode) {
        Map<String, Object> params = new HashMap<>();
        params.put("screen_id",    screenId);
        params.put("menu_objid",   menuObjid);
        params.put("company_code", companyCode);
        sqlSession.delete(NS + "deleteMenuAssignment", params);
        sqlSession.update(NS + "updateMenuInfoUnassign", Map.of("menu_objid", menuObjid));
    }

    @Transactional
    public int cleanupDeletedMenuAssignments() {
        return sqlSession.update(NS + "cleanupDeletedMenuAssignments", new HashMap<>());
    }

    // ═══════════════════════════════════════════════════════════
    // 탭 컴포넌트 screenId 참조 업데이트
    // ═══════════════════════════════════════════════════════════

    @Transactional
    public void updateTabScreenReferences(Map<String, Object> body) {
        @SuppressWarnings("unchecked")
        Map<String, Object> idMappingRaw = (Map<String, Object>) body.get("id_mapping");
        @SuppressWarnings("unchecked")
        List<Integer> screenIds = (List<Integer>) body.get("screen_ids");
        String companyCode = (String) body.get("company_code");

        if (idMappingRaw == null || screenIds == null) return;

        Map<Integer, Integer> idMapping = new LinkedHashMap<>();
        for (Map.Entry<String, Object> entry : idMappingRaw.entrySet()) {
            idMapping.put(Integer.parseInt(entry.getKey()), toInteger(entry.getValue()));
        }

        updateTabReferencesForScreens(screenIds, idMapping, companyCode);
    }

    // ═══════════════════════════════════════════════════════════
    // 복제 작업 (메뉴 할당, 코드, 카테고리, 관계)
    // ═══════════════════════════════════════════════════════════

    @Transactional
    public int copyScreenMenuAssignments(Map<String, Object> body) {
        @SuppressWarnings("unchecked")
        List<Integer> screenIds = (List<Integer>) body.get("screen_ids");
        String sourceCompanyCode = (String) body.get("source_company_code");
        String targetCompanyCode = (String) body.get("target_company_code");
        String userId            = (String) body.get("user_id");

        Map<String, Object> params = new HashMap<>();
        params.put("screen_ids",         screenIds);
        params.put("source_company_code", sourceCompanyCode);
        List<Map<String, Object>> assignments = sqlSession.selectList(NS + "selectMenuAssignmentsForCopy", params);

        int count = 0;
        for (Map<String, Object> a : assignments) {
            Map<String, Object> ip = new HashMap<>();
            ip.put("screen_id",    a.get("screen_id"));
            ip.put("menu_objid",   a.get("menu_objid"));
            ip.put("company_code", targetCompanyCode);
            ip.put("display_order", a.get("display_order"));
            ip.put("created_by",   userId);
            sqlSession.insert(NS + "insertMenuAssignmentCopy", ip);
            count++;
        }
        return count;
    }

    @Transactional
    public int copyCodeInfoAndCodes(Map<String, Object> body) {
        String sourceCompanyCode = (String) body.get("source_company_code");
        String targetCompanyCode = (String) body.get("target_company_code");
        String userId            = (String) body.get("user_id");

        Map<String, Object> params = new HashMap<>();
        params.put("source_company_code", sourceCompanyCode);

        List<Map<String, Object>> categories = sqlSession.selectList(NS + "selectCodeInfoForCopy", params);
        int count = 0;
        for (Map<String, Object> cat : categories) {
            Map<String, Object> cp = new HashMap<>(cat);
            cp.put("target_company_code", targetCompanyCode);
            sqlSession.insert(NS + "upsertCodeInfo", cp);

            Map<String, Object> codeParams = new HashMap<>();
            codeParams.put("source_company_code", sourceCompanyCode);
            codeParams.put("code_info",      cat.get("category_code"));
            List<Map<String, Object>> codes = sqlSession.selectList(NS + "selectCodeInfoForCopy", codeParams);
            for (Map<String, Object> code : codes) {
                Map<String, Object> cop = new HashMap<>(code);
                cop.put("target_company_code", targetCompanyCode);
                sqlSession.insert(NS + "upsertCodeInfo", cop);
                count++;
            }
        }
        return count;
    }

    @Transactional
    public int copyCategoryMapping(Map<String, Object> body) {
        String sourceCompanyCode = (String) body.get("source_company_code");
        String targetCompanyCode = (String) body.get("target_company_code");

        Map<String, Object> params = new HashMap<>();
        params.put("source_company_code", sourceCompanyCode);

        List<Map<String, Object>> trees = sqlSession.selectList(NS + "selectCategoryTreeForCopy", params);
        for (Map<String, Object> t : trees) {
            Map<String, Object> tp = new HashMap<>(t);
            tp.put("target_company_code", targetCompanyCode);
            sqlSession.insert(NS + "upsertCategoryTree", tp);
        }

        List<Map<String, Object>> values = sqlSession.selectList(NS + "selectCategoryValueForCopy", params);
        for (Map<String, Object> v : values) {
            Map<String, Object> vp = new HashMap<>(v);
            vp.put("target_company_code", targetCompanyCode);
            sqlSession.insert(NS + "upsertCategoryValue", vp);
        }
        return trees.size() + values.size();
    }

    @Transactional
    public int copyTableTypeColumns(Map<String, Object> body) {
        String sourceCompanyCode = (String) body.get("source_company_code");
        String targetCompanyCode = (String) body.get("target_company_code");

        Map<String, Object> params = new HashMap<>();
        params.put("source_company_code", sourceCompanyCode);

        List<Map<String, Object>> cols = sqlSession.selectList(NS + "selectTableTypeColumnsForCopy", params);
        for (Map<String, Object> col : cols) {
            Map<String, Object> cp = new HashMap<>(col);
            cp.put("target_company_code", targetCompanyCode);
            sqlSession.insert(NS + "upsertTableTypeColumn", cp);
        }
        return cols.size();
    }

    @Transactional
    public int copyCascadingRelation(Map<String, Object> body) {
        String sourceCompanyCode = (String) body.get("source_company_code");
        String targetCompanyCode = (String) body.get("target_company_code");

        Map<String, Object> params = new HashMap<>();
        params.put("source_company_code", sourceCompanyCode);

        List<Map<String, Object>> rels = sqlSession.selectList(NS + "selectCascadingRelationForCopy", params);
        for (Map<String, Object> rel : rels) {
            Map<String, Object> rp = new HashMap<>(rel);
            rp.put("target_company_code", targetCompanyCode);
            sqlSession.insert(NS + "upsertCascadingRelation", rp);
        }
        return rels.size();
    }

    // ═══════════════════════════════════════════════════════════
    // POP 화면 배포
    // ═══════════════════════════════════════════════════════════

    public Map<String, Object> analyzePopScreenLinks(Integer screenId, String companyCode) {
        Map<String, Object> params = new HashMap<>();
        params.put("screen_id",    screenId);
        params.put("company_code", companyCode);
        Map<String, Object> popLayout = sqlSession.selectOne(NS + "selectLayoutPop", params);

        List<Integer> linkedScreenIds = new ArrayList<>();
        if (popLayout != null) {
            Object layoutDataRaw = popLayout.get("layout_data");
            linkedScreenIds = extractScreenIdsFromLayout(layoutDataRaw);
        }

        List<Map<String, Object>> linkedScreens = new ArrayList<>();
        if (!linkedScreenIds.isEmpty()) {
            Map<String, Object> idsParams = new HashMap<>();
            idsParams.put("ids", linkedScreenIds);
            linkedScreens = sqlSession.selectList(NS + "selectScreensByIds", idsParams);
        }

        Map<String, Object> result = new LinkedHashMap<>();
        result.put("screen_id",      screenId);
        result.put("linked_screens", linkedScreens);
        return result;
    }

    @Transactional
    public Map<String, Object> deployPopScreens(Map<String, Object> body, String userId) {
        @SuppressWarnings("unchecked")
        List<Map<String, Object>> screens = (List<Map<String, Object>>) body.get("screens");
        String targetCompanyCode = (String) body.get("target_company_code");

        List<Map<String, Object>> deployed = new ArrayList<>();
        for (Map<String, Object> screenDef : screens) {
            String screenCode = (String) screenDef.get("screen_code");
            String sourceCompanyCode = (String) screenDef.get("source_company_code");

            // 대상 회사에 화면 이미 있는지 체크
            Map<String, Object> existsParams = new HashMap<>();
            existsParams.put("screen_code",  screenCode);
            existsParams.put("company_code", targetCompanyCode);
            Map<String, Object> existing = sqlSession.selectOne(NS + "selectScreenByCode", existsParams);

            Integer targetScreenId;
            if (existing == null) {
                // 화면 생성
                Map<String, Object> insertParams = new HashMap<>();
                insertParams.put("screen_name",    screenDef.get("screen_name"));
                insertParams.put("screen_code",    screenCode);
                insertParams.put("table_name",     screenDef.get("table_name"));
                insertParams.put("company_code",   targetCompanyCode);
                insertParams.put("description",   screenDef.get("description"));
                insertParams.put("created_by",     userId);
                insertParams.put("db_source_type",  screenDef.get("db_source_type"));
                insertParams.put("data_source_type", screenDef.get("data_source_type"));
                Map<String, Object> created = sqlSession.selectOne(NS + "insertScreenForDeploy", insertParams);
                targetScreenId = toInteger(created.get("screen_id"));
            } else {
                targetScreenId = toInteger(existing.get("screen_id"));
            }

            // POP 레이아웃 복사
            Map<String, Object> sourceParams = new HashMap<>();
            sourceParams.put("screen_id",    toInteger(screenDef.get("screen_id")));
            sourceParams.put("company_code", sourceCompanyCode);
            Map<String, Object> sourceLayout = sqlSession.selectOne(NS + "selectLayoutPop", sourceParams);

            if (sourceLayout != null) {
                Map<String, Object> deployParams = new HashMap<>();
                deployParams.put("screen_id",    targetScreenId);
                deployParams.put("company_code", targetCompanyCode);
                deployParams.put("layout_data",  toJsonString(sourceLayout.get("layout_data")));
                deployParams.put("user_id",      userId);
                sqlSession.insert(NS + "upsertPopLayoutDeploy", deployParams);
            }

            Map<String, Object> result = new LinkedHashMap<>();
            result.put("screen_code",  screenCode);
            result.put("screen_id",    targetScreenId);
            result.put("company_code", targetCompanyCode);
            deployed.add(result);
        }

        Map<String, Object> result = new LinkedHashMap<>();
        result.put("deployed", deployed);
        result.put("count",    deployed.size());
        return result;
    }

    // ═══════════════════════════════════════════════════════════
    // private helpers
    // ═══════════════════════════════════════════════════════════

    /** 연결된 모달 화면 탐색 (V1 레이아웃 properties 내 screenId 참조) */
    private List<Map<String, Object>> detectLinkedScreensInternal(Integer screenId, String companyCode) {
        Map<String, Object> params = new HashMap<>();
        params.put("screen_id", screenId);
        List<Map<String, Object>> layouts = sqlSession.selectList(NS + "selectLayoutsForLinkedModal", params);

        Set<Integer> linkedIds = new LinkedHashSet<>();
        for (Map<String, Object> layout : layouts) {
            Object propsRaw = layout.get("properties");
            if (propsRaw == null) continue;
            try {
                JsonNode props = objectMapper.readTree(propsRaw.toString());
                // screenId 참조 탐색 (tab 컴포넌트 등)
                collectScreenIdRefs(props, linkedIds);
            } catch (Exception ignored) {}
        }

        if (linkedIds.isEmpty()) return Collections.emptyList();

        Map<String, Object> idsParams = new HashMap<>();
        idsParams.put("ids", new ArrayList<>(linkedIds));
        return sqlSession.selectList(NS + "selectScreensByIds", idsParams);
    }

    /** JSON 노드 트리를 재귀 탐색하여 screenId 참조 수집 */
    private void collectScreenIdRefs(JsonNode node, Set<Integer> ids) {
        if (node == null) return;
        if (node.isObject()) {
            JsonNode screenIdNode = node.get("screen_id");
            if (screenIdNode != null && screenIdNode.isNumber()) {
                ids.add(screenIdNode.asInt());
            }
            node.fields().forEachRemaining(e -> collectScreenIdRefs(e.getValue(), ids));
        } else if (node.isArray()) {
            node.elements().forEachRemaining(el -> collectScreenIdRefs(el, ids));
        }
    }

    /** Layout JSON 에서 flowId 수집 */
    @SuppressWarnings("unchecked")
    private Set<Integer> collectFlowIds(Object layoutDataRaw) {
        Set<Integer> ids = new LinkedHashSet<>();
        if (layoutDataRaw == null) return ids;
        try {
            String json = layoutDataRaw instanceof String s ? s
                    : objectMapper.writeValueAsString(layoutDataRaw);
            JsonNode root = objectMapper.readTree(json);
            JsonNode components = root.get("components");
            if (components == null || !components.isArray()) return ids;
            for (JsonNode comp : components) {
                collectFlowIdFromComponent(comp, ids);
            }
        } catch (Exception ignored) {}
        return ids;
    }

    private void collectFlowIdFromComponent(JsonNode comp, Set<Integer> ids) {
        if (comp == null) return;
        // overrides.flowId
        extractFlowIdFromPath(comp, ids, "overrides", "flow_id");
        // overrides.webTypeConfig.dataflowConfig.flowConfig.flowId
        extractFlowIdFromPath(comp, ids, "overrides", "web_type_config", "dataflow_config", "flow_config", "flow_id");
        // overrides.webTypeConfig.dataflowConfig.flowControls[].flowId
        JsonNode controls = navigatePath(comp, "overrides", "web_type_config", "dataflow_config", "flow_controls");
        if (controls != null && controls.isArray()) {
            for (JsonNode ctrl : controls) {
                JsonNode fid = ctrl.get("flow_id");
                if (fid != null && fid.isNumber()) ids.add(fid.asInt());
            }
        }
        // overrides.action.excelAfterUploadFlows[].flowId
        JsonNode flows = navigatePath(comp, "overrides", "action", "excel_after_upload_flows");
        if (flows != null && flows.isArray()) {
            for (JsonNode f : flows) {
                JsonNode fid = f.get("flow_id");
                if (fid != null && fid.isNumber()) ids.add(fid.asInt());
            }
        }
    }

    private void extractFlowIdFromPath(JsonNode root, Set<Integer> ids, String... path) {
        JsonNode node = navigatePath(root, path);
        if (node != null && node.isNumber()) ids.add(node.asInt());
    }

    private JsonNode navigatePath(JsonNode root, String... path) {
        JsonNode current = root;
        for (String key : path) {
            if (current == null || !current.isObject()) return null;
            current = current.get(key);
        }
        return current;
    }

    /** Layout JSON 에서 screenId 참조 수집 (POP 분석용) */
    private List<Integer> extractScreenIdsFromLayout(Object layoutDataRaw) {
        List<Integer> ids = new ArrayList<>();
        if (layoutDataRaw == null) return ids;
        try {
            String json = layoutDataRaw instanceof String s ? s
                    : objectMapper.writeValueAsString(layoutDataRaw);
            JsonNode root = objectMapper.readTree(json);
            Set<Integer> idSet = new LinkedHashSet<>();
            collectScreenIdRefs(root, idSet);
            ids.addAll(idSet);
        } catch (Exception ignored) {}
        return ids;
    }

    /** 탭 컴포넌트 screenId 참조 일괄 업데이트 */
    private void updateTabReferencesForScreens(List<Integer> screenIds,
                                                Map<Integer, Integer> idMapping,
                                                String companyCode) {
        for (Integer sid : screenIds) {
            Map<String, Object> p = new HashMap<>();
            p.put("screen_id", sid);

            // V1
            List<Map<String, Object>> v1Layouts = sqlSession.selectList(NS + "selectLayoutsV1ForScreen", p);
            for (Map<String, Object> layout : v1Layouts) {
                Object propsRaw = layout.get("properties");
                if (propsRaw == null) continue;
                String updated = replaceScreenIdsInJson(propsRaw.toString(), idMapping);
                if (updated != null) {
                    Map<String, Object> up = new HashMap<>();
                    up.put("layout_id",   layout.get("layout_id"));
                    up.put("properties", updated);
                    sqlSession.update(NS + "updateLayoutV1Properties", up);
                }
            }

            // V2
            List<Map<String, Object>> v2Layouts = sqlSession.selectList(NS + "selectLayoutsV2ForScreen", p);
            for (Map<String, Object> layout : v2Layouts) {
                Object dataRaw = layout.get("layout_data");
                if (dataRaw == null) continue;
                String updated = replaceScreenIdsInJson(dataRaw.toString(), idMapping);
                if (updated != null) {
                    Map<String, Object> up = new HashMap<>();
                    up.put("screen_id",   layout.get("screen_id"));
                    up.put("layer_id",    layout.get("layer_id"));
                    up.put("company_code", layout.get("company_code"));
                    up.put("layout_data", updated);
                    sqlSession.update(NS + "updateLayoutV2Data", up);
                }
            }
        }
    }

    /** JSON 문자열 내 screenId 값 일괄 치환 */
    private String replaceScreenIdsInJson(String json, Map<Integer, Integer> idMapping) {
        if (json == null) return null;
        boolean changed = false;
        for (Map.Entry<Integer, Integer> entry : idMapping.entrySet()) {
            String from1 = "\"screenId\":" + entry.getKey();
            String to1   = "\"screenId\":" + entry.getValue();
            String from2 = "\"screenId\": " + entry.getKey();
            String to2   = "\"screenId\": " + entry.getValue();
            if (json.contains(from1)) { json = json.replace(from1, to1); changed = true; }
            if (json.contains(from2)) { json = json.replace(from2, to2); changed = true; }
        }
        return changed ? json : null;
    }

    /** 테이블 레이블 병합 */
    private void enrichWithTableLabels(List<Map<String, Object>> screens) {
        Set<String> tableNames = new LinkedHashSet<>();
        for (Map<String, Object> s : screens) {
            Object tn = s.get("table_name");
            if (tn != null && !tn.toString().isBlank()) tableNames.add(tn.toString());
        }
        if (tableNames.isEmpty()) return;

        Map<String, Object> params = new HashMap<>();
        params.put("table_names", new ArrayList<>(tableNames));
        try {
            List<Map<String, Object>> labels = sqlSession.selectList(NS + "selectTableLabelsByNames", params);
            Map<String, String> labelMap = new HashMap<>();
            for (Map<String, Object> row : labels) {
                labelMap.put((String) row.get("table_name"), (String) row.get("table_label"));
            }
            for (Map<String, Object> s : screens) {
                Object tn = s.get("table_name");
                if (tn != null) s.put("table_label", labelMap.get(tn.toString()));
            }
        } catch (Exception ignored) {}
    }

    /** 다음 화면 코드 계산 (advisory lock 선 획득 후 호출) */
    private String calcNextScreenCode(String companyCode) {
        Map<String, Object> params = new HashMap<>();
        params.put("pattern", companyCode + "_%");
        List<Map<String, Object>> existing = sqlSession.selectList(NS + "selectScreenCodesByPattern", params);

        Pattern numPattern = Pattern.compile("^" + Pattern.quote(companyCode) + "_(\\d+)$");
        int maxNumber = existing.stream()
            .map(row -> String.valueOf(row.get("screen_code")))
            .mapToInt(code -> {
                Matcher m = numPattern.matcher(code);
                return m.matches() ? Integer.parseInt(m.group(1)) : 0;
            })
            .max()
            .orElse(0);

        return companyCode + "_" + (maxNumber + 1);
    }

    /** 다음 레이어 ID 계산 */
    private int calcNextLayerId(Integer screenId, String companyCode) {
        Map<String, Object> params = new HashMap<>();
        params.put("screen_id",    screenId);
        params.put("company_code", companyCode);
        Map<String, Object> row = sqlSession.selectOne(NS + "selectMaxLayerId", params);
        int maxId = row != null ? toInt(row.getOrDefault("max_id", 1), 1) : 1;
        return maxId + 1;
    }

    private String toJsonString(Object obj) {
        if (obj == null) return null;
        if (obj instanceof String s) return s;
        try {
            return objectMapper.writeValueAsString(obj);
        } catch (JsonProcessingException e) {
            log.warn("JSON 직렬화 실패: {}", e.getMessage());
            return null;
        }
    }

    private int toInt(Object val, int defaultVal) {
        if (val == null) return defaultVal;
        try { return Integer.parseInt(val.toString()); }
        catch (NumberFormatException e) { return defaultVal; }
    }

    private Integer toInteger(Object val) {
        if (val == null) return null;
        try { return Integer.parseInt(val.toString()); }
        catch (NumberFormatException e) { return null; }
    }

    /** snake_case 우선, camelCase fallback으로 request body 파라미터 추출 */
    private Object bp(Map<String, Object> body, String snakeKey, String camelKey) {
        Object val = body.get(snakeKey);
        return val != null ? val : body.get(camelKey);
    }
}
