package com.erp.service;

import com.erp.common.BaseService;
import com.erp.constants.InputTypeConstants;
import com.erp.constants.InputTypeContext;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.*;
import java.util.stream.Collectors;

/**
 * 테이블 관리 서비스.
 * - 정적 메타데이터 쿼리 → sqlSession (MyBatis XML)
 * - 동적 테이블 CRUD (임의 테이블/컬럼) → JdbcTemplate (런타임 SQL 빌드 불가피)
 */
@Service
@RequiredArgsConstructor
@Slf4j
public class TableManagementService extends BaseService {

    private final JdbcTemplate jdbcTemplate;
    private final ObjectMapper objectMapper;

    private static final String NS = "tableManagement.";

    /** 로그 테이블 컬럼 정의에 허용하는 PostgreSQL data_type 화이트리스트.
     *  information_schema.columns.data_type 값과 정확히 일치해야 한다. */
    private static final Set<String> ALLOWED_LOG_COLUMN_TYPES = Set.of(
            "varchar", "text", "char", "character", "character varying",
            "integer", "bigint", "smallint", "numeric", "decimal", "real", "double precision",
            "boolean", "date", "timestamp", "timestamp without time zone", "timestamp with time zone",
            "time", "time without time zone", "time with time zone",
            "uuid", "json", "jsonb", "bytea"
    );

    // ──────────────────────────────────────────────────
    // 테이블 목록
    // ──────────────────────────────────────────────────

    public List<Map<String, Object>> getTableList() {
        List<Map<String, Object>> tables = sqlSession.selectList(NS + "getTableList");
        // columnCount Long → Integer 변환 (Node 호환)
        for (Map<String, Object> t : tables) {
            Object cnt = t.get("column_count");
            if (cnt instanceof Number) {
                t.put("column_count", ((Number) cnt).intValue());
            }
        }
        return tables;
    }

    // ──────────────────────────────────────────────────
    // 컬럼 목록
    // ──────────────────────────────────────────────────

    public Map<String, Object> getColumnList(String tableName, int page, int size, String companyCode) {
        Map<String, Object> params = new HashMap<>();
        params.put("table_name", tableName);
        params.put("size", size);
        params.put("offset", (long) (page - 1) * size);

        Integer totalObj = sqlSession.selectOne(NS + "getColumnListCnt", params);
        int total = totalObj != null ? totalObj : 0;
        List<Map<String, Object>> columns;

        if (companyCode != null && !companyCode.isBlank()) {
            params.put("company_code", companyCode);
            columns = sqlSession.selectList(NS + "getColumnListWithCompany", params);
        } else {
            columns = sqlSession.selectList(NS + "getColumnList", params);
        }

        int totalPages = total == 0 ? 1 : (int) Math.ceil((double) total / size);
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("columns", columns);
        result.put("total", total);
        result.put("page", page);
        result.put("size", size);
        result.put("total_pages", totalPages);
        return result;
    }

    // ──────────────────────────────────────────────────
    // 테이블 스키마 / 존재 여부
    // ──────────────────────────────────────────────────

    public List<Map<String, Object>> getTableSchema(String tableName) {
        Map<String, Object> params = new HashMap<>();
        params.put("table_name", tableName);
        return sqlSession.selectList(NS + "getTableSchemaList", params);
    }

    public boolean checkTableExists(String tableName) {
        Map<String, Object> params = new HashMap<>();
        params.put("table_name", tableName);
        Map<String, Object> result = sqlSession.selectOne(NS + "checkTableExists", params);
        Object exists = result != null ? result.get("exists") : null;
        return Boolean.TRUE.equals(exists);
    }

    public boolean hasColumn(String tableName, String columnName) {
        Map<String, Object> params = new HashMap<>();
        params.put("table_name", sanitize(tableName));
        params.put("column_name", sanitize(columnName));
        Integer cntObj = sqlSession.selectOne(NS + "checkTableHasColumn", params);
        return cntObj != null && cntObj > 0;
    }

    // ──────────────────────────────────────────────────
    // 테이블 라벨
    // ──────────────────────────────────────────────────

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

    @Transactional
    public void updateTableLabel(String tableName, String displayName, String description) {
        Map<String, Object> params = new HashMap<>();
        params.put("table_name", tableName);
        params.put("display_name", displayName);
        params.put("description", description != null ? description : "");
        sqlSession.update(NS + "upsertTableLabel", params);
        log.info("테이블 라벨 업데이트: {}", tableName);
    }

    private void ensureTableInLabels(String tableName) {
        Map<String, Object> params = new HashMap<>();
        params.put("table_name", tableName);
        sqlSession.insert(NS + "insertTableLabelIfNotExists", params);
    }

    // ──────────────────────────────────────────────────
    // 컬럼 라벨
    // ──────────────────────────────────────────────────

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

    // ──────────────────────────────────────────────────
    // 컬럼 설정 업데이트
    // ──────────────────────────────────────────────────

    @Transactional
    public void updateColumnSettings(String tableName, String columnName,
                                     Map<String, Object> settings, String companyCode) {
        ensureTableInLabels(tableName);

        Object rawInputType = settings.get("input_type");
        boolean inputTypeChanged = settings.containsKey("input_type") && rawInputType != null;
        InputTypeContext ctx = inputTypeChanged
            ? InputTypeContext.USER_UPDATE_TYPE
            : InputTypeContext.USER_UPDATE_OTHER;
        String inputType = normalizeInputType((String) rawInputType, ctx);
        Map<String, Object> params = new HashMap<>();
        params.put("table_name", tableName);
        params.put("column_name", columnName);
        params.put("column_label", settings.get("column_label"));
        params.put("input_type", inputType);
        params.put("detail_settings", toJsonString(settings.get("detail_settings")));
        params.put("code_info", "code".equals(inputType) ? settings.get("code_info") : null);
        params.put("code_value", "code".equals(inputType) ? settings.get("code_value") : null);
        params.put("reference_table", "entity".equals(inputType) ? settings.get("reference_table") : null);
        params.put("reference_column", "entity".equals(inputType) ? settings.get("reference_column") : null);
        params.put("display_column", "entity".equals(inputType) ? settings.get("display_column") : null);
        params.put("display_order", settings.getOrDefault("display_order", 0));
        params.put("is_visible", settings.getOrDefault("is_visible", true));
        // is_nullable: 'Y'/'N' 또는 null. null 이면 mapper 의 COALESCE 로 기존 값 유지.
        Object rawIsNullable = settings.get("is_nullable");
        if (rawIsNullable != null) {
            String s = rawIsNullable.toString();
            // 프론트가 'YES'/'NO' 또는 'Y'/'N' 어느 쪽이든 보낼 수 있어 정규화
            if ("NO".equalsIgnoreCase(s) || "N".equalsIgnoreCase(s) || "FALSE".equalsIgnoreCase(s)) {
                params.put("is_nullable", "N");
            } else if ("YES".equalsIgnoreCase(s) || "Y".equalsIgnoreCase(s) || "TRUE".equalsIgnoreCase(s)) {
                params.put("is_nullable", "Y");
            } else {
                params.put("is_nullable", null);
            }
        } else {
            params.put("is_nullable", null);
        }
        params.put("company_code", companyCode);
        params.put("category_ref", "category".equals(inputType) ? settings.get("category_ref") : null);
        sqlSession.update(NS + "upsertColumnSettings", params);

        // 화면 레이아웃 동기화
        syncScreenLayouts(tableName, columnName, inputType, companyCode);
        log.info("컬럼 설정 업데이트: {}.{}, company={}", tableName, columnName, companyCode);
    }

    @Transactional
    public void updateAllColumnSettings(String tableName, List<Map<String, Object>> columnSettings,
                                        String companyCode) {
        ensureTableInLabels(tableName);
        for (Map<String, Object> colSetting : columnSettings) {
            Object colName = colSetting.get("column_name");
            if (colName != null && !colName.toString().isBlank()) {
                updateColumnSettings(tableName, colName.toString(), colSetting, companyCode);
            }
        }
        log.info("컬럼 설정 일괄 업데이트: {}, {}개, company={}", tableName, columnSettings.size(), companyCode);
    }

    @Transactional
    public void updateColumnWebType(String tableName, String columnName,
                                    String webType, Map<String, Object> detailSettings,
                                    String companyCode) {
        String finalType = normalizeInputType(webType);
        Map<String, Object> params = new HashMap<>();
        params.put("table_name", tableName);
        params.put("column_name", columnName);
        params.put("input_type", finalType);
        params.put("detail_settings", detailSettings != null ? toJsonString(detailSettings) : "{}");
        // 멀티테넌트 격리: SUPER_ADMIN("*") 은 공통 설정, 그 외는 회사별 설정
        params.put("company_code", companyCode != null ? companyCode : "*");
        params.put("clear_entity", false);
        params.put("clear_code", false);
        params.put("clear_category", false);
        sqlSession.update(NS + "upsertColumnInputType", params);
        log.info("컬럼 웹타입 설정: {}.{} = {} (company={})", tableName, columnName, finalType, companyCode);
    }

    @Transactional
    public void updateColumnInputType(String tableName, String columnName,
                                      String inputType, String companyCode,
                                      Map<String, Object> detailSettings) {
        String finalType = normalizeInputType(inputType, InputTypeContext.USER_UPDATE_TYPE);
        Map<String, Object> params = new HashMap<>();
        params.put("table_name", tableName);
        params.put("column_name", columnName);
        params.put("input_type", finalType);
        params.put("detail_settings", detailSettings != null ? toJsonString(detailSettings) : "{}");
        params.put("company_code", companyCode);
        params.put("clear_entity", !"entity".equals(finalType));
        params.put("clear_code", !"code".equals(finalType));
        params.put("clear_category", !"category".equals(finalType));
        sqlSession.update(NS + "upsertColumnInputType", params);
        syncScreenLayouts(tableName, columnName, finalType, companyCode);
        log.info("컬럼 입력타입 설정: {}.{} = {}, company={}", tableName, columnName, finalType, companyCode);
    }

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

    // ──────────────────────────────────────────────────
    // 제약조건 관리
    // ──────────────────────────────────────────────────

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

        List<Map<String, Object>> pkResult = sqlSession.selectList(NS + "getTablePrimaryKeyList", params);
        Map<String, Object> primaryKey = new HashMap<>();
        if (!pkResult.isEmpty()) {
            Map<String, Object> pk = pkResult.get(0);
            primaryKey.put("name", pk.get("constraint_name"));
            primaryKey.put("columns", parseColumnArray(pk.get("columns")));
        } else {
            primaryKey.put("name", "");
            primaryKey.put("columns", List.of());
        }

        List<Map<String, Object>> indexResult = sqlSession.selectList(NS + "getTableIndexList", params);
        List<Map<String, Object>> indexes = indexResult.stream().map(row -> {
            Map<String, Object> idx = new HashMap<>();
            idx.put("name", row.get("index_name"));
            idx.put("columns", parseColumnArray(row.get("columns")));
            idx.put("is_unique", row.get("is_unique"));
            return idx;
        }).collect(Collectors.toList());

        Map<String, Object> result = new HashMap<>();
        result.put("primary_key", primaryKey);
        result.put("indexes", indexes);
        return result;
    }

    @Transactional
    public void setTablePrimaryKey(String tableName, List<String> columns) {
        String safeTable = sanitize(tableName);
        // 기존 PK 삭제
        Map<String, Object> params = new HashMap<>();
        params.put("table_name", safeTable);
        List<Map<String, Object>> existingPk = sqlSession.selectList(NS + "getTablePrimaryKeyList", params);
        if (!existingPk.isEmpty()) {
            String constraintName = (String) existingPk.get(0).get("constraint_name");
            jdbcTemplate.execute(
                    String.format("ALTER TABLE \"public\".\"%s\" DROP CONSTRAINT \"%s\"",
                            safeTable, sanitize(constraintName)));
            log.info("기존 PK 삭제: {}.{}", safeTable, constraintName);
        }
        // 새 PK 추가
        String colList = columns.stream()
                .map(c -> "\"" + sanitize(c) + "\"")
                .collect(Collectors.joining(", "));
        jdbcTemplate.execute(
                String.format("ALTER TABLE \"public\".\"%s\" ADD PRIMARY KEY (%s)", safeTable, colList));
        log.info("PK 설정: {} → [{}]", safeTable, String.join(", ", columns));
    }

    @Transactional
    public void toggleTableIndex(String tableName, String columnName,
                                 String indexType, String action) {
        String safeTable = sanitize(tableName);
        String safeCol = sanitize(columnName);
        String indexName = String.format("idx_%s_%s%s", safeTable, safeCol,
                "unique".equals(indexType) ? "_uq" : "");

        if ("create".equals(action)) {
            String indexColumns = "\"" + safeCol + "\"";
            if ("unique".equals(indexType) && hasColumn(safeTable, "company_code")) {
                indexColumns = "\"company_code\", \"" + safeCol + "\"";
                log.info("멀티테넌시: company_code + {} 복합 유니크 인덱스", safeCol);
            }
            String unique = "unique".equals(indexType) ? "UNIQUE " : "";
            jdbcTemplate.execute(
                    String.format("CREATE %sINDEX IF NOT EXISTS \"%s\" ON \"public\".\"%s\" (%s)",
                            unique, indexName, safeTable, indexColumns));
            log.info("인덱스 생성: {}", indexName);
        } else if ("drop".equals(action)) {
            jdbcTemplate.execute(
                    String.format("DROP INDEX IF EXISTS \"public\".\"%s\"", indexName));
            log.info("인덱스 삭제: {}", indexName);
        }
    }

    @Transactional
    public void toggleColumnNullable(String tableName, String columnName,
                                     boolean nullable, String companyCode) {
        Map<String, Object> params = new HashMap<>();
        params.put("table_name", tableName);
        params.put("column_name", columnName);
        params.put("is_nullable", nullable ? "Y" : "N");
        params.put("company_code", companyCode);
        sqlSession.update(NS + "upsertNullable", params);
        log.info("NOT NULL 토글: {}.{} nullable={}, company={}", tableName, columnName, nullable, companyCode);
    }

    @Transactional
    public void toggleColumnUnique(String tableName, String columnName,
                                   boolean unique, String companyCode) {
        Map<String, Object> params = new HashMap<>();
        params.put("table_name", tableName);
        params.put("column_name", columnName);
        params.put("is_unique", unique ? "Y" : "N");
        params.put("company_code", companyCode);
        sqlSession.update(NS + "upsertUnique", params);
        log.info("UNIQUE 토글: {}.{} unique={}, company={}", tableName, columnName, unique, companyCode);
    }

    // ──────────────────────────────────────────────────
    // 소프트 제약조건 검증
    // ──────────────────────────────────────────────────

    public List<String> validateNotNullConstraints(String tableName,
                                                    Map<String, Object> data,
                                                    String companyCode) {
        Map<String, Object> params = new HashMap<>();
        params.put("table_name", tableName);
        params.put("company_code", companyCode);
        List<Map<String, Object>> notNullCols = sqlSession.selectList(NS + "getNotNullColumnList", params);

        List<String> violations = new ArrayList<>();
        for (Map<String, Object> col : notNullCols) {
            String colName = (String) col.get("column_name");
            Object val = data.get(colName);
            if (val == null || val.toString().isBlank()) {
                violations.add(colName);
            }
        }
        return violations;
    }

    public List<String> validateUniqueConstraints(String tableName,
                                                   Map<String, Object> data,
                                                   String companyCode,
                                                   String excludeId) {
        Map<String, Object> params = new HashMap<>();
        params.put("table_name", tableName);
        params.put("company_code", companyCode);
        List<Map<String, Object>> uniqueCols = sqlSession.selectList(NS + "getUniqueColumnList", params);

        String safeTable = sanitize(tableName);
        List<String> violations = new ArrayList<>();

        // N+N → N+1 최적화: hasColumn 은 information_schema 조회라 비싸. 루프 밖에서 한 번만 수행.
        boolean hasCompanyCode = hasColumn(safeTable, "company_code");

        for (Map<String, Object> col : uniqueCols) {
            String colName = (String) col.get("column_name");
            Object val = data.get(colName);
            if (val == null) continue;

            String sql;
            List<Object> sqlParams = new ArrayList<>();

            if (hasCompanyCode && !"*".equals(companyCode)) {
                sql = String.format(
                        "SELECT COUNT(*) FROM \"%s\" WHERE \"%s\" = ? AND company_code = ?%s",
                        safeTable, sanitize(colName),
                        excludeId != null ? " AND id::text != ?" : "");
                sqlParams.add(val.toString());
                sqlParams.add(companyCode);
                if (excludeId != null) sqlParams.add(excludeId);
            } else {
                sql = String.format(
                        "SELECT COUNT(*) FROM \"%s\" WHERE \"%s\" = ?%s",
                        safeTable, sanitize(colName),
                        excludeId != null ? " AND id::text != ?" : "");
                sqlParams.add(val.toString());
                if (excludeId != null) sqlParams.add(excludeId);
            }

            Integer count = jdbcTemplate.queryForObject(sql, Integer.class, sqlParams.toArray());
            if (count != null && count > 0) {
                violations.add(colName);
            }
        }
        return violations;
    }

    // ──────────────────────────────────────────────────
    // 동적 테이블 CRUD (JdbcTemplate 사용 - 임의 테이블/컬럼이므로 MyBatis XML 불가)
    // ──────────────────────────────────────────────────

    public Map<String, Object> getTableData(String tableName, Map<String, Object> options) {
        String safeTable = sanitize(tableName);
        int page = toInt(options.get("page"), 1);
        int size = toInt(options.get("size"), 10);
        int offset = (page - 1) * size;
        @SuppressWarnings("unchecked")
        Map<String, Object> search = (Map<String, Object>) options.getOrDefault("search", Map.of());
        String sortBy = (String) options.get("sort_by");
        String sortOrder = "desc".equalsIgnoreCase((String) options.get("sort_order")) ? "DESC" : "ASC";

        List<String> conditions = new ArrayList<>();
        List<Object> values = new ArrayList<>();

        for (Map.Entry<String, Object> entry : search.entrySet()) {
            Object val = entry.getValue();
            if (val == null || val.toString().isBlank() || "__ALL__".equals(val.toString())) continue;
            String safeCol = sanitize(entry.getKey());
            if (safeCol.isBlank()) continue;
            conditions.add(String.format("\"%s\"::text ILIKE ?", safeCol));
            values.add("%" + val.toString() + "%");
        }

        String where = conditions.isEmpty() ? "" : "WHERE " + String.join(" AND ", conditions);

        // 정렬
        String order = "";
        if (sortBy != null && !sortBy.isBlank()) {
            order = String.format("ORDER BY \"%s\" %s", sanitize(sortBy), sortOrder);
        } else if (hasColumn(safeTable, "created_date")) {
            order = "ORDER BY main.created_date DESC";
        }

        String countSql = String.format("SELECT COUNT(*) FROM \"%s\" main %s", safeTable, where);
        Integer total = jdbcTemplate.queryForObject(countSql, Integer.class, values.toArray());
        if (total == null) total = 0;

        String dataSql = String.format(
                "SELECT main.* FROM \"%s\" main %s %s LIMIT %d OFFSET %d",
                safeTable, where, order, size, offset);
        List<Map<String, Object>> data = jdbcTemplate.queryForList(dataSql, values.toArray());

        int totalPages = total == 0 ? 1 : (int) Math.ceil((double) total / size);
        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", totalPages);
        return result;
    }

    // ──────────────────────────────────────────────────
    // 동적 테이블 집계 (count / sum / avg / min / max / distinctCount)
    // ──────────────────────────────────────────────────

    private static final Set<String> AGG_TYPES = Set.of(
            "count", "sum", "avg", "min", "max", "distinctCount"
    );

    private static final Set<String> FILTER_OPS = Set.of(
            "=", "!=", ">", "<", ">=", "<=",
            "like", "in", "notIn", "isNull", "isNotNull"
    );

    /**
     * 단일 집계 값 계산.
     *
     * count           — column 없이도 동작 (COUNT(*))
     * sum/avg/min/max — column 필수
     * distinctCount   — column 필수 (COUNT(DISTINCT col))
     */
    public Map<String, Object> aggregateTableData(String tableName, Map<String, Object> options) {
        String safeTable = sanitize(tableName);
        if (safeTable.isBlank() || !checkTableExists(safeTable)) {
            throw new IllegalArgumentException("테이블이 존재하지 않습니다: " + tableName);
        }

        String aggregation = options.get("aggregation") instanceof String s ? s : "count";
        if (!AGG_TYPES.contains(aggregation)) {
            throw new IllegalArgumentException("지원하지 않는 집계 타입: " + aggregation);
        }

        String columnName = options.get("columnName") instanceof String s ? s : null;
        String safeColumn = columnName != null ? sanitize(columnName) : "";

        boolean columnRequired = !"count".equals(aggregation);
        if (columnRequired) {
            if (safeColumn.isBlank()) {
                throw new IllegalArgumentException(aggregation + " 은 columnName 이 필요합니다.");
            }
            if (!hasColumn(safeTable, safeColumn)) {
                throw new IllegalArgumentException("컬럼이 존재하지 않습니다: " + tableName + "." + columnName);
            }
        } else if (!safeColumn.isBlank() && !hasColumn(safeTable, safeColumn)) {
            // count + columnName 가 들어왔지만 실제 없는 컬럼이면 명확히 거절
            throw new IllegalArgumentException("컬럼이 존재하지 않습니다: " + tableName + "." + columnName);
        }

        List<Map<String, Object>> filters = normalizeAggregateFilters(options.get("filters"));

        List<Object> values = new ArrayList<>();
        String where = buildAggregateWhere(safeTable, filters, values);

        String selectExpr;
        if ("count".equals(aggregation)) {
            selectExpr = !safeColumn.isBlank()
                    ? String.format("COUNT(\"%s\")", safeColumn)
                    : "COUNT(*)";
        } else if ("distinctCount".equals(aggregation)) {
            selectExpr = String.format("COUNT(DISTINCT \"%s\")", safeColumn);
        } else {
            // sum / avg / min / max — 숫자 캐스팅 (avg 만 numeric, 나머지는 컬럼 타입 그대로)
            String upper = aggregation.toUpperCase();
            if ("AVG".equals(upper) || "SUM".equals(upper)) {
                selectExpr = String.format("%s(CAST(\"%s\" AS NUMERIC))", upper, safeColumn);
            } else {
                selectExpr = String.format("%s(\"%s\")", upper, safeColumn);
            }
        }

        String sql = String.format("SELECT %s AS agg_value FROM \"%s\" main %s",
                selectExpr, safeTable, where);

        Number raw = jdbcTemplate.queryForObject(sql, Number.class, values.toArray());
        double value = raw != null ? raw.doubleValue() : 0d;

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

    private String buildAggregateWhere(String safeTable, List<Map<String, Object>> filters, List<Object> values) {
        if (filters == null || filters.isEmpty()) return "";
        List<String> clauses = new ArrayList<>();
        for (Map<String, Object> f : filters) {
            if (f == null) continue;
            String col = f.get("column") instanceof String s ? s : null;
            String op = f.get("operator") instanceof String s ? s : "=";
            if (col == null || col.isBlank()) continue;
            String safeCol = sanitize(col);
            if (safeCol.isBlank() || !hasColumn(safeTable, safeCol)) continue;
            if (!FILTER_OPS.contains(op)) continue;

            Object val = f.get("value");

            switch (op) {
                case "isNull":
                    clauses.add(String.format("\"%s\" IS NULL", safeCol));
                    break;
                case "isNotNull":
                    clauses.add(String.format("\"%s\" IS NOT NULL", safeCol));
                    break;
                case "in":
                case "notIn": {
                    List<Object> list = toList(val);
                    if (list.isEmpty()) continue;
                    String marks = list.stream().map(v -> "?").collect(Collectors.joining(", "));
                    String kw = "in".equals(op) ? "IN" : "NOT IN";
                    clauses.add(String.format("\"%s\" %s (%s)", safeCol, kw, marks));
                    values.addAll(list);
                    break;
                }
                case "like":
                    if (isEmptyAggregateFilterValue(val)) continue;
                    clauses.add(String.format("\"%s\"::text ILIKE ?", safeCol));
                    values.add("%" + val + "%");
                    break;
                default:
                    if (isEmptyAggregateFilterValue(val)) continue;
                    clauses.add(String.format("\"%s\" %s ?", safeCol, op));
                    values.add(val);
            }
        }
        return clauses.isEmpty() ? "" : "WHERE " + String.join(" AND ", clauses);
    }

    private List<Map<String, Object>> normalizeAggregateFilters(Object rawFilters) {
        if (!(rawFilters instanceof List<?> rawList) || rawList.isEmpty()) {
            return Collections.emptyList();
        }
        List<Map<String, Object>> out = new ArrayList<>();
        for (Object item : rawList) {
            if (item instanceof Map<?, ?> rawMap) {
                Map<String, Object> normalized = new LinkedHashMap<>();
                for (Map.Entry<?, ?> entry : rawMap.entrySet()) {
                    if (entry.getKey() instanceof String key) {
                        normalized.put(key, entry.getValue());
                    }
                }
                if (!normalized.isEmpty()) out.add(normalized);
            }
        }
        return out;
    }

    private boolean isEmptyAggregateFilterValue(Object val) {
        if (val == null) return true;
        if (val instanceof String s) return s.isBlank();
        if (val instanceof Collection<?> c) return c.isEmpty();
        return false;
    }

    private List<Object> toList(Object val) {
        if (val == null) return List.of();
        if (val instanceof List<?> l) {
            List<Object> out = new ArrayList<>();
            for (Object o : l) {
                if (o == null) continue;
                if (o instanceof String s && s.isBlank()) continue;
                out.add(o);
            }
            return out;
        }
        if (val instanceof String s) {
            if (s.isBlank()) return List.of();
            return Arrays.stream(s.split(","))
                    .map(String::trim)
                    .filter(p -> !p.isEmpty())
                    .map(p -> (Object) p)
                    .collect(Collectors.toList());
        }
        return List.of(val);
    }

    // ──────────────────────────────────────────────────
    // 그룹별 집계 (Phase G.3 — canonical chart 용)
    // ──────────────────────────────────────────────────

    /**
     * groupBy 컬럼별로 집계 결과 반환. canonical chart 컴포넌트가 bar / line / donut /
     * horizontalBar 모두에서 같은 endpoint 를 사용.
     *
     * body 예:
     *   { "groupBy": "status", "aggregation": "count", "filters": [...] }
     *   { "groupBy": "dept_code", "aggregation": "sum", "valueColumn": "amount", "limit": 12 }
     *
     * response:
     *   { "rows": [{ "group": "재직", "value": 35 }, { "group": "휴직", "value": 4 }] }
     */
    public Map<String, Object> aggregateTableGroup(String tableName, Map<String, Object> options) {
        String safeTable = sanitize(tableName);
        if (safeTable.isBlank() || !checkTableExists(safeTable)) {
            throw new IllegalArgumentException("테이블이 존재하지 않습니다: " + tableName);
        }

        String groupBy = options.get("groupBy") instanceof String s ? s : null;
        String safeGroupBy = groupBy != null ? sanitize(groupBy) : "";
        if (safeGroupBy.isBlank() || !hasColumn(safeTable, safeGroupBy)) {
            throw new IllegalArgumentException("groupBy 컬럼이 존재하지 않습니다: " + tableName + "." + groupBy);
        }

        String aggregation = options.get("aggregation") instanceof String s ? s : "count";
        if (!AGG_TYPES.contains(aggregation)) {
            throw new IllegalArgumentException("지원하지 않는 집계 타입: " + aggregation);
        }

        String valueColumn = options.get("valueColumn") instanceof String s ? s : null;
        if (valueColumn == null && options.get("columnName") instanceof String s) valueColumn = s;
        String safeValueCol = valueColumn != null ? sanitize(valueColumn) : "";

        boolean columnRequired = !"count".equals(aggregation);
        if (columnRequired) {
            if (safeValueCol.isBlank()) {
                throw new IllegalArgumentException(aggregation + " 은 valueColumn 이 필요합니다.");
            }
            if (!hasColumn(safeTable, safeValueCol)) {
                throw new IllegalArgumentException("valueColumn 이 존재하지 않습니다: " + tableName + "." + valueColumn);
            }
        } else if (!safeValueCol.isBlank() && !hasColumn(safeTable, safeValueCol)) {
            throw new IllegalArgumentException("valueColumn 이 존재하지 않습니다: " + tableName + "." + valueColumn);
        }

        List<Map<String, Object>> filters = normalizeAggregateFilters(options.get("filters"));

        int limit = toInt(options.get("limit"), 50);
        if (limit < 1) limit = 50;
        if (limit > 500) limit = 500;

        String orderDir = options.get("orderDir") instanceof String s
                && ("asc".equalsIgnoreCase(s) || "desc".equalsIgnoreCase(s))
                ? s.toUpperCase()
                : "DESC";

        List<Object> values = new ArrayList<>();
        String where = buildAggregateWhere(safeTable, filters, values);

        String selectExpr;
        if ("count".equals(aggregation)) {
            selectExpr = !safeValueCol.isBlank()
                    ? String.format("COUNT(\"%s\")", safeValueCol)
                    : "COUNT(*)";
        } else if ("distinctCount".equals(aggregation)) {
            selectExpr = String.format("COUNT(DISTINCT \"%s\")", safeValueCol);
        } else {
            String upper = aggregation.toUpperCase();
            if ("AVG".equals(upper) || "SUM".equals(upper)) {
                selectExpr = String.format("%s(CAST(\"%s\" AS NUMERIC))", upper, safeValueCol);
            } else {
                selectExpr = String.format("%s(\"%s\")", upper, safeValueCol);
            }
        }

        String sql = String.format(
                "SELECT \"%s\" AS group_value, %s AS agg_value " +
                        "FROM \"%s\" main %s " +
                        "GROUP BY \"%s\" " +
                        "ORDER BY agg_value %s NULLS LAST " +
                        "LIMIT %d",
                safeGroupBy, selectExpr, safeTable, where, safeGroupBy, orderDir, limit);

        List<Map<String, Object>> rawRows = jdbcTemplate.queryForList(sql, values.toArray());
        List<Map<String, Object>> rows = new ArrayList<>();
        for (Map<String, Object> r : rawRows) {
            Object groupVal = r.get("group_value");
            Object aggVal = r.get("agg_value");
            double value = aggVal instanceof Number ? ((Number) aggVal).doubleValue() : 0d;
            Map<String, Object> out = new LinkedHashMap<>();
            out.put("group", groupVal);
            out.put("value", value);
            rows.add(out);
        }

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

    // ──────────────────────────────────────────────────
    // 가벼운 select-rows (Phase G.3.1 — card-list / grouped-table 용)
    // ──────────────────────────────────────────────────

    /**
     * OptionFilter 호환 필터 + orderBy + limit/offset 로 임의 컬럼들의 row 들을 반환.
     * `getTableData` 는 페이지네이션 + ILIKE search 가 묶여 있어 view 컴포넌트가
     * 사용하기 무겁다. 본 메서드는 raw rows 만 깔끔하게 반환.
     *
     * body 예:
     *   { "columns": ["user_name", "dept_code"], "filters": [...], "limit": 50 }
     *   { "groupBy 없이 단순 다중 컬럼", "orderBy": [{ "column": "created_date", "direction": "desc" }] }
     *
     * response:
     *   { "rows": [{...}, {...}] }
     */
    public Map<String, Object> selectTableRows(String tableName, Map<String, Object> options) {
        String safeTable = sanitize(tableName);
        if (safeTable.isBlank() || !checkTableExists(safeTable)) {
            throw new IllegalArgumentException("테이블이 존재하지 않습니다: " + tableName);
        }

        @SuppressWarnings("unchecked")
        List<Object> rawColumns = options.get("columns") instanceof List<?> raw
                ? (List<Object>) raw : Collections.emptyList();

        List<String> safeColumns = new ArrayList<>();
        for (Object c : rawColumns) {
            if (!(c instanceof String s)) continue;
            String safe = sanitize(s);
            if (safe.isBlank()) continue;
            if (!hasColumn(safeTable, safe)) continue;
            safeColumns.add(safe);
        }

        String selectExpr;
        if (safeColumns.isEmpty()) {
            selectExpr = "main.*";
        } else {
            selectExpr = safeColumns.stream()
                    .map(c -> "\"" + c + "\"")
                    .collect(Collectors.joining(", "));
        }

        List<Map<String, Object>> filters = normalizeAggregateFilters(options.get("filters"));

        List<Object> values = new ArrayList<>();
        String where = buildAggregateWhere(safeTable, filters, values);

        // orderBy: [{ column, direction }]
        List<Map<String, Object>> orderBy = normalizeAggregateFilters(options.get("orderBy"));

        List<String> orderClauses = new ArrayList<>();
        for (Map<String, Object> ob : orderBy) {
            if (ob == null) continue;
            String col = ob.get("column") instanceof String s ? s : null;
            if (col == null) continue;
            String safeCol = sanitize(col);
            if (safeCol.isBlank() || !hasColumn(safeTable, safeCol)) continue;
            String dir = ob.get("direction") instanceof String s
                    && "desc".equalsIgnoreCase(s) ? "DESC" : "ASC";
            orderClauses.add(String.format("\"%s\" %s", safeCol, dir));
        }
        String order = "";
        if (!orderClauses.isEmpty()) {
            order = "ORDER BY " + String.join(", ", orderClauses);
        } else if (hasColumn(safeTable, "created_date")) {
            order = "ORDER BY main.created_date DESC";
        }

        int limit = toInt(options.get("limit"), 50);
        if (limit < 1) limit = 50;
        if (limit > 500) limit = 500;
        int offset = toInt(options.get("offset"), 0);
        if (offset < 0) offset = 0;

        String sql = String.format(
                "SELECT %s FROM \"%s\" main %s %s LIMIT %d OFFSET %d",
                selectExpr, safeTable, where, order, limit, offset);

        List<Map<String, Object>> rows = jdbcTemplate.queryForList(sql, values.toArray());

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

    @Transactional
    public Map<String, Object> addTableData(String tableName, Map<String, Object> data) {
        String safeTable = sanitize(tableName);

        // 테이블 컬럼 정보 조회
        Map<String, String> colTypes = getColumnTypes(safeTable);

        // created_date 자동 설정
        if (colTypes.containsKey("created_date") && !data.containsKey("created_date")) {
            data.put("created_date", new java.sql.Timestamp(System.currentTimeMillis()));
        }

        // 존재하는 컬럼만 필터링
        List<String> existingCols = new ArrayList<>();
        for (String col : data.keySet()) {
            if (colTypes.containsKey(col)) existingCols.add(col);
        }

        if (existingCols.isEmpty()) {
            throw new IllegalArgumentException("저장할 유효한 컬럼이 없습니다. 테이블: " + safeTable);
        }

        String colNames = existingCols.stream()
                .map(c -> "\"" + sanitize(c) + "\"")
                .collect(Collectors.joining(", "));
        String placeholders = existingCols.stream().map(c -> "?").collect(Collectors.joining(", "));
        Object[] params = existingCols.stream().map(data::get).toArray();

        String sql = String.format(
                "INSERT INTO \"%s\" (%s) VALUES (%s)", safeTable, colNames, placeholders);

        // RETURNING id if id column exists
        String insertedId = null;
        if (colTypes.containsKey("id")) {
            List<Map<String, Object>> result = jdbcTemplate.queryForList(sql + " RETURNING id", params);
            if (!result.isEmpty()) insertedId = String.valueOf(result.get(0).get("id"));
        } else {
            jdbcTemplate.update(sql, params);
        }

        log.info("테이블 데이터 추가: {}, id={}", safeTable, insertedId);
        Map<String, Object> result = new HashMap<>();
        result.put("inserted_id", insertedId);
        result.put("saved_columns", existingCols);
        return result;
    }

    @Transactional
    public void editTableData(String tableName, Map<String, Object> originalData,
                               Map<String, Object> updatedData) {
        String safeTable = sanitize(tableName);
        Map<String, String> colTypes = getColumnTypes(safeTable);

        // updated_date 자동 설정
        if (colTypes.containsKey("updated_date") && !updatedData.containsKey("updated_date")) {
            updatedData.put("updated_date", new java.sql.Timestamp(System.currentTimeMillis()));
        }

        // PK 컬럼 조회
        List<String> pkCols = getPrimaryKeys(safeTable);

        // SET 절
        List<String> setClauses = new ArrayList<>();
        List<Object> setValues = new ArrayList<>();
        for (Map.Entry<String, Object> entry : updatedData.entrySet()) {
            String col = sanitize(entry.getKey());
            if (col.isBlank() || !colTypes.containsKey(entry.getKey())) continue;
            setClauses.add(String.format("\"%s\" = ?", col));
            setValues.add(entry.getValue());
        }

        if (setClauses.isEmpty()) return;

        // WHERE 절
        List<String> whereClauses = new ArrayList<>();
        List<Object> whereValues = new ArrayList<>();
        if (!pkCols.isEmpty()) {
            for (String pk : pkCols) {
                if (originalData.containsKey(pk)) {
                    whereClauses.add(String.format("\"%s\" = ?", sanitize(pk)));
                    whereValues.add(originalData.get(pk));
                }
            }
        }
        if (whereClauses.isEmpty()) {
            for (Map.Entry<String, Object> entry : originalData.entrySet()) {
                String col = sanitize(entry.getKey());
                if (col.isBlank()) continue;
                whereClauses.add(String.format("\"%s\" = ?", col));
                whereValues.add(entry.getValue());
            }
        }

        List<Object> allValues = new ArrayList<>(setValues);
        allValues.addAll(whereValues);

        String sql = String.format("UPDATE \"%s\" SET %s WHERE %s",
                safeTable,
                String.join(", ", setClauses),
                String.join(" AND ", whereClauses));

        jdbcTemplate.update(sql, allValues.toArray());
        log.info("테이블 데이터 수정: {}", safeTable);
    }

    @Transactional
    public int deleteTableData(String tableName, List<Map<String, Object>> dataList) {
        String safeTable = sanitize(tableName);
        Map<String, String> colTypes = getColumnTypes(safeTable);
        List<String> pkCols = getPrimaryKeys(safeTable);
        int deletedCount = 0;

        for (Map<String, Object> row : dataList) {
            List<String> whereClauses = new ArrayList<>();
            List<Object> whereValues = new ArrayList<>();

            if (!pkCols.isEmpty()) {
                for (String pk : pkCols) {
                    if (row.containsKey(pk)) {
                        whereClauses.add(String.format("\"%s\" = ?", sanitize(pk)));
                        whereValues.add(row.get(pk));
                    }
                }
            }
            if (whereClauses.isEmpty()) {
                for (Map.Entry<String, Object> entry : row.entrySet()) {
                    String col = sanitize(entry.getKey());
                    if (col.isBlank() || !colTypes.containsKey(entry.getKey())) continue;
                    whereClauses.add(String.format("\"%s\" = ?", col));
                    whereValues.add(entry.getValue());
                }
            }
            if (whereClauses.isEmpty()) continue;

            String sql = String.format("DELETE FROM \"%s\" WHERE %s",
                    safeTable, String.join(" AND ", whereClauses));
            deletedCount += jdbcTemplate.update(sql, whereValues.toArray());
        }
        log.info("테이블 데이터 삭제: {}, {}건", safeTable, deletedCount);
        return deletedCount;
    }

    // ──────────────────────────────────────────────────
    // 로그 테이블 관리
    // ──────────────────────────────────────────────────

    @Transactional
    public void createLogTable(String tableName, List<String> logColumns, boolean isActive) {
        String safeOrig = sanitize(tableName);
        if (safeOrig.isBlank()) {
            throw new IllegalArgumentException("유효하지 않은 테이블명입니다.");
        }
        String safeLog = sanitize(safeOrig + "_log");
        if (safeLog.isBlank()) {
            throw new IllegalArgumentException("유효하지 않은 로그 테이블명입니다.");
        }

        // 원본 테이블 컬럼 정보 조회
        Map<String, String> colTypes = getColumnTypes(safeOrig);

        // 로그 테이블 생성 SQL 빌드
        List<String> colDefs = new ArrayList<>();
        colDefs.add("log_id BIGSERIAL PRIMARY KEY");
        colDefs.add("log_action VARCHAR(10) NOT NULL");
        colDefs.add("log_date TIMESTAMP DEFAULT NOW()");
        colDefs.add("log_user VARCHAR(100)");

        List<String> requestedCols = (logColumns != null && !logColumns.isEmpty())
                ? logColumns
                : new ArrayList<>(colTypes.keySet());

        // 실제 SQL 에 들어간 컬럼만 메타에 저장 (skip 된 것은 log_columns 설정에서도 빠짐)
        List<String> persistedCols = new ArrayList<>();
        for (String col : requestedCols) {
            if (col == null) continue;
            String safeCol = sanitize(col);
            if (safeCol.isBlank()) continue;           // sanitize 결과 빈 식별자 차단
            if (!colTypes.containsKey(col)) continue;  // 원본 테이블에 없는 컬럼 skip

            String rawType = colTypes.get(col);
            String normalized = (rawType == null ? "" : rawType.toLowerCase(Locale.ROOT).trim());
            if (!ALLOWED_LOG_COLUMN_TYPES.contains(normalized)) {
                // 알 수 없는 type 은 text 로 fallback (안전 default)
                log.warn("로그 테이블 컬럼 타입 화이트리스트 미일치 → text 로 대체: table={}, col={}, type={}",
                        safeOrig, safeCol, rawType);
                normalized = "text";
            }
            colDefs.add(String.format("\"%s\" %s", safeCol, normalized));
            persistedCols.add(safeCol);
        }

        if (persistedCols.isEmpty()) {
            throw new IllegalArgumentException("log 생성할 컬럼이 없습니다.");
        }

        String createSql = String.format(
                "CREATE TABLE IF NOT EXISTS \"%s\" (%s)", safeLog, String.join(", ", colDefs));
        jdbcTemplate.execute(createSql);

        // log_tables 설정 저장
        Map<String, Object> params = new HashMap<>();
        params.put("table_name", tableName);
        params.put("is_active", isActive);
        params.put("log_columns", String.join(",", persistedCols));
        sqlSession.update(NS + "upsertLogConfig", params);

        log.info("로그 테이블 생성: {}", safeLog);
    }

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

    public Map<String, Object> getLogData(String tableName, int page, int size) {
        String logTableName = sanitize(tableName) + "_log";
        if (!checkTableExists(logTableName)) {
            return Map.of("data", List.of(), "total", 0, "page", page, "size", size, "total_pages", 0);
        }
        int offset = (page - 1) * size;
        String countSql = String.format("SELECT COUNT(*) FROM \"%s\"", logTableName);
        Integer total = jdbcTemplate.queryForObject(countSql, Integer.class);
        if (total == null) total = 0;

        String dataSql = String.format(
                "SELECT * FROM \"%s\" ORDER BY log_date DESC LIMIT %d OFFSET %d",
                logTableName, size, offset);
        List<Map<String, Object>> data = jdbcTemplate.queryForList(dataSql);

        int totalPages = total == 0 ? 1 : (int) Math.ceil((double) total / size);
        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", totalPages);
        return result;
    }

    @Transactional
    public void toggleLogTable(String tableName, boolean isActive) {
        Map<String, Object> params = new HashMap<>();
        params.put("table_name", tableName);
        params.put("is_active", isActive);
        params.put("log_columns", "");
        sqlSession.update(NS + "upsertLogConfig", params);
        log.info("로그 테이블 토글: {} → {}", tableName, isActive);
    }

    // ──────────────────────────────────────────────────
    // DB 연결 확인
    // ──────────────────────────────────────────────────

    public Map<String, Object> checkDatabaseConnection() {
        try {
            sqlSession.selectOne(NS + "checkDatabaseConnection", null);
            return Map.of("connected", true, "message", "데이터베이스 연결 정상");
        } catch (Exception e) {
            log.error("DB 연결 실패", e);
            return Map.of("connected", false, "message", "데이터베이스 연결 실패: " + e.getMessage());
        }
    }

    // ──────────────────────────────────────────────────
    // 카테고리 / 채번 컬럼
    // ──────────────────────────────────────────────────

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

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

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

    // ──────────────────────────────────────────────────
    // 엔티티 관계
    // ──────────────────────────────────────────────────

    public Map<String, Object> getTableEntityRelations(String leftTable, String rightTable,
                                                        String companyCode) {
        Map<String, Object> params = new HashMap<>();
        params.put("left_table", leftTable);
        params.put("right_table", rightTable);
        params.put("company_code", companyCode);
        List<Map<String, Object>> relations = sqlSession.selectList(NS + "getEntityRelationList", params);
        Map<String, Object> result = new HashMap<>();
        result.put("left_table", leftTable);
        result.put("right_table", rightTable);
        result.put("relations", relations);
        return result;
    }

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

    // ──────────────────────────────────────────────────
    // 다중 테이블 저장
    // ──────────────────────────────────────────────────

    @Transactional
    public Map<String, Object> multiTableSave(Map<String, Object> payload, String companyCode) {
        @SuppressWarnings("unchecked")
        Map<String, Object> mainData = (Map<String, Object>) payload.get("main_data");
        String mainTable = (String) payload.get("main_table");
        @SuppressWarnings("unchecked")
        List<Map<String, Object>> subTables = (List<Map<String, Object>>) payload.get("sub_tables");

        if (mainTable == null || mainData == null) {
            throw new IllegalArgumentException("mainTable과 mainData가 필요합니다.");
        }

        // 멀티테넌시
        if (!mainData.containsKey("company_code") && hasColumn(sanitize(mainTable), "company_code")) {
            mainData.put("company_code", companyCode);
        }

        Map<String, Object> mainResult = addTableData(mainTable, mainData);
        String mainId = (String) mainResult.get("inserted_id");

        List<Map<String, Object>> subResults = new ArrayList<>();
        if (subTables != null) {
            for (Map<String, Object> subTable : subTables) {
                String subTableName = (String) subTable.get("table_name");
                @SuppressWarnings("unchecked")
                List<Map<String, Object>> rows = (List<Map<String, Object>>) subTable.get("rows");
                String fkColumn = (String) subTable.get("fk_column");

                if (subTableName == null || rows == null) continue;
                for (Map<String, Object> row : rows) {
                    if (fkColumn != null && mainId != null) row.put(fkColumn, mainId);
                    if (!row.containsKey("company_code") && hasColumn(sanitize(subTableName), "company_code")) {
                        row.put("company_code", companyCode);
                    }
                    addTableData(subTableName, row);
                }
                Map<String, Object> subResult = new HashMap<>();
                subResult.put("table_name", subTableName);
                subResult.put("row_count", rows.size());
                subResults.add(subResult);
            }
        }

        Map<String, Object> result = new HashMap<>();
        result.put("main_id", mainId);
        result.put("sub_results", subResults);
        log.info("다중 테이블 저장 완료: mainTable={}, mainId={}", mainTable, mainId);
        return result;
    }

    // ──────────────────────────────────────────────────
    // 엑셀 데이터 검증
    // ──────────────────────────────────────────────────

    public Map<String, Object> validateExcelData(String tableName, List<Map<String, Object>> rows,
                                                   String companyCode) {
        List<Map<String, Object>> errors = new ArrayList<>();
        for (int i = 0; i < rows.size(); i++) {
            Map<String, Object> row = rows.get(i);
            List<String> notNullViolations = validateNotNullConstraints(tableName, row, companyCode);
            if (!notNullViolations.isEmpty()) {
                Map<String, Object> err = new HashMap<>();
                err.put("row", i + 1);
                err.put("type", "NOT_NULL");
                err.put("columns", notNullViolations);
                errors.add(err);
            }
        }
        Map<String, Object> result = new HashMap<>();
        result.put("valid", errors.isEmpty());
        result.put("errors", errors);
        result.put("total_rows", rows.size());
        return result;
    }

    // ──────────────────────────────────────────────────
    // 내부 유틸
    // ──────────────────────────────────────────────────

    /** 테이블 컬럼명 → DB 타입 맵 */
    private Map<String, String> getColumnTypes(String safeTable) {
        String sql = "SELECT column_name, data_type FROM information_schema.columns WHERE table_name = ?";
        List<Map<String, Object>> rows = jdbcTemplate.queryForList(sql, safeTable);
        Map<String, String> map = new LinkedHashMap<>();
        for (Map<String, Object> row : rows) {
            map.put((String) row.get("column_name"), (String) row.get("data_type"));
        }
        return map;
    }

    /** 테이블 PK 컬럼 목록 */
    private List<String> getPrimaryKeys(String safeTable) {
        String sql = "SELECT kcu.column_name FROM information_schema.table_constraints tc " +
                "JOIN information_schema.key_column_usage kcu " +
                "ON tc.constraint_name = kcu.constraint_name " +
                "WHERE tc.table_name = ? AND tc.constraint_type = 'PRIMARY KEY' " +
                "ORDER BY kcu.ordinal_position";
        return jdbcTemplate.queryForList(sql, String.class, safeTable);
    }

    /** SQL injection 방지용 식별자 정리 */
    /**
     * SQL 식별자(테이블/컬럼명) 살균.
     * - 영숫자/언더스코어만 허용 (PostgreSQL identifier 규칙)
     * - 빈 문자열, 숫자로 시작, 63자 초과, SQL 예약어 거부 → IllegalArgumentException
     *
     * 이렇게 가드해두지 않으면 동적 SQL 에 빈 식별자가 들어가거나 예약어가 통과해
     * 의도치 않은 컬럼에 접근하거나 SQL 문법 깨짐(500) 이 생김.
     */
    private static final java.util.Set<String> SQL_RESERVED_WORDS = java.util.Set.of(
        "user", "order", "group", "table", "column", "index", "select", "insert",
        "update", "delete", "from", "where", "join", "on", "as", "and", "or", "not",
        "null", "true", "false", "create", "alter", "drop", "primary", "key",
        "foreign", "references", "constraint", "default", "unique", "check",
        "view", "procedure", "function"
    );

    private String sanitize(String name) {
        if (name == null) {
            throw new IllegalArgumentException("식별자가 null 입니다.");
        }
        String cleaned = name.replaceAll("[^a-zA-Z0-9_]", "");
        if (cleaned.isEmpty()) {
            throw new IllegalArgumentException("식별자가 비어있거나 유효하지 않습니다: " + name);
        }
        if (cleaned.length() > 63) {
            throw new IllegalArgumentException("식별자가 63자를 초과합니다: " + cleaned);
        }
        if (Character.isDigit(cleaned.charAt(0))) {
            throw new IllegalArgumentException("식별자는 숫자로 시작할 수 없습니다: " + cleaned);
        }
        if (SQL_RESERVED_WORDS.contains(cleaned.toLowerCase())) {
            throw new IllegalArgumentException("'" + cleaned + "' 은 SQL 예약어라 식별자로 사용할 수 없습니다.");
        }
        return cleaned;
    }

    /** "direct" / "auto" → "text" 변환 (legacy 호출처 보호 — system-normalize 동작) */
    private String normalizeInputType(String inputType) {
        if ("direct".equals(inputType) || "auto".equals(inputType)) {
            log.warn("잘못된 inputType 값 감지: {} → 'text'로 변환", inputType);
            return "text";
        }
        return inputType != null ? inputType : "text";
    }

    /**
     * context 에 따라 INPUT_TYPE 정규화 및 검증.
     */
    private String normalizeInputType(String value, InputTypeContext context) {
        if (context == InputTypeContext.USER_INSERT || context == InputTypeContext.USER_UPDATE_TYPE) {
            if (value == null || !InputTypeConstants.USER_SELECTABLE_INPUT_TYPES.contains(value)) {
                throw new IllegalArgumentException(
                    "INPUT_TYPE 은 다음 8개 중 하나여야 합니다: " + InputTypeConstants.USER_SELECTABLE_INPUT_TYPES
                    + " (받은 값: " + value + ")"
                );
            }
            return value;
        }
        // USER_UPDATE_OTHER / SYSTEM_NORMALIZE: 기존 동작 그대로
        return normalizeInputType(value);
    }

    private String toJsonString(Object obj) {
        if (obj == null) return "{}";
        if (obj instanceof String s) return s.isBlank() ? "{}" : s;
        try {
            return objectMapper.writeValueAsString(obj);
        } catch (Exception e) {
            log.warn("JSON 직렬화 실패, 빈 객체로 처리: {}", e.getMessage());
            return "{}";
        }
    }

    private int toInt(Object val, int def) {
        if (val == null) return def;
        try { return ((Number) val).intValue(); } catch (Exception e) {
            try { return Integer.parseInt(val.toString()); } catch (Exception ex) { return def; }
        }
    }

    @SuppressWarnings("unchecked")
    private List<String> parseColumnArray(Object cols) {
        if (cols instanceof List) return (List<String>) cols;
        if (cols instanceof String s) {
            return Arrays.stream(s.replace("{", "").replace("}", "").split(","))
                    .filter(c -> !c.isBlank())
                    .collect(Collectors.toList());
        }
        return List.of();
    }

    private void syncScreenLayouts(String tableName, String columnName,
                                    String inputType, String companyCode) {
        try {
            Map<String, Object> p = new HashMap<>();
            p.put("table_name", tableName);
            p.put("column_name", columnName);
            p.put("input_type", inputType);
            p.put("company_code", companyCode);
            p.put("component_id", mapInputTypeToComponentId(inputType));
            sqlSession.update(NS + "syncScreenLayoutsInputType", p);
        } catch (Exception e) {
            log.warn("화면 레이아웃 동기화 실패 (무시됨): {}.{}", tableName, columnName);
        }
    }

    private String mapInputTypeToComponentId(String inputType) {
        if (inputType == null) return "text-input";
        return switch (inputType) {
            case "number", "decimal" -> "number-input";
            case "date", "datetime", "time" -> "date-input";
            case "textarea" -> "textarea-basic";
            case "select", "dropdown", "code", "entity", "category" -> "select-basic";
            case "checkbox" -> "checkbox-basic";
            case "radio" -> "radio-basic";
            case "boolean" -> "toggle-switch";
            case "file" -> "file-upload";
            case "image", "img", "picture", "photo" -> "image-widget";
            case "button" -> "button-primary";
            case "label" -> "text-display";
            default -> "text-input";
        };
    }
}
