/**
 * Task B: PDF 업로드 감지 → 자동 인덱싱 Cloud Function
 * Spec: docs/specs/260225-insuwiki-rag-spec-v2.md § 14-1
 *
 * 트리거: Firestore jobs 컬렉션에 type=index_pdf 인 Job 문서 생성 시
 * (Cloud Storage 트리거 대신 Firestore 기반으로 통일)
 *
 * 처리 흐름:
 * 1. Drive 경로 파싱 → insurance_metadata upsert
 * 2. PDF 다운로드 → 청크 분할 (500~800자, 단락 기준)
 * 3. 청크별 Gemini Embedding → insurance_chunks 저장
 *    ⚠️ 보험료/수치 표 내용은 청킹 제외 (insurance_tables로 분리)
 * 4. 용어 자동 추출 → insurance_terms 저장 (verified: false)
 * 5. Job 완료 처리 + index_logs 저장
 */

import * as admin from 'firebase-admin';
import { onDocumentCreated } from 'firebase-functions/v2/firestore';
import { GoogleGenerativeAI } from '@google/generative-ai';
import { GoogleAIFileManager } from '@google/generative-ai/server';
import { google } from 'googleapis';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
// @opendataloader/pdf는 ESM 전용 패키지 (import.meta.url 사용)
// Firebase CLI가 CJS 환경에서 cold-load 시 크래시하므로 lazy dynamic import 사용

const TERM_EXTRACTION_PROMPT = `
이 약관에서 보험 용어를 추출해주세요.
특히 다음을 찾아주세요:
1. 이 상품 고유의 담보/특약 명칭
2. 질병 분류 기준 (어떤 코드/정의를 쓰는지)
3. 일반 용어와 다르게 정의된 용어

JSON 배열로만 출력 (다른 텍스트 금지):
[{
  "term": "뇌혈관질환",
  "definition": "약관 원문 정의 그대로",
  "commonAliases": ["뇌졸중", "중풍"],
  "icdCodes": ["I60", "I61", "I63"],
  "pageNumber": 12
}]
`;

// 보험료/해지환급금 표 청킹 제외 패턴
const TABLE_EXCLUSION_PATTERNS = [
    /보험료\s*표/,
    /해지환급금\s*표/,
    /납입보험료.*원/,
    /연령.*남.*여/,
    /월납.*연납/,
];

function isTableContent(text: string): boolean {
    return TABLE_EXCLUSION_PATTERNS.some(p => p.test(text));
}

// ── 별표/부속서류 섹션 감지 패턴 ─────────────────────────────────────────
const APPENDIX_PATTERNS = [
    /^[\s]*\[?별표[\s]*\d*\]?/m,
    /^[\s]*별표[\s]*\d*/m,
    /^[\s]*부표[\s]*\d*/m,
    /^[\s]*부속서류/m,
];

// 별표 본문 참조 패턴
const APPENDIX_REFERENCE_PATTERN = /별표\s*\d*\s*(?:참조|참고|에\s*따라|수술분류표|장해분류표|질병분류)/g;

// 별표 유형
export type AppendixType = 'surgery_table' | 'disability_table' | 'disease_code' | 'other';

export function classifyAppendixType(text: string): AppendixType {
    if (/수술분류|수술코드|수술 분류/.test(text)) return 'surgery_table';
    if (/장해분류|장해 분류|장해등급/.test(text)) return 'disability_table';
    if (/질병분류|질병코드|질병 분류|ICD|KCD/.test(text)) return 'disease_code';
    return 'other';
}

export function isAppendixSection(text: string): boolean {
    return APPENDIX_PATTERNS.some(p => p.test(text));
}

/**
 * 전체 텍스트에서 별표/부속서류 섹션을 분리하여 반환
 * - [PAGE N] 마커 단위로 페이지 블록을 순회하며 별표 시작 여부 감지
 * - 별표 섹션 내에서 "제N조" 패턴이 다시 등장하면 본문으로 복귀
 * - 각 별표 섹션에 classifyAppendixType으로 유형 분류
 */
export function separateAppendixSections(fullText: string): {
    mainText: string;
    appendixSections: { text: string; type: AppendixType }[];
} {
    // [PAGE N] 마커를 기준으로 페이지 블록으로 분리 (마커 포함)
    const pageBlocks = fullText.split(/(?=\[PAGE \d+\])/);

    const mainBlocks: string[] = [];
    const appendixSections: { text: string; type: AppendixType }[] = [];

    let inAppendix = false;
    let currentAppendixChunks: string[] = [];

    for (const block of pageBlocks) {
        if (isAppendixSection(block)) {
            // 이전 별표 섹션이 있으면 먼저 확정
            if (inAppendix && currentAppendixChunks.length > 0) {
                const appendixText = currentAppendixChunks.join('');
                appendixSections.push({
                    text: appendixText,
                    type: classifyAppendixType(appendixText),
                });
                currentAppendixChunks = [];
            }
            // 새 별표 섹션 시작
            inAppendix = true;
            currentAppendixChunks.push(block);
        } else if (inAppendix) {
            // "제N조" 패턴이 등장하면 본문 조항으로 복귀
            if (/\n\s*제\s*[\d일이삼사오육칠팔구십백천만]+\s*조/.test(block)) {
                const appendixText = currentAppendixChunks.join('');
                appendixSections.push({
                    text: appendixText,
                    type: classifyAppendixType(appendixText),
                });
                currentAppendixChunks = [];
                inAppendix = false;
                mainBlocks.push(block);
            } else {
                currentAppendixChunks.push(block);
            }
        } else {
            mainBlocks.push(block);
        }
    }

    // 마지막 별표 섹션 확정
    if (inAppendix && currentAppendixChunks.length > 0) {
        const appendixText = currentAppendixChunks.join('');
        appendixSections.push({
            text: appendixText,
            type: classifyAppendixType(appendixText),
        });
    }

    return {
        mainText: mainBlocks.join(''),
        appendixSections,
    };
}

/**
 * 별표 텍스트를 Markdown 테이블로 변환 시도
 * - PDF 추출 텍스트의 탭/2+공백 패턴으로 열 구분
 * - "| 구분 | 내용 |" 형태로 재구성
 * - 변환 실패 시 null 반환 (원문 보존)
 */
export function parseAppendixToMarkdownTable(text: string): { markdown: string; parsedTable: { headers: string[]; rows: string[][] } } | null {
    // [PAGE N] 마커 제거
    const cleanText = text.replace(/\[PAGE \d+\]/g, '').trim();
    if (!cleanText) return null;

    const lines = cleanText.split('\n').map(l => l.trim()).filter(l => l.length > 0);
    if (lines.length < 2) return null; // 최소 헤더 + 1행

    // 탭 또는 2개 이상 연속 공백으로 열 구분 시도
    const delimiter = /\t|  +/;

    // 열 수가 2개 이상인 행이 전체의 50% 이상이면 테이블로 판단
    const splitLines = lines.map(line => line.split(delimiter).map(cell => cell.trim()).filter(cell => cell.length > 0));
    const multiColLines = splitLines.filter(cols => cols.length >= 2);

    if (multiColLines.length < lines.length * 0.5) return null; // 테이블 아님

    // 첫 번째 multi-column 행을 헤더로 사용
    const headerIdx = splitLines.findIndex(cols => cols.length >= 2);
    if (headerIdx === -1) return null;

    const headers = splitLines[headerIdx];
    const colCount = headers.length;

    // 데이터 행 구성
    const rows: string[][] = [];
    for (let i = headerIdx + 1; i < splitLines.length; i++) {
        const cols = splitLines[i];
        if (cols.length < 2) continue; // 단일 열은 스킵
        // 열 수가 다르면 맞춤 (부족하면 빈 문자열, 초과하면 마지막 열에 합침)
        if (cols.length < colCount) {
            while (cols.length < colCount) cols.push('');
        } else if (cols.length > colCount) {
            const extra = cols.splice(colCount - 1);
            cols.push(extra.join(' '));
        }
        rows.push(cols);
    }

    if (rows.length === 0) return null;

    // Markdown 테이블 생성
    const headerLine = '| ' + headers.join(' | ') + ' |';
    const separatorLine = '| ' + headers.map(() => '---').join(' | ') + ' |';
    const rowLines = rows.map(row => '| ' + row.join(' | ') + ' |');
    const markdown = [headerLine, separatorLine, ...rowLines].join('\n');

    return { markdown, parsedTable: { headers, rows } };
}

// opendataloader/pdf 인터페이스 정의
interface OdlElement {
    type: string;
    id?: number;
    'page number'?: number;
    'bounding box'?: number[];
    content?: string;
    kids?: OdlElement[];
    'list items'?: OdlElement[];
    'heading level'?: number;
}

interface OdlOutput {
    'file name': string;
    'number of pages': number;
    kids: OdlElement[];
}

function collectElements(node: OdlElement | OdlOutput, elements: OdlElement[] = []): OdlElement[] {
    if ('content' in node && (node as OdlElement).content) {
        elements.push(node as OdlElement);
    }
    const kids = (node as any).kids || [];
    for (const kid of kids) {
        collectElements(kid, elements);
    }
    const listItems = (node as any)['list items'] || [];
    for (const item of listItems) {
        collectElements(item, elements);
    }
    return elements;
}

// @opendataloader/pdf로 페이지별 텍스트 추출 (pdf-parse 대체)
async function extractTextWithOpendataloader(pdfPath: string): Promise<{ fullText: string; pageCount: number }> {
    // Set JAVA_HOME if not already set (for local development)
    if (!process.env.JAVA_HOME) {
        process.env.JAVA_HOME = '/home/jay/.local/jdk/jdk-11.0.25+9-jre';
        process.env.PATH = `/home/jay/.local/jdk/jdk-11.0.25+9-jre/bin:${process.env.PATH}`;
    }

    const outputDir = path.join(os.tmpdir(), `odl_${Date.now()}`);
    fs.mkdirSync(outputDir, { recursive: true });

    try {
        // Lazy dynamic import: Firebase CLI cold-load 시 ESM 패키지 크래시 방지
        const { convert } = await import('@opendataloader/pdf');
        await convert([pdfPath], {
            outputDir,
            format: 'json'
        });

        // Find the output JSON file
        const jsonFiles = fs.readdirSync(outputDir).filter(f => f.endsWith('.json'));
        if (jsonFiles.length === 0) {
            throw new Error('opendataloader-pdf: no JSON output found');
        }

        const data: OdlOutput = JSON.parse(
            fs.readFileSync(path.join(outputDir, jsonFiles[0]), 'utf-8')
        );

        const pageCount = data['number of pages'];
        const elements = collectElements(data);

        // Group elements by page number and create [PAGE N] markers
        const pageMap = new Map<number, string[]>();
        for (const elem of elements) {
            const pageNum = elem['page number'] || 1;
            if (!pageMap.has(pageNum)) pageMap.set(pageNum, []);
            if (elem.content) {
                pageMap.get(pageNum)!.push(elem.content);
            }
        }

        // Sort by page number and create marked text
        const sortedPages = [...pageMap.entries()].sort((a, b) => a[0] - b[0]);
        const markedText = sortedPages
            .map(([pageNum, texts]) => `[PAGE ${pageNum}]\n${texts.join('\n')}`)
            .join('\n\n');

        return { fullText: markedText, pageCount };
    } finally {
        // Cleanup temp dir
        try {
            fs.rmSync(outputDir, { recursive: true, force: true });
        } catch (e) {
            console.warn('Failed to cleanup temp dir:', e);
        }
    }
}

// 단락 기준 청크 분할 (500~800자)
function splitIntoChunks(text: string, maxLength = 750, overlap = 100): string[] {
    const paragraphs = text.split(/\n\n+/);
    const chunks: string[] = [];
    let current = '';

    for (const para of paragraphs) {
        // 보험료 표 내용 제외
        if (isTableContent(para)) continue;

        if ((current + para).length > maxLength && current) {
            chunks.push(current.trim());
            current = para;
        } else {
            current += (current ? '\n\n' : '') + para;
        }
    }
    if (current.trim()) chunks.push(current.trim());

    const filtered = chunks.filter(c => c.length > 50); // 너무 짧은 청크 제외

    // overlap이 0보다 크면 인접 청크 간 overlap 적용
    if (overlap > 0 && filtered.length > 1) {
        for (let i = 1; i < filtered.length; i++) {
            const prevChunk = filtered[i - 1];
            const overlapText = prevChunk.slice(-overlap);
            filtered[i] = overlapText + '\n\n' + filtered[i];
        }
    }

    return filtered;
}

/**
 * 구조 인식 청킹 함수
 *
 * 인식 패턴:
 *   - 조항 경계: "제X조" (한글 숫자·아라비아 숫자 모두)
 *   - 항 번호:   ① ② ③ ... (원문자) 및 ⑴ ⑵ ⑶ ...
 *   - 목 번호:   가. 나. 다. (한글 목록)
 *   - 괄호 번호: (1) (2) (3)
 *   - 점 번호:   1. 2. 3. (줄 첫머리)
 *
 * 동작:
 *   1. "제X조" 단위로 1차 분할 → 조 전체가 1청크
 *   2. 2000자 초과 조는 항(①②③) 단위로 2차 분할
 *   3. 단서조항("다만", "단,", "그러나", "제외한다" 등) → 이전 문장과 동일 청크 강제 병합
 *   4. "면책", "부지급" 등 키워드 포함 블록 → 관련 열거 전체를 하나의 청크로 병합
 *   5. Overlap 300자 양방향 적용
 *   6. [PAGE N] 마커 보존
 *   7. isTableContent로 표 내용 제외
 */
function structureAwareChunk(text: string): string[] {
    const MAX_CHUNK_SIZE = 2000;
    const OVERLAP_SIZE = 300;

    // ── 단서조항 키워드 ────────────────────────────────────────
    const PROVISO_KEYWORDS = [
        '다만', '단,', '단 ', '그러나', '제외한다', '적용하지 않는다',
        '하지 아니한다', '아니한다', '제외됩니다', '적용되지 않습니다',
    ];

    // ── 면책/부지급 키워드 ─────────────────────────────────────
    const EXCLUSION_KEYWORDS = [
        '면책', '부지급', '보장하지 아니', '보상하지 아니',
        '보장하지 않', '보상하지 않',
    ];

    // 패턴: 조항 경계 (제N조, 제N조의N)
    // 한글 숫자(일이삼...) 및 아라비아 숫자 모두 허용
    const ARTICLE_PATTERN = /(?=\n\s*제\s*[\d일이삼사오육칠팔구십백천만]+\s*조)/g;

    // 패턴: 항 경계 (원문자 ①-⑳ 및 ⑴-⒇, 줄 첫머리 기준)
    const PARAGRAPH_PATTERN = /(?=\n\s*[①②③④⑤⑥⑦⑧⑨⑩⑪⑫⑬⑭⑮⑯⑰⑱⑲⑳⑴⑵⑶⑷⑸⑹⑺⑻⑼⑽])/g;

    // 텍스트가 없거나 너무 짧으면 빈 배열 반환 → 폴백 처리
    if (!text || text.trim().length < 100) return [];

    // ── Step 1: "제X조" 단위로 1차 분할 ──────────────────────
    const articleBlocks = text.split(ARTICLE_PATTERN);

    // 조항 패턴이 전혀 없으면 빈 배열 반환 → splitIntoChunks 폴백
    const hasArticles = articleBlocks.some(b => /제\s*[\d일이삼사오육칠팔구십백천만]+\s*조/.test(b));
    if (!hasArticles) return [];

    // ── Step 2: 각 조항 블록을 처리 ──────────────────────────
    let rawChunks: string[] = [];

    for (const block of articleBlocks) {
        const trimmed = block.trim();
        if (!trimmed) continue;

        // 표 내용 제외
        if (isTableContent(trimmed)) continue;

        if (trimmed.length <= MAX_CHUNK_SIZE) {
            rawChunks.push(trimmed);
        } else {
            // 2000자 초과 → 항(①②③) 단위로 2차 분할
            const paragraphBlocks = trimmed.split(PARAGRAPH_PATTERN);

            if (paragraphBlocks.length <= 1) {
                // 항 구분자 없음 → 가./나./(1)(2)/1.2. 패턴으로 추가 분할 시도
                const subBlocks = splitBySubPatterns(trimmed, MAX_CHUNK_SIZE);
                rawChunks = rawChunks.concat(subBlocks);
            } else {
                // 항 블록을 MAX_CHUNK_SIZE 이내로 병합
                const merged = mergeBlocksToSize(paragraphBlocks, MAX_CHUNK_SIZE);
                rawChunks = rawChunks.concat(merged);
            }
        }
    }

    // ── Step 3: 단서조항 병합 ─────────────────────────────────
    rawChunks = mergeProvisoClauses(rawChunks, PROVISO_KEYWORDS);

    // ── Step 4: 면책/부지급 블록 강제 병합 ────────────────────
    rawChunks = mergeExclusionBlocks(rawChunks, EXCLUSION_KEYWORDS);

    // ── Step 5: 너무 짧은 청크 필터링 ────────────────────────
    const filtered = rawChunks.filter(c => c.trim().length > 50);

    if (filtered.length === 0) return [];

    // ── Step 6: 양방향 Overlap 300자 적용 ────────────────────
    const withOverlap: string[] = [];
    for (let i = 0; i < filtered.length; i++) {
        let chunk = filtered[i];

        // 이전 청크의 마지막 300자를 앞에 추가
        if (i > 0) {
            const prevTail = filtered[i - 1].slice(-OVERLAP_SIZE);
            chunk = prevTail + '\n\n' + chunk;
        }

        // 다음 청크의 처음 300자를 뒤에 추가
        if (i < filtered.length - 1) {
            const nextHead = filtered[i + 1].slice(0, OVERLAP_SIZE);
            chunk = chunk + '\n\n' + nextHead;
        }

        withOverlap.push(chunk);
    }

    return withOverlap;
}

/**
 * 가./나./(1)(2)/1.2. 등 하위 패턴으로 블록을 분할한 후 maxSize 이내로 병합
 */
function splitBySubPatterns(text: string, maxSize: number): string[] {
    // 가. 나. 다. / (1) (2) (3) / 1. 2. 3. (줄 첫머리)
    const SUB_PATTERNS = [
        /(?=\n\s*[가나다라마바사아자차카타파하]\s*\.)/g,
        /(?=\n\s*\(\d+\))/g,
        /(?=\n\s*\d+\.\s)/g,
    ];

    for (const pattern of SUB_PATTERNS) {
        const blocks = text.split(pattern);
        if (blocks.length > 1) {
            return mergeBlocksToSize(blocks, maxSize);
        }
    }

    // 어떤 패턴으로도 분할 안 되면 maxSize 단위로 강제 분할
    const result: string[] = [];
    for (let i = 0; i < text.length; i += maxSize) {
        const slice = text.slice(i, i + maxSize).trim();
        if (slice) result.push(slice);
    }
    return result;
}

/**
 * 블록 배열을 maxSize 이내가 되도록 병합
 * 표 내용(isTableContent)은 제외
 */
function mergeBlocksToSize(blocks: string[], maxSize: number): string[] {
    const result: string[] = [];
    let current = '';

    for (const block of blocks) {
        const trimmed = block.trim();
        if (!trimmed) continue;
        if (isTableContent(trimmed)) continue;

        if (current && (current + '\n' + trimmed).length > maxSize) {
            result.push(current.trim());
            current = trimmed;
        } else {
            current += (current ? '\n' : '') + trimmed;
        }
    }
    if (current.trim()) result.push(current.trim());
    return result;
}

/**
 * 단서조항 키워드로 시작하는 문장을 이전 청크에 강제 병합
 * 청크 내부의 문장 단위로 검사하여 이전 문장과 합침
 */
function mergeProvisoClauses(chunks: string[], keywords: string[]): string[] {
    return chunks.map(chunk => {
        // 문장 단위 분리 (마침표·줄바꿈 기준)
        const sentences = chunk.split(/(?<=[\.\]\)。])\s+(?=[^\s])/);
        const merged: string[] = [];

        for (const sentence of sentences) {
            const startsWithProviso = keywords.some(kw => {
                const idx = sentence.indexOf(kw);
                // 문장 앞부분(30자 이내)에 단서 키워드가 있어야 함
                return idx >= 0 && idx < 30;
            });

            if (startsWithProviso && merged.length > 0) {
                // 이전 문장에 이어 붙임
                merged[merged.length - 1] += ' ' + sentence.trim();
            } else {
                merged.push(sentence);
            }
        }

        return merged.join(' ').trim();
    });
}

/**
 * 면책/부지급 키워드를 포함하는 연속된 청크들을 하나로 강제 병합
 */
function mergeExclusionBlocks(chunks: string[], keywords: string[]): string[] {
    const isExclusionChunk = (c: string) => keywords.some(kw => c.includes(kw));

    const result: string[] = [];
    let i = 0;

    while (i < chunks.length) {
        if (!isExclusionChunk(chunks[i])) {
            result.push(chunks[i]);
            i++;
            continue;
        }

        // 면책/부지급 블록 시작 → 연속되는 관련 청크 전부 병합
        let merged = chunks[i];
        let j = i + 1;

        while (j < chunks.length && isExclusionChunk(chunks[j])) {
            merged += '\n\n' + chunks[j];
            j++;
        }

        result.push(merged.trim());
        i = j;
    }

    return result;
}

// 임베딩 지수 백오프 retry 유틸
async function embedWithRetry(
    embedModel: any,
    chunk: string,
    maxRetries = 3
): Promise<any> {
    for (let attempt = 0; attempt <= maxRetries; attempt++) {
        try {
            return await embedModel.embedContent(chunk);
        } catch (err) {
            if (attempt === maxRetries) throw err;
            const delayMs = Math.pow(2, attempt) * 1000; // 1초, 2초, 4초
            console.warn(`⚠️ 임베딩 실패 (시도 ${attempt + 1}/${maxRetries}), ${delayMs}ms 후 재시도...`, err);
            await new Promise(r => setTimeout(r, delayMs));
        }
    }
}

export const onPdfIndexingJob = onDocumentCreated({
    document: 'jobs/{jobId}',
    timeoutSeconds: 540,
    memory: '1GiB',
    region: 'asia-northeast3',
}, async (event) => {
    const snapshot = event.data;
    if (!snapshot) return;

    const jobData = snapshot.data();
    const jobId = snapshot.id;

    // PDF 인덱싱 Job이 아니면 패스
    if (jobData.type !== 'index_pdf' || jobData.status !== 'pending') return;

    const db = admin.firestore();
    const jobRef = db.collection('jobs').doc(jobId);

    let tempPath = '';

    try {
        await jobRef.update({ status: 'indexing', updatedAt: admin.firestore.FieldValue.serverTimestamp() });

        const { driveFileId, companyId, companyName, productId, productName, category, effectiveDate } = jobData;

        // ── API 설정 ──────────────────────────────────────────────
        const apiKey = process.env.GEMINI_API_KEY;
        if (!apiKey) throw new Error('GEMINI_API_KEY 환경 변수 없음');

        const genAI = new GoogleGenerativeAI(apiKey);
        const fileManager = new GoogleAIFileManager(apiKey);
        const embedModel = genAI.getGenerativeModel({ model: 'text-embedding-004' });
        const analysisModel = genAI.getGenerativeModel({ model: 'gemini-2.0-flash' }); // 용어 추출 전용

        // ── 1. Drive → 임시 다운로드 ──────────────────────────────
        const auth = new google.auth.GoogleAuth({
            scopes: ['https://www.googleapis.com/auth/drive.readonly'],
        });
        const drive = google.drive({ version: 'v3', auth });

        tempPath = path.join(os.tmpdir(), `${driveFileId}.pdf`);
        const dest = fs.createWriteStream(tempPath);
        const driveResponse = await drive.files.get({ fileId: driveFileId, alt: 'media' }, { responseType: 'stream' });
        await new Promise((resolve, reject) => driveResponse.data.pipe(dest).on('finish', resolve).on('error', reject));

        // ── 2. Gemini File API 업로드 (Long-Context 용 + 용어 추출 용) ──
        const uploadResponse = await fileManager.uploadFile(tempPath, {
            mimeType: 'application/pdf',
            displayName: `${companyName}_${productName}`,
        });

        // 파일 처리 완료 대기
        let geminiFile = uploadResponse.file;
        while (geminiFile.state === 'PROCESSING') {
            await new Promise(r => setTimeout(r, 5000));
            geminiFile = await fileManager.getFile(geminiFile.name!);
        }
        if (geminiFile.state === 'FAILED') throw new Error('Gemini File 처리 실패');

        // ── 3. 용어 자동 추출 (§11) ───────────────────────────────
        console.log('📚 용어 자동 추출 중...');
        const termResult = await analysisModel.generateContent({
            contents: [{
                role: 'user',
                parts: [
                    { fileData: { mimeType: 'application/pdf', fileUri: geminiFile.uri } },
                    { text: TERM_EXTRACTION_PROMPT },
                ],
            }],
            generationConfig: { responseMimeType: 'application/json' },
        });

        try {
            const terms: any[] = JSON.parse(termResult.response.text());
            const batch = db.batch();
            for (const term of terms.slice(0, 50)) { // 최대 50개
                const termDocId = `${companyId}_${productId}_${term.term}`;
                const termRef = db.collection('insurance_terms').doc(termDocId);
                batch.set(termRef, {
                    term: term.term,
                    definition: term.definition,
                    commonAliases: term.commonAliases || [],
                    icdCodes: term.icdCodes || [],
                    companyId,
                    productId,
                    pageNumber: term.pageNumber || 0,
                    verified: false, // 관리자 검수 후 true로 변경
                    createdAt: admin.firestore.FieldValue.serverTimestamp(),
                }, { merge: true });
            }
            await batch.commit();
            console.log(`✅ 용어 ${terms.length}개 추출 완료 (verified: false)`);
        } catch (e) {
            console.warn('⚠️ 용어 추출 파싱 실패 (계속 진행):', e);
        }

        // ── 4. 청크 분할 + Embedding + insurance_chunks 저장 ──────
        console.log('✂️ 청크 분할 + Embedding 시작...');

        // @opendataloader/pdf로 텍스트 추출 (pdf-parse 대체)
        const { fullText, pageCount } = await extractTextWithOpendataloader(tempPath);

        // ── Item 13: 스캔 PDF 감지 + 조기 실패 ──────────────────
        const avgCharsPerPage = pageCount > 0 ? fullText.length / pageCount : 0;
        if (avgCharsPerPage < 50) {
            await jobRef.update({
                status: 'failed',
                error: '스캔 PDF로 감지됨. OCR 파이프라인이 필요합니다.',
                updatedAt: admin.firestore.FieldValue.serverTimestamp(),
            });
            console.warn(`⚠️ 스캔 PDF 감지: 페이지당 평균 ${avgCharsPerPage.toFixed(1)}자. 종료.`);
            return;
        }

        // ── 별표/부속서류 섹션 분리 ────────────────────────────
        const { mainText, appendixSections } = separateAppendixSections(fullText);
        console.log(`별표 섹션 ${appendixSections.length}개 감지 (유형: ${appendixSections.map(a => a.type).join(', ') || '없음'})`);

        // ── Item 21: 대용량 PDF 200페이지씩 분할 처리 (본문 대상) ──
        let chunks: string[];
        if (pageCount > 500) {
            console.log(`📄 대용량 PDF 감지: ${pageCount}페이지. 200페이지씩 분할 처리.`);
            const pageMarkers = mainText.split(/(?=\[PAGE \d+\])/);
            const PAGE_SPLIT_SIZE = 200;
            chunks = [];
            for (let start = 0; start < pageMarkers.length; start += PAGE_SPLIT_SIZE) {
                const segmentPages = pageMarkers.slice(start, start + PAGE_SPLIT_SIZE);
                const segmentText = segmentPages.join('');
                let segmentChunks = structureAwareChunk(segmentText);
                if (segmentChunks.length === 0) {
                    console.warn('⚠️ 구조 인식 청킹 실패, 기존 방식으로 폴백');
                    segmentChunks = splitIntoChunks(segmentText);
                }
                chunks = chunks.concat(segmentChunks);
                console.log(`  - 페이지 ${start + 1}~${Math.min(start + PAGE_SPLIT_SIZE, pageMarkers.length)}: ${segmentChunks.length}개 청크`);
            }
        } else {
            chunks = structureAwareChunk(mainText);
            if (chunks.length === 0) {
                console.warn('⚠️ 구조 인식 청킹 실패, 기존 방식으로 폴백');
                chunks = splitIntoChunks(mainText);
            }
        }

        // ── 별표 섹션 청킹 (더 작은 청크 크기로 splitIntoChunks 사용) ──
        const appendixChunks: { text: string; type: AppendixType }[] = [];
        for (const section of appendixSections) {
            // 보험료 표 전체인 별표는 제외
            if (isTableContent(section.text)) continue;
            const sectionChunks = splitIntoChunks(section.text, 500, 50);
            for (const chunkText of sectionChunks) {
                appendixChunks.push({ text: chunkText, type: section.type });
            }
        }
        console.log(`총 ${chunks.length}개 본문 청크 + ${appendixChunks.length}개 별표 청크 생성 (${pageCount}페이지)`);

        // ── Item 8: 재인덱싱 시 기존 chunks 처리 (Blue-Green 분기) ──
        const blueGreenMode = jobData.blueGreenMode === true;
        const targetCollection = blueGreenMode
            ? (jobData.targetCollection || 'insurance_chunks_staging')
            : 'insurance_chunks';

        // ── CL-9: 기존 insurance_appendices 정리 ──────────────
        const existingAppendicesQuery = await db.collection('insurance_appendices')
            .where('productId', '==', productId)
            .get();
        if (!existingAppendicesQuery.empty) {
            const appendixBatch = db.batch();
            existingAppendicesQuery.docs.forEach(doc => appendixBatch.delete(doc.ref));
            await appendixBatch.commit();
            console.log(`🗑️ 기존 insurance_appendices ${existingAppendicesQuery.size}개 정리 완료`);
        }

        if (blueGreenMode) {
            // Blue-Green 모드: staging 컬렉션의 잔여 데이터만 정리 (insurance_chunks는 건드리지 않음)
            console.log(`🔵 Blue-Green 모드: staging 컬렉션(${targetCollection})에 저장 예정`);
            const existingStagingQuery = await db.collection(targetCollection)
                .where('productId', '==', productId)
                .get();
            if (!existingStagingQuery.empty) {
                const CLEANUP_BATCH_LIMIT = 400;
                const stagingDocs = existingStagingQuery.docs;
                for (let i = 0; i < stagingDocs.length; i += CLEANUP_BATCH_LIMIT) {
                    const cleanupBatch = db.batch();
                    stagingDocs.slice(i, i + CLEANUP_BATCH_LIMIT).forEach(doc => cleanupBatch.delete(doc.ref));
                    await cleanupBatch.commit();
                }
                console.log(`🗑️ 기존 staging ${existingStagingQuery.size}개 청크 정리 완료`);
            }
        } else {
            // 기존 모드: insurance_chunks에서 기존 청크 전체 삭제
            console.log(`🗑️ 기존 chunks 삭제 중 (productId: ${productId})...`);
            const existingChunksQuery = await db.collection('insurance_chunks')
                .where('productId', '==', productId)
                .get();
            if (!existingChunksQuery.empty) {
                const CLEANUP_BATCH_LIMIT = 400;
                const existingDocs = existingChunksQuery.docs;
                for (let i = 0; i < existingDocs.length; i += CLEANUP_BATCH_LIMIT) {
                    const deleteBatch = db.batch();
                    existingDocs.slice(i, i + CLEANUP_BATCH_LIMIT).forEach(doc => deleteBatch.delete(doc.ref));
                    await deleteBatch.commit();
                }
                console.log(`🗑️ 기존 ${existingChunksQuery.size}개 청크 삭제 완료`);
            }
        }

        // 임베딩 배치 병렬 처리 (10개씩)
        const EMBEDDING_BATCH_SIZE = 10;
        let chunkBatch = db.batch();
        let batchCount = 0;
        let totalSaved = 0;

        // ── 별표 청크 먼저 저장 (본문 청크에서 ID 참조를 위해 선행 처리) ──
        console.log(`📎 별표 청크 ${appendixChunks.length}개 임베딩 + 저장 중...`);

        // appendixType → appendix chunkId 목록 맵 (본문 참조 연결용)
        const appendixIdsByType: Record<AppendixType, string[]> = {
            surgery_table: [],
            disability_table: [],
            disease_code: [],
            other: [],
        };

        for (let batchStart = 0; batchStart < appendixChunks.length; batchStart += EMBEDDING_BATCH_SIZE) {
            const batchEnd = Math.min(batchStart + EMBEDDING_BATCH_SIZE, appendixChunks.length);
            const batchItems = appendixChunks.slice(batchStart, batchEnd);

            const embeddingResults = await Promise.all(
                batchItems.map(item => embedWithRetry(embedModel, item.text))
            );

            for (let j = 0; j < batchItems.length; j++) {
                const i = batchStart + j;
                const { text: chunkText, type: appendixType } = batchItems[j];
                const embedding = embeddingResults[j].embedding.values;

                const pageMatch = chunkText.match(/\[PAGE (\d+)\]/);
                const pageNumber = pageMatch ? parseInt(pageMatch[1]) : 0;

                const chunkId = `${companyId}_${productId}_appendix_${i}`;
                const chunkRef = db.collection(targetCollection).doc(chunkId);
                chunkBatch.set(chunkRef, {
                    companyId,
                    companyName,
                    productId,
                    productName,
                    pageNumber,
                    chunkText,
                    embedding,
                    coverageNames: [],
                    sourceType: 'appendix',
                    appendixType,
                    effectiveDate: effectiveDate || '',
                    driveFileId,
                    createdAt: admin.firestore.FieldValue.serverTimestamp(),
                });

                appendixIdsByType[appendixType].push(chunkId);
                batchCount++;

                if (batchCount === 400) {
                    await chunkBatch.commit();
                    totalSaved += batchCount;
                    console.log(`💾 ${totalSaved}개 청크 저장됨 (별표 포함)`);
                    chunkBatch = db.batch();
                    batchCount = 0;
                }
            }
        }

        // ── CL-9: insurance_appendices 컬렉션에 별표 데이터 저장 ──────────
        console.log(`📋 별표 ${appendixSections.length}개 → insurance_appendices 저장 중...`);
        for (let idx = 0; idx < appendixSections.length; idx++) {
            const section = appendixSections[idx];
            const parsed = parseAppendixToMarkdownTable(section.text);
            const appendixDocId = `${productId}_appendix_${idx}`;
            const appendixRef = db.collection('insurance_appendices').doc(appendixDocId);

            // 해당 타입의 청크 ID 목록
            const sourceChunkIds = appendixIdsByType[section.type] || [];

            await appendixRef.set({
                productId,
                appendixType: section.type,
                content: section.text,
                parsedTable: parsed?.parsedTable || null,
                markdownTable: parsed?.markdown || null,
                sourceChunkIds,
                createdAt: admin.firestore.FieldValue.serverTimestamp(),
            });
        }
        if (appendixSections.length > 0) {
            console.log(`✅ insurance_appendices ${appendixSections.length}개 저장 완료`);
        }

        // ── 본문 청크 저장 (별표 참조 연결 포함) ─────────────────
        console.log(`📄 본문 청크 ${chunks.length}개 임베딩 + 저장 중...`);

        for (let batchStart = 0; batchStart < chunks.length; batchStart += EMBEDDING_BATCH_SIZE) {
            const batchEnd = Math.min(batchStart + EMBEDDING_BATCH_SIZE, chunks.length);
            const batchChunks = chunks.slice(batchStart, batchEnd);

            // 병렬 임베딩 생성 (개별 retry 포함)
            const embeddingResults = await Promise.all(
                batchChunks.map(chunk => embedWithRetry(embedModel, chunk))
            );

            // Firestore 배치에 추가
            for (let j = 0; j < batchChunks.length; j++) {
                const i = batchStart + j;
                const chunk = batchChunks[j];
                const embedding = embeddingResults[j].embedding.values;

                const pageMatch = chunk.match(/\[PAGE (\d+)\]/);
                const pageNumber = pageMatch ? parseInt(pageMatch[1]) : 0;

                // 본문 청크에서 별표 참조 감지 → relatedAppendixIds 구성
                const relatedAppendixIds: string[] = [];
                const appendixRefs = chunk.match(APPENDIX_REFERENCE_PATTERN);
                if (appendixRefs && appendixRefs.length > 0) {
                    for (const ref of appendixRefs) {
                        if (/수술분류/.test(ref)) {
                            relatedAppendixIds.push(...appendixIdsByType['surgery_table']);
                        } else if (/장해분류/.test(ref)) {
                            relatedAppendixIds.push(...appendixIdsByType['disability_table']);
                        } else if (/질병분류/.test(ref)) {
                            relatedAppendixIds.push(...appendixIdsByType['disease_code']);
                        } else {
                            // 일반 참조: 모든 별표 ID 연결
                            for (const ids of Object.values(appendixIdsByType)) {
                                relatedAppendixIds.push(...ids);
                            }
                        }
                    }
                }
                // 중복 제거
                const uniqueRelatedIds = [...new Set(relatedAppendixIds)];

                const chunkId = `${companyId}_${productId}_chunk_${i}`;
                const chunkRef = db.collection(targetCollection).doc(chunkId);
                const chunkDoc: Record<string, any> = {
                    companyId,
                    companyName,
                    productId,
                    productName,
                    pageNumber,
                    chunkText: chunk,
                    embedding,
                    coverageNames: [], // TODO: 용어 추출 결과로 자동 태깅
                    sourceType: 'policy',
                    effectiveDate: effectiveDate || '',
                    driveFileId,
                    createdAt: admin.firestore.FieldValue.serverTimestamp(),
                };
                if (uniqueRelatedIds.length > 0) {
                    chunkDoc['relatedAppendixIds'] = uniqueRelatedIds;
                }
                chunkBatch.set(chunkRef, chunkDoc);
                batchCount++;

                // Firestore 500개 배치 제한
                if (batchCount === 400) {
                    await chunkBatch.commit();
                    totalSaved += batchCount;
                    console.log(`💾 ${totalSaved}개 청크 저장됨`);
                    chunkBatch = db.batch();
                    batchCount = 0;
                }
            }
        }
        if (batchCount > 0) {
            await chunkBatch.commit();
            totalSaved += batchCount;
        }
        const totalChunksCount = chunks.length + appendixChunks.length;
        console.log(`✅ 전체 ${totalChunksCount}개 청크 저장 완료 (본문: ${chunks.length}, 별표: ${appendixChunks.length})`);

        // ── 5. insurance_metadata upsert (버전별 별도 문서 + 이전 버전 종료 처리) ──
        const metaDocId = `${productId}_${effectiveDate}`;
        const metaCollection = db.collection('insurance_metadata');

        await db.runTransaction(async (tx) => {
            // 동일 productId의 기존 현행(end 없는) 문서 조회
            const existingQuery = await tx.get(
                metaCollection
                    .where('productId', '==', productId)
                    .where('isActive', '==', true)
            );

            for (const doc of existingQuery.docs) {
                const data = doc.data();
                const existingStart: string | undefined = data.effectiveDateRange?.start;

                // end가 없는 현행 문서만 처리
                if (data.effectiveDateRange?.end) continue;

                // 동일 effectiveDate면 end date 설정 없이 그냥 업데이트 (후속 set에서 처리)
                if (existingStart === effectiveDate) continue;

                // 이전 버전의 종료 시점을 새 약관의 effectiveDate로 설정
                tx.update(doc.ref, {
                    'effectiveDateRange.end': effectiveDate,
                    updatedAt: admin.firestore.FieldValue.serverTimestamp(),
                });
            }

            // 새(또는 동일) 버전 문서 생성/업데이트
            const newMetaRef = metaCollection.doc(metaDocId);
            tx.set(newMetaRef, {
                companyId,
                companyName,
                productId,
                productName,
                category,
                driveFileId,
                effectiveDateRange: { start: effectiveDate },
                generationType: 'gen1',
                isSalesStopped: false,
                isActive: true,
                createdAt: admin.firestore.FieldValue.serverTimestamp(),
                updatedAt: admin.firestore.FieldValue.serverTimestamp(),
            }, { merge: true });
        });

        // ── 6. summary_jobs 생성 (Phase 2: 요약 파이프라인 트리거용) ──
        const summaryJobId = productId;
        await db.collection('summary_jobs').doc(summaryJobId).set({
            productId,
            companyId,
            companyName,
            productName,
            driveFileId,
            chunksCount: totalChunksCount,
            status: 'pending',
            createdAt: admin.firestore.FieldValue.serverTimestamp(),
            updatedAt: admin.firestore.FieldValue.serverTimestamp(),
        }, { merge: false }); // 재인덱싱 시 덮어쓰기
        console.log(`📋 summary_job 생성: ${summaryJobId} (status: pending)`);

        // ── 7. Job 완료 ───────────────────────────────────────────
        await jobRef.update({
            status: 'complete',
            chunksCount: totalChunksCount,
            updatedAt: admin.firestore.FieldValue.serverTimestamp(),
        });

        console.log(`🎉 Job ${jobId} 완료! 청크: ${totalChunksCount}개 (본문: ${chunks.length}, 별표: ${appendixChunks.length})`);

    } catch (error: any) {
        console.error(`❌ Job ${jobId} 실패:`, error);
        await jobRef.update({
            status: 'failed',
            error: error.message,
            updatedAt: admin.firestore.FieldValue.serverTimestamp(),
        });
    } finally {
        // ── 임시 파일 정리 (에러 여부와 무관하게 항상 실행) ──────
        if (tempPath && fs.existsSync(tempPath)) {
            fs.unlinkSync(tempPath);
            console.log(`🧹 임시 파일 정리 완료: ${tempPath}`);
        }
    }
});
