package com.zioinfo.kintex.ai.nlquery;

import com.zioinfo.kintex.common.error.ApiException;
import com.zioinfo.kintex.common.error.ErrorCode;
import org.springframework.stereotype.Component;

import java.util.LinkedHashSet;
import java.util.Locale;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * F092 자연어 조회(Text-to-SQL) 안전 가드 — LLM 이 생성한 SQL 을 <b>실행 전</b> 검증한다.
 *
 * <p>LLM 산출 SQL 은 신뢰할 수 없다(프롬프트 인젝션·환각·오작성). 이 가드는 결정론적(정규식 기반) 검증으로
 * 오직 <b>단일 읽기 전용 SELECT</b> 만, <b>화이트리스트 테이블</b> 에 대해서만, <b>PII 원문 컬럼 없이</b>,
 * <b>LIMIT 200 이하</b> 로만 통과시킨다. 하나라도 위반하면 {@link ErrorCode#VALIDATION} 을 던져 실행을 차단한다.
 *
 * <p>이중 방어: 여기서 통과해도 실제 실행은 read-only 트랜잭션({@link NlQueryService})에서 수행한다.
 */
@Component
public class NlQuerySqlGuard {

    /** 조회 허용 테이블(그 외 모든 테이블·시스템 카탈로그 차단). */
    public static final Set<String> ALLOWED_TABLES = Set.of(
            "event", "hall", "booth", "visitor_registration", "lead", "invoice", "auction");

    /** PII 원문 컬럼(어떤 경우에도 SQL 에 등장 금지 — 프롬프트에서도 제외). */
    private static final Set<String> PII_COLUMNS = Set.of("phone", "email");
    /** visitor_registration·lead 참조 시 추가로 금지되는 PII 컬럼(event.name 등 비-PII 는 허용). */
    private static final Set<String> PII_NAME_TABLES = Set.of("visitor_registration", "lead");

    /** 쓰기/DDL/제어/우회 키워드(단어 경계). SELECT 단일 읽기문 외 일체 차단. */
    private static final Pattern FORBIDDEN = Pattern.compile(
            "\\b(insert|update|delete|drop|alter|truncate|create|grant|revoke|merge|replace|"
                    + "call|exec|execute|do|copy|vacuum|analyze|reindex|cluster|lock|"
                    + "comment|into|returning|nextval|setval|pg_sleep|pg_read_file|lo_import|lo_export|dblink)\\b",
            Pattern.CASE_INSENSITIVE);

    /** FROM/JOIN 뒤의 테이블 참조(스키마.테이블 형태 포함) 추출. */
    private static final Pattern TABLE_REF = Pattern.compile(
            "\\b(?:from|join)\\s+([a-zA-Z_][\\w.]*)", Pattern.CASE_INSENSITIVE);

    private static final Pattern LIMIT_NUM = Pattern.compile("\\blimit\\s+(\\d+)", Pattern.CASE_INSENSITIVE);
    private static final int MAX_LIMIT = 200;

    /** 가드 통과 SQL 과 검증 근거. */
    public record GuardedSql(String sql, java.util.List<String> tables, int limit) {
    }

    /**
     * LLM 산출 SQL 을 검증·정규화한다. 위반 시 {@link ApiException}(VALIDATION) 을 던진다.
     * 통과 시 LIMIT 이 강제 부여/제한된 실행용 SQL 을 반환한다.
     */
    public GuardedSql validate(String raw) {
        if (raw == null || raw.isBlank()) {
            throw new ApiException(ErrorCode.VALIDATION, "생성된 SQL 이 비어 있습니다.");
        }
        String sql = strip(raw);
        String lower = sql.toLowerCase(Locale.ROOT);

        // 1) 주석 차단(주석 기반 우회 방지).
        if (sql.contains("--") || sql.contains("/*") || sql.contains("*/") || sql.contains("#")) {
            throw new ApiException(ErrorCode.VALIDATION, "SQL 주석은 허용되지 않습니다.");
        }
        // 2) 다중문 차단(세미콜론). 끝의 단일 세미콜론만 허용해 제거.
        String noTrailing = sql.endsWith(";") ? sql.substring(0, sql.length() - 1) : sql;
        if (noTrailing.contains(";")) {
            throw new ApiException(ErrorCode.VALIDATION, "단일 SELECT 문만 허용됩니다(다중 문 금지).");
        }
        sql = noTrailing.trim();
        lower = sql.toLowerCase(Locale.ROOT);

        // 3) SELECT 로만 시작(단일 읽기문). WITH/CTE·서브쿼리 밖 시작 키워드 제한.
        if (!lower.startsWith("select")) {
            throw new ApiException(ErrorCode.VALIDATION, "SELECT 로 시작하는 조회문만 허용됩니다.");
        }
        // 4) 금지 키워드(쓰기/DDL/제어/우회) 차단.
        Matcher fm = FORBIDDEN.matcher(sql);
        if (fm.find()) {
            throw new ApiException(ErrorCode.VALIDATION, "허용되지 않은 키워드가 포함되어 있습니다: " + fm.group(1));
        }
        // 5) 테이블 화이트리스트 검증(FROM/JOIN 대상 전부 허용 목록에 있어야 함).
        java.util.List<String> tables = new java.util.ArrayList<>();
        Set<String> refSet = new LinkedHashSet<>();
        Matcher tm = TABLE_REF.matcher(sql);
        while (tm.find()) {
            String ref = tm.group(1);
            // 스키마 접두어 제거(public.event → event) 및 소문자화.
            String tbl = ref.contains(".") ? ref.substring(ref.lastIndexOf('.') + 1) : ref;
            tbl = tbl.toLowerCase(Locale.ROOT);
            if (!ALLOWED_TABLES.contains(tbl)) {
                throw new ApiException(ErrorCode.VALIDATION, "조회 허용되지 않은 테이블입니다: " + tbl);
            }
            if (refSet.add(tbl)) {
                tables.add(tbl);
            }
        }
        if (tables.isEmpty()) {
            throw new ApiException(ErrorCode.VALIDATION, "조회 대상 테이블(FROM)을 찾을 수 없습니다.");
        }
        // 6) PII 원문 컬럼 차단. phone/email 은 항상, name 은 visitor_registration/lead 참조 시 차단.
        for (String pii : PII_COLUMNS) {
            if (containsWord(lower, pii)) {
                throw new ApiException(ErrorCode.VALIDATION, "개인정보 컬럼은 조회할 수 없습니다.");
            }
        }
        boolean touchesPiiTable = tables.stream().anyMatch(PII_NAME_TABLES::contains);
        if (touchesPiiTable && containsWord(lower, "name")) {
            throw new ApiException(ErrorCode.VALIDATION, "개인정보 컬럼(name)은 조회할 수 없습니다.");
        }
        // 7) LIMIT 200 강제. 기존 limit 은 200 이하만 허용, 없으면 부여.
        int limit;
        Matcher lm = LIMIT_NUM.matcher(sql);
        if (lm.find()) {
            try {
                limit = Integer.parseInt(lm.group(1));
            } catch (NumberFormatException e) {
                throw new ApiException(ErrorCode.VALIDATION, "LIMIT 값이 올바르지 않습니다.");
            }
            if (limit > MAX_LIMIT || limit <= 0) {
                throw new ApiException(ErrorCode.VALIDATION, "LIMIT 는 1~" + MAX_LIMIT + " 사이여야 합니다.");
            }
        } else {
            sql = sql + " LIMIT " + MAX_LIMIT;
            limit = MAX_LIMIT;
        }
        return new GuardedSql(sql, tables, limit);
    }

    /** 코드펜스(```sql ... ```)·잉여 공백 제거. */
    private static String strip(String s) {
        String out = s.trim();
        if (out.startsWith("```")) {
            int nl = out.indexOf('\n');
            if (nl >= 0) {
                out = out.substring(nl + 1);
            }
            int fence = out.lastIndexOf("```");
            if (fence >= 0) {
                out = out.substring(0, fence);
            }
        }
        // 여러 줄/탭을 단일 공백으로 정규화(가드 정규식 안정화).
        return out.replaceAll("\\s+", " ").trim();
    }

    /** 단어 경계 기반 토큰 포함 검사(부분 문자열 오탐 방지). */
    private static boolean containsWord(String haystackLower, String word) {
        return Pattern.compile("\\b" + Pattern.quote(word) + "\\b").matcher(haystackLower).find();
    }
}
