/**
 * Task I: YouTube 채널 자동 수집·요약·Drive 업로드 파이프라인
 * Spec: docs/specs/260225-insuwiki-rag-spec-v2.md § 12
 *
 * 채널:
 *   - 보험명의정닥터 (@보험명의정닥터) — videos + shorts
 *   - ins-king      (@ins-king)       — videos + shorts
 *
 * 실행: Cloud Scheduler → 6시간마다 (하루 4회)
 * 모델: gemini-2.5-flash (1시간 30분 영상 자막도 처리)
 *
 * 흐름:
 * 1. youtube_channels에서 활성 채널 로드
 * 2. YouTube Data API → 신규 영상 목록 (videos + shorts)
 * 3. 영상별: 자막 추출 → Gemini 2.5 Flash 요약
 * 4. Google Drive 04_유튜브요약/{채널명}/{날짜}_{제목}.md 업로드
 * 5. Gemini Embedding → insurance_chunks 저장 (sourceType: 'youtube')
 * 6. 약관 청크와 유사도 비교 → conflictsWithPolicy 감지
 * 7. youtube_knowledge 저장
 * 8. youtube_channels.lastCrawledAt 갱신
 */

import * as admin from 'firebase-admin';
import { onSchedule } from 'firebase-functions/v2/scheduler';
import { GoogleGenerativeAI } from '@google/generative-ai';
import { google } from 'googleapis';
import * as https from 'https';
import { whisperTranscribe } from './whisperStt';

// ── 처리 로그 인터페이스 ──────────────────────────────────────────────────
interface VideoProcessingLog {
    index: number;
    videoId: string;
    title: string;
    processingMethod: 'youtube_caption' | 'whisper_stt' | 'title_description';
    transcriptLength: number;
    summaryLength: number;
    status: 'completed' | 'quality_warning' | 'failed';
    trackAFailReason?: string;
    audioSizeMb?: number;
    whisperModel?: string;
    driveUrl?: string;
    processingTimeMs: number;
    captionLanguage?: string;
    captionType?: string;  // 'manual' | 'auto-generated'
}

// ── 요약 프롬프트 (보험 도메인 특화) ────────────────────────────────────
const YOUTUBE_SUMMARY_PROMPT = `
당신은 보험 콘텐츠 전문 분석가입니다.
아래 보험 유튜브 영상 자막을 꼼꼼히 분석하고 **설계사가 바로 업무에 활용할 수 있도록** 충분히 상세하게 요약해 주세요.

[분석 항목]

## 1. 핵심 요약
영상의 핵심 주제와 결론을 **충분히 담아서** 요약합니다.
짧은 영상이면 간결하게, 긴 영상이면 중요 내용이 빠지지 않도록 길게 작성해도 됩니다.

## 2. 설계사 고객 상담 활용 포인트
고객 상담 시 실제로 쓸 수 있는 포인트를 **내용이 있는 만큼** 추출합니다.
개수 제한 없음 — 중요한 내용이라면 10개, 20개도 괜찮습니다.

## 3. 언급된 보험 상품 / 보험사
영상에서 언급된 특정 상품명, 보험사명, 특약명을 모두 나열합니다. (없으면 "없음")

## 4. 수치 / 금액 정보
보험료, 보험금, 비율 등 수치가 언급된 경우 **자막 원문 그대로** 정리합니다. (없으면 "없음")

## 5. 약관과 다를 수 있는 내용 ⚠️
영상 내용이 일반적인 약관 규정과 다르거나 오해를 살 수 있는 부분을 명시합니다.
(없으면 "없음" — 억지로 찾지 마세요)

## 6. 주제별 섹션 흐름 요약
영상이 어떤 순서로 진행됐는지, 주요 섹션별로 요약합니다.
긴 영상일수록 섹션을 더 세분화해서 작성합니다.

⚠️ 반드시 지켜야 할 규칙:
- 자막에 없는 내용은 절대 추가하지 않습니다
- 보험료·금액 수치는 자막 원문 그대로만 인용합니다
- 불확실한 표현("아마도", "추정" 등) 사용 금지
- 마지막 줄에 반드시 포함: "※ 이 요약은 자동 생성이며 약관 원문을 우선합니다"
`;

const YOUTUBE_L2_SUMMARY_PROMPT = `
당신은 보험 콘텐츠 구조화 분석가입니다.
아래 보험 유튜브 영상의 6섹션 요약을 기반으로 4가지 항목의 구조화 요약을 생성하세요.

[구조화 항목]

## 핵심 주장
이 영상의 핵심 메시지와 주장을 3~5개 불릿 포인트로 정리합니다.

## 관련 약관 조항
영상에서 직접 언급되거나 밀접하게 관련된 약관 조항/보장 항목을 나열합니다.
언급되지 않았으면 "직접 언급 없음"으로 표기합니다.

## 실무 시사점
설계사가 고객 상담이나 업무에 활용할 수 있는 구체적 시사점을 정리합니다.

## 주의사항
영상 내용 중 약관과 다를 수 있거나, 주의가 필요한 부분을 명시합니다.
없으면 "특별한 주의사항 없음"으로 표기합니다.

⚠️ 규칙:
- 원본 요약에 없는 내용 절대 추가 금지
- "아마도", "추정" 등 불확실 표현 금지
- JSON이 아닌 마크다운 형식으로 작성
`;

// ── Gemini API 지수 백오프 래퍼 ──────────────────────────────────────────
async function withBackoff<T>(
    fn: () => Promise<T>,
    maxRetries = 4,
    baseDelayMs = 5000
): Promise<T> {
    let lastErr: unknown;
    for (let attempt = 0; attempt <= maxRetries; attempt++) {
        try {
            return await fn();
        } catch (err: any) {
            lastErr = err;
            if (attempt === maxRetries) break;
            const delay = baseDelayMs * Math.pow(2, attempt) + Math.random() * 1000;
            console.warn(`  ⚠️ Gemini 오류 (시도 ${attempt + 1}/${maxRetries}), ${Math.round(delay / 1000)}초 후 재시도...`, err?.message);
            await new Promise(r => setTimeout(r, delay));
        }
    }
    throw lastErr;
}

// ── Google Drive 마크다운 업로드 유틸 ────────────────────────────────────
async function uploadMarkdownToDrive(
    drive: ReturnType<typeof google.drive>,
    content: string,
    channelName: string,
    videoTitle: string,
    videoDate: string,
    rootFolderId: string,
    suffix: string = ''  // '_요약' 또는 '_전문'
): Promise<string> {
    // 04_유튜브요약 폴더 확인/생성
    const youtubeFolder = await findOrCreateFolder(drive, rootFolderId, '04_유튜브요약');
    const channelFolder = await findOrCreateFolder(drive, youtubeFolder, channelName);

    const safeTitle = videoTitle.replace(/[/\\:*?"<>|]/g, '_').slice(0, 50);
    const fileName = `${videoDate}_${safeTitle}${suffix}.md`;

    // ── 중복 파일 방지: 동일 파일명 존재 시 기존 파일 URL 반환 ──────────
    const existingFile = await drive.files.list({
        q: `'${channelFolder}' in parents and name='${fileName}' and trashed=false`,
        fields: 'files(id, webViewLink)',
        pageSize: 1,
    });
    if (existingFile.data.files && existingFile.data.files.length > 0) {
        console.log(`  ℹ️ Drive 중복 파일 스킵: ${fileName}`);
        return existingFile.data.files[0].webViewLink || existingFile.data.files[0].id || '';
    }

    const res = await drive.files.create({
        requestBody: {
            name: fileName,
            mimeType: 'text/plain',
            parents: [channelFolder],
        },
        media: {
            mimeType: 'text/plain',
            body: content,
        },
        fields: 'id, webViewLink',
    });

    return res.data.webViewLink || res.data.id || '';
}

async function findOrCreateFolder(
    drive: ReturnType<typeof google.drive>,
    parentId: string,
    folderName: string
): Promise<string> {
    const res = await drive.files.list({
        q: `'${parentId}' in parents and name='${folderName}' and mimeType='application/vnd.google-apps.folder' and trashed=false`,
        fields: 'files(id)',
        pageSize: 1,
    });
    if (res.data.files && res.data.files.length > 0) {
        return res.data.files[0].id!;
    }
    const created = await drive.files.create({
        requestBody: { name: folderName, mimeType: 'application/vnd.google-apps.folder', parents: [parentId] },
        fields: 'id',
    });
    return created.data.id!;
}

// ── YouTube 자막 추출 ─────────────────────────────────────────────────────
// YouTube가 timedtext API에 서명 파라미터(signature, ei, expire 등)를 요구하도록 변경됨.
// 따라서 먼저 영상 페이지 HTML을 파싱하여 서명된 자막 URL을 추출한 뒤 사용.
export async function fetchPageHtml(url: string): Promise<string> {
    return new Promise((resolve, reject) => {
        const req = https.get(url, {
            headers: {
                'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36',
                'Accept-Language': 'ko-KR,ko;q=0.9,en;q=0.8',
            },
        }, (res) => {
            let data = '';
            res.on('data', (chunk) => data += chunk);
            res.on('end', () => resolve(data));
        });
        req.on('error', reject);
        req.setTimeout(20000, () => { req.destroy(); reject(new Error('timeout')); });
    });
}

export async function fetchUrlContent(url: string): Promise<string> {
    return new Promise((resolve, reject) => {
        const req = https.get(url, {
            headers: { 'User-Agent': 'Mozilla/5.0' },
        }, (res) => {
            // 리다이렉트 처리
            if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
                fetchUrlContent(res.headers.location).then(resolve).catch(reject);
                return;
            }
            let data = '';
            res.on('data', (chunk) => data += chunk);
            res.on('end', () => resolve(data));
        });
        req.on('error', reject);
        req.setTimeout(15000, () => { req.destroy(); reject(new Error('timeout')); });
    });
}

// InnerTube API POST 요청 유틸
async function postJson(url: string, body: object, headers: Record<string, string> = {}): Promise<any> {
    return new Promise((resolve, reject) => {
        const bodyStr = JSON.stringify(body);
        const parsedUrl = new URL(url);
        const req = https.request({
            hostname: parsedUrl.hostname,
            path: parsedUrl.pathname + parsedUrl.search,
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
                'Content-Length': Buffer.byteLength(bodyStr),
                ...headers,
            },
        }, (res) => {
            let data = '';
            res.on('data', (chunk) => data += chunk);
            res.on('end', () => {
                try { resolve(JSON.parse(data)); }
                catch { reject(new Error(`JSON parse failed: ${data.slice(0, 200)}`)); }
            });
        });
        req.on('error', reject);
        req.setTimeout(20000, () => { req.destroy(); reject(new Error('timeout')); });
        req.write(bodyStr);
        req.end();
    });
}

// WEB 브라우저 UA를 포함한 URL 콘텐츠 fetch (자막 요청용)
async function fetchUrlContentWithUA(url: string): Promise<string> {
    return new Promise((resolve, reject) => {
        const req = https.get(url, {
            headers: {
                'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
                'Accept-Language': 'ko-KR,ko;q=0.9',
            },
        }, (res) => {
            if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
                fetchUrlContentWithUA(res.headers.location).then(resolve).catch(reject);
                return;
            }
            let data = '';
            res.on('data', (chunk) => data += chunk);
            res.on('end', () => resolve(data));
        });
        req.on('error', reject);
        req.setTimeout(15000, () => { req.destroy(); reject(new Error('timeout')); });
    });
}

interface TranscriptResult {
    text: string;
    captionLanguage: string;
    captionType: 'manual' | 'auto-generated';
}

async function fetchYouTubeTranscript(videoId: string): Promise<TranscriptResult | null> {
    try {
        // Step 1: InnerTube API로 player 정보 요청 (WEB → TVHTML5 다단계 fallback)
        let playerResponse: any;

        // 방법 1: WEB 클라이언트 시도
        try {
            playerResponse = await withBackoff(
                () => postJson(
                    'https://www.youtube.com/youtubei/v1/player?key=AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8',
                    {
                        videoId,
                        context: {
                            client: {
                                clientName: 'WEB',
                                clientVersion: '2.20240313.00.00',
                                hl: 'ko',
                                gl: 'KR',
                            },
                        },
                    },
                    { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' }
                ),
                2, 3000
            );
            console.log(`  🌐 ${videoId}: WEB 클라이언트 응답 수신`);
        } catch (apiErr: any) {
            console.warn(`  ⚠️ ${videoId}: WEB 클라이언트 실패:`, apiErr.message);
            playerResponse = null;
        }

        // 방법 2: WEB에서 captionTracks 없으면 TVHTML5_SIMPLY_EMBEDDED_PLAYER 시도
        let captionTracks = playerResponse?.captions?.playerCaptionsTracklistRenderer?.captionTracks;
        if (!captionTracks || !Array.isArray(captionTracks) || captionTracks.length === 0) {
            console.log(`  ℹ️ ${videoId}: WEB captionTracks 없음 — TVHTML5 클라이언트 재시도`);
            try {
                playerResponse = await withBackoff(
                    () => postJson(
                        'https://www.youtube.com/youtubei/v1/player?key=AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8',
                        {
                            videoId,
                            context: {
                                client: {
                                    clientName: 'TVHTML5_SIMPLY_EMBEDDED_PLAYER',
                                    clientVersion: '2.0',
                                    hl: 'ko',
                                    gl: 'KR',
                                },
                                thirdParty: {
                                    embedUrl: 'https://www.google.com',
                                },
                            },
                        },
                        { 'User-Agent': 'Mozilla/5.0' }
                    ),
                    2, 3000
                );
                console.log(`  📺 ${videoId}: TVHTML5 클라이언트 응답 수신`);
                captionTracks = playerResponse?.captions?.playerCaptionsTracklistRenderer?.captionTracks;
            } catch (tvErr: any) {
                console.warn(`  ⚠️ ${videoId}: TVHTML5 클라이언트 실패:`, tvErr.message);
            }
        }

        // Step 2: captionTracks 최종 확인
        if (!captionTracks || !Array.isArray(captionTracks) || captionTracks.length === 0) {
            console.log(`  ℹ️ ${videoId}: captionTracks 없음 (자막 미제공 영상 — WEB/TVHTML5 모두 실패)`);
            return null;
        }

        // Step 3: 한국어 자막 우선 선택
        // vssId가 'a.'로 시작하면 자동 생성 자막
        const manualKoTrack =
            captionTracks.find((t: any) => t.languageCode === 'ko' && !t.vssId?.startsWith('a.')) ||
            captionTracks.find((t: any) => t.languageCode === 'ko-KR' && !t.vssId?.startsWith('a.'));
        const autoKoTrack =
            captionTracks.find((t: any) => t.languageCode === 'ko') ||
            captionTracks.find((t: any) => t.languageCode === 'ko-KR');
        const koTrack = manualKoTrack || autoKoTrack || null;
        const captionType: 'manual' | 'auto-generated' = manualKoTrack ? 'manual' : 'auto-generated';

        if (!koTrack) {
            console.log(`  ℹ️ ${videoId}: 한국어 자막 없음 (사용 가능: ${captionTracks.map((t: any) => `${t.languageCode}(${t.vssId || 'N/A'})`).join(', ')})`);
            return null;
        }
        const captionLanguage: string = koTrack.languageCode || 'ko';
        console.log(`  📝 자막 선택: ${captionLanguage} (${koTrack.vssId || 'N/A'}, ${captionType})`);

        // Step 4: 서명된 자막 URL로 json3 형식 요청 (웹 UA 사용)
        const transcriptUrl = `${koTrack.baseUrl}&fmt=json3`;
        let rawData: string | null = null;
        for (let attempt = 0; attempt < 3; attempt++) {
            try {
                rawData = await fetchUrlContentWithUA(transcriptUrl);
                if (rawData && rawData.trim().length > 0) break;
            } catch (retryErr: any) {
                console.warn(`  ⚠️ ${videoId}: 자막 요청 재시도 ${attempt + 1}/3: ${retryErr.message}`);
            }
            if (attempt < 2) await new Promise(r => setTimeout(r, 3000));
        }

        if (!rawData || rawData.trim().length === 0) {
            console.log(`  ⚠️ ${videoId}: 자막 데이터 비어 있음 (3회 시도 실패)`);
            return null;
        }

        // Step 5: 응답 파싱 (JSON3 또는 XML 형식 모두 처리)
        let text: string | null = null;

        // JSON3 파싱 시도
        try {
            const json = JSON.parse(rawData);
            const events = json.events || [];
            text = events
                .filter((e: any) => e.segs)
                .map((e: any) => e.segs.map((s: any) => s.utf8 ?? '').join(''))
                .join(' ')
                .replace(/\n/g, ' ')
                .replace(/\s{2,}/g, ' ')
                .trim();
        } catch {
            // JSON 파싱 실패 → XML 형식 시도
            // 1차: <text> 태그 (구형 timedtext)
            const textSegments = rawData.match(/<text[^>]*>([\s\S]*?)<\/text>/g);
            if (textSegments && textSegments.length > 0) {
                text = textSegments
                    .map(seg => seg.replace(/<[^>]+>/g, '').replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&#39;/g, "'").replace(/&quot;/g, '"').trim())
                    .filter(Boolean)
                    .join(' ')
                    .replace(/\s{2,}/g, ' ')
                    .trim();
            } else {
                // 2차: <s> 태그 (timedtext format=3, YouTube 자동 생성 자막)
                const sPattern = /<s[^>]*>([^<]+)<\/s>/g;
                let match;
                const words: string[] = [];
                while ((match = sPattern.exec(rawData)) !== null) {
                    const word = match[1]
                        .replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>')
                        .replace(/&#39;/g, "'").replace(/&quot;/g, '"').trim();
                    if (word) words.push(word);
                }
                if (words.length > 0) {
                    text = words.join(' ').replace(/\s{2,}/g, ' ').trim();
                }
            }
        }

        if (!text) return null;
        return { text, captionLanguage, captionType };
    } catch (err: any) {
        console.error(`  ❌ ${videoId} 자막 추출 오류:`, err.message);
        return null;
    }
}

// ── 자막 텍스트 청크 분할 (L3용) ─────────────────────────────────────────
// chunkSize: 청크당 최대 글자 수, overlap: 앞 청크와 겹치는 글자 수
function chunkTranscript(
    text: string,
    chunkSize = 500,
    overlap = 50
): string[] {
    const chunks: string[] = [];
    let start = 0;
    while (start < text.length) {
        const end = Math.min(start + chunkSize, text.length);
        chunks.push(text.slice(start, end));
        if (end === text.length) break;
        start += chunkSize - overlap;
    }
    return chunks;
}

// ── 마크다운 파일 콘텐츠 생성 ─────────────────────────────────────────────
function buildMarkdownContent(
    summary: string,
    videoTitle: string,
    channelName: string,
    videoId: string,
    publishedAt: string,
    duration: string,
    hasTranscript: boolean,
    transcriptionSource?: 'youtube_caption' | 'whisper_stt' | 'title_description',
    transcriptLength?: number,
    captionLanguage?: string,
    captionType?: string
): string {
    let transcriptionLine: string;
    if (transcriptionSource === 'youtube_caption') {
        const langLabel = captionLanguage || 'ko';
        const typeLabel = captionType || '자동생성';
        transcriptionLine = `✅ YouTube 자막 (${langLabel}, ${typeLabel})`;
    } else if (transcriptionSource === 'whisper_stt') {
        const lenLabel = transcriptLength ? `${transcriptLength.toLocaleString()}자` : '';
        transcriptionLine = `🎤 Whisper STT (GPU${lenLabel ? `, ${lenLabel}` : ''})`;
    } else {
        transcriptionLine = '⚠️ 제목+설명만';
    }

    const transcriptLengthLine = transcriptLength
        ? `- **전사 텍스트 길이**: ${transcriptLength.toLocaleString()}자`
        : '';

    return `# ${videoTitle}

- **채널**: ${channelName}
- **업로드일**: ${publishedAt.slice(0, 10)}
- **영상 길이**: ${duration}
- **원본 URL**: https://www.youtube.com/watch?v=${videoId}
- **처리일**: ${new Date().toISOString().slice(0, 19)}+09:00
- **전사 방식**: ${transcriptionLine}
${transcriptLengthLine}

---

${summary}

---
*자동 생성 | Gemini 2.5 Flash | 원본 내용과 다를 수 있으니 약관 원문을 우선하세요*
`;
}

// ── 전문 마크다운 파일 콘텐츠 생성 ───────────────────────────────────────
function buildTranscriptContent(
    transcript: string,
    videoTitle: string,
    channelName: string,
    videoId: string,
    publishedAt: string,
    duration: string,
    transcriptionSource: string
): string {
    const sourceLabel = transcriptionSource === 'whisper_stt' ? 'Whisper STT 전사' : '유튜브 자막 추출';
    return `# ${videoTitle} — 전문 (Full Transcript)

- **채널**: ${channelName}
- **업로드일**: ${publishedAt.slice(0, 10)}
- **영상 길이**: ${duration}
- **원본 URL**: https://www.youtube.com/watch?v=${videoId}
- **전사 방식**: ${sourceLabel}
- **처리일**: ${new Date().toISOString().slice(0, 19)}+09:00

---

${transcript}

---
*자동 전사 | ${sourceLabel} | 원본 음성과 다를 수 있습니다*
`;
}

// ── 처리 로그 마크다운 생성 ────────────────────────────────────────────────
function buildProcessingLogMarkdown(
    logs: VideoProcessingLog[],
    channelNames: string[],
    startTime: Date
): string {
    const endTime = new Date();
    const totalElapsedSec = Math.round((endTime.getTime() - startTime.getTime()) / 1000);
    const dateStr = startTime.toISOString().slice(0, 10);
    const timeStr = startTime.toISOString().slice(11, 19);

    const methodLabel = (m: VideoProcessingLog['processingMethod']) => {
        if (m === 'youtube_caption') return 'YouTube 자막';
        if (m === 'whisper_stt') return 'Whisper STT';
        return '제목+설명';
    };

    const statusLabel = (s: VideoProcessingLog['status']) => {
        if (s === 'completed') return '✅ 완료';
        if (s === 'quality_warning') return '⚠️ 품질경고';
        return '❌ 실패';
    };

    const rows = logs.map(log => {
        const elapsedSec = Math.round(log.processingTimeMs / 1000);
        const driveLink = log.driveUrl ? `[링크](${log.driveUrl})` : '-';
        return `| ${log.index} | ${log.videoId} | ${log.title.slice(0, 30)} | ${methodLabel(log.processingMethod)} | ${log.captionLanguage || '-'} | ${log.captionType || '-'} | ${log.transcriptLength.toLocaleString()} | ${log.summaryLength.toLocaleString()} | ${statusLabel(log.status)} | ${elapsedSec}s | ${driveLink} |`;
    }).join('\n');

    const completedCount = logs.filter(l => l.status === 'completed').length;
    const warningCount = logs.filter(l => l.status === 'quality_warning').length;
    const failedCount = logs.filter(l => l.status === 'failed').length;
    const ytCaptionCount = logs.filter(l => l.processingMethod === 'youtube_caption').length;
    const whisperCount = logs.filter(l => l.processingMethod === 'whisper_stt').length;
    const titleDescCount = logs.filter(l => l.processingMethod === 'title_description').length;

    return `# 유튜브 크롤링 처리 로그

- **처리일**: ${dateStr}
- **시작 시각**: ${timeStr} (UTC)
- **총 소요 시간**: ${totalElapsedSec}초
- **처리 채널**: ${channelNames.join(', ')}

## 처리 요약

| 항목 | 수치 |
|------|------|
| 총 처리 영상 | ${logs.length}개 |
| ✅ 완료 | ${completedCount}개 |
| ⚠️ 품질경고 | ${warningCount}개 |
| ❌ 실패 | ${failedCount}개 |
| YouTube 자막 | ${ytCaptionCount}개 |
| Whisper STT | ${whisperCount}개 |
| 제목+설명 | ${titleDescCount}개 |

## 영상별 처리 결과

| # | videoId | 제목 | 전사방식 | 언어 | 자막유형 | 전사길이 | 요약길이 | 상태 | 소요 | Drive |
|---|---------|------|----------|------|----------|----------|----------|------|------|-------|
${rows}

---
*자동 생성 | crawlYoutubeChannels | ${dateStr}*
`;
}

// ── ISO 8601 duration → 사람이 읽기 좋은 형식 ─────────────────────────────
function parseDuration(iso: string): string {
    const match = iso.match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/);
    if (!match) return iso;
    const h = match[1] ? `${match[1]}시간 ` : '';
    const m = match[2] ? `${match[2]}분 ` : '';
    const s = match[3] ? `${match[3]}초` : '';
    return (h + m + s).trim() || '0초';
}

// ── 메인 Cloud Function ──────────────────────────────────────────────────
export const crawlYoutubeChannels = onSchedule({
    schedule: 'every 6 hours',
    timeZone: 'Asia/Seoul',
    timeoutSeconds: 540,
    memory: '1GiB',
    region: 'asia-northeast3',
    secrets: [
        'GEMINI_API_KEY',
        'YOUTUBE_API_KEY',
        'DRIVE_CLIENT_ID',
        'DRIVE_CLIENT_SECRET',
        'DRIVE_REFRESH_TOKEN',
        'OPENAI_API_KEY',
    ],
}, async () => {
    const db = admin.firestore();

    // ── 파이프라인 실행 로그 ──
    const runId = `run_${Date.now()}`;
    const pipelineLogRef = db.collection('pipeline_logs').doc(runId);
    const runStats = {
        totalChannels: 0,
        totalVideos: 0,
        processed: 0,
        skipped: 0,
        failed: 0,
        errors: [] as string[],
    };
    const pendingVideoIds: string[] = []; // SKIP_GEMINI_SUMMARY 모드에서 요약 대기 영상 추적
    const processingLogs: VideoProcessingLog[] = []; // 처리 로그 수집
    const pipelineStartTime = new Date(); // 파이프라인 시작 시각
    const processedChannelNames: string[] = []; // 처리된 채널명 수집
    let videoIndexCounter = 0; // 전체 영상 인덱스 카운터
    await pipelineLogRef.set({
        runId,
        functionName: 'crawlYoutubeChannels',
        status: 'running',
        startedAt: admin.firestore.FieldValue.serverTimestamp(),
        stats: runStats,
    });

    // 환경변수 로드 (fallback 포함)
    const apiKey = process.env.GEMINI_API_KEY;
    const youtubeApiKey = process.env.YOUTUBE_API_KEY;
    const rootFolderId = process.env.GOOGLE_DRIVE_ROOT_FOLDER_ID;
    const driveClientId = process.env.DRIVE_CLIENT_ID;
    const driveClientSecret = process.env.DRIVE_CLIENT_SECRET;
    const driveRefreshToken = process.env.DRIVE_REFRESH_TOKEN;

    if (!apiKey || !youtubeApiKey || !rootFolderId) {
        console.error('❌ 필수 환경변수 없음: GEMINI_API_KEY / YOUTUBE_API_KEY / GOOGLE_DRIVE_ROOT_FOLDER_ID');
        return;
    }

    // ── SKIP_GEMINI_SUMMARY: Gemini 요약 비활성화 플래그 ──
    // 'true'이면 Gemini 요약/L2 생성을 스킵하고 전문만 저장 (아누가 별도 요약 위임)
    const skipGeminiSummary = process.env.SKIP_GEMINI_SUMMARY === 'true';
    if (skipGeminiSummary) {
        console.log('🔧 SKIP_GEMINI_SUMMARY=true — Gemini 요약 비활성화, 전문만 저장 모드');
    }

    // Drive OAuth 환경변수 검증 (선택적 — Drive 업로드 실패 시에도 파이프라인 계속)
    const hasDriveCredentials = !!(driveClientId && driveClientSecret && driveRefreshToken);
    if (!hasDriveCredentials) {
        console.warn('⚠️ Drive OAuth 환경변수 없음 — Drive 업로드 스킵');
    }

    const genAI = new GoogleGenerativeAI(apiKey);
    const model = genAI.getGenerativeModel({ model: 'gemini-2.5-flash' }); // 1.5시간 영상 처리
    const embedModel = genAI.getGenerativeModel({ model: 'gemini-embedding-001' });

    const youtube = google.youtube({ version: 'v3', auth: youtubeApiKey });

    // Drive OAuth2 클라이언트 (선택적)
    let drive: ReturnType<typeof google.drive> | null = null;
    if (hasDriveCredentials) {
        const driveOAuth2 = new google.auth.OAuth2(
            driveClientId,
            driveClientSecret,
        );
        driveOAuth2.setCredentials({ refresh_token: driveRefreshToken });
        drive = google.drive({ version: 'v3', auth: driveOAuth2 });
    }

    // ── 1. 활성 채널 목록 로드 ────────────────────────────────────────
    const channelsSnap = await db.collection('youtube_channels')
        .where('isActive', '==', true)
        .get();

    if (channelsSnap.empty) {
        console.log('등록된 활성 채널 없음 — Firestore youtube_channels 컬렉션을 확인하세요');
        return;
    }

    runStats.totalChannels = channelsSnap.size;

    for (const channelDoc of channelsSnap.docs) {
        const channel = channelDoc.data();
        const { channelId, channelName, lastCrawledAt } = channel;

        console.log(`\n📺 채널 처리 시작: ${channelName} (${channelId})`);
        processedChannelNames.push(channelName);

        try {
            // ── 2. 채널 업로드 플레이리스트 ID 조회 ───────────────────
            const channelRes = await youtube.channels.list({
                part: ['contentDetails'],
                id: [channelId],
                maxResults: 1,
            });

            const uploadsPlaylistId =
                channelRes.data.items?.[0]?.contentDetails?.relatedPlaylists?.uploads;
            if (!uploadsPlaylistId) {
                console.warn(`⚠️ ${channelName}: 업로드 플레이리스트 없음`);
                continue;
            }

            // ── 3. 최신 영상 목록 조회 (최대 20개) ────────────────────
            const playlistRes = await youtube.playlistItems.list({
                part: ['snippet'],
                playlistId: uploadsPlaylistId,
                maxResults: 20,
            });

            const items = playlistRes.data.items || [];
            const cutoffDate = lastCrawledAt?.toDate() || new Date(0);

            // 신규 영상 필터 (lastCrawledAt 이후)
            const newVideos = items.filter((item) => {
                const pubDate = new Date(item.snippet?.publishedAt || 0);
                return pubDate > cutoffDate;
            });

            console.log(`  새 영상 ${newVideos.length}개 발견`);

            for (const item of newVideos) {
                const videoId = item.snippet?.resourceId?.videoId;
                const videoTitle = item.snippet?.title || '(제목 없음)';
                const publishedAt = item.snippet?.publishedAt || new Date().toISOString();
                const videoDate = publishedAt.slice(0, 10);

                if (!videoId) continue;

                // ── 영상별 처리 시작 시각 기록 ───────────────────────
                const videoStartTime = Date.now();
                videoIndexCounter++;

                // ── 중복 처리 방지 ─────────────────────────────────
                const existing = await db.collection('youtube_knowledge')
                    .where('videoId', '==', videoId)
                    .limit(1)
                    .get();
                if (!existing.empty) {
                    console.log(`  스킵 (이미 처리됨): ${videoTitle}`);
                    runStats.skipped++;
                    runStats.totalVideos++;
                    continue;
                }

                // ── 영상 상세 정보 (길이) ──────────────────────────
                const videoRes = await youtube.videos.list({
                    part: ['contentDetails', 'snippet'],
                    id: [videoId],
                });
                const videoDetail = videoRes.data.items?.[0];
                const durationIso = videoDetail?.contentDetails?.duration || 'PT0S';
                const duration = parseDuration(durationIso);
                const description = videoDetail?.snippet?.description?.slice(0, 1000) || '';

                console.log(`  🎬 처리 중: ${videoTitle} (${duration})`);

                // ── 4. 자막 추출 ───────────────────────────────────
                const transcriptResult = await fetchYouTubeTranscript(videoId);
                let transcriptText: string | null = transcriptResult ? transcriptResult.text : null;
                let transcriptionSource: 'youtube_caption' | 'whisper_stt' | 'title_description' = 'youtube_caption';
                let captionLanguage: string | undefined = transcriptResult?.captionLanguage;
                let captionType: string | undefined = transcriptResult?.captionType;

                if (!transcriptText) {
                    // Phase 1: Whisper STT 폴백 시도
                    console.log(`  🎤 자막 없음 — Whisper STT 폴백 시도`);
                    transcriptText = await whisperTranscribe(videoId);
                    if (transcriptText) {
                        transcriptionSource = 'whisper_stt';
                        captionLanguage = undefined;
                        captionType = undefined;
                        console.log(`  ✅ Whisper STT 전사 완료: ${transcriptText.length}자`);
                    }
                }

                const hasTranscript = !!transcriptText;

                if (!transcriptText) {
                    // 최종 폴백: 제목 + 설명
                    transcriptText = `영상 제목: ${videoTitle}\n\n영상 설명:\n${description}`;
                    transcriptionSource = 'title_description';
                    captionLanguage = undefined;
                    captionType = undefined;
                    console.log(`  ⚠️ 자막/STT 모두 실패 — 제목+설명 기반으로 요약`);
                }

                // ── 4-L3. 자막 L3 청크 → youtube_transcripts 저장 ─
                // 실제 자막이 있을 때만 저장 (hasTranscript === true)
                if (hasTranscript) {
                    try {
                        // 중복 방지: 동일 videoId 문서 존재 여부 확인
                        const transcriptExisting = await db
                            .collection('youtube_transcripts')
                            .where('videoId', '==', videoId)
                            .limit(1)
                            .get();

                        if (!transcriptExisting.empty) {
                            console.log(`  ℹ️ L3 청크 스킵 (이미 저장됨): ${videoId}`);
                        } else {
                            // transcriptText는 이 시점에서 반드시 실제 자막 (hasTranscript=true)
                            const rawTranscript = transcriptText!;
                            const chunks = chunkTranscript(rawTranscript, 500, 50);
                            console.log(`  📦 L3 청크 저장 시작: ${chunks.length}개 청크`);

                            const batch = db.batch();
                            for (let i = 0; i < chunks.length; i++) {
                                const chunkEmbeddingResult = await embedModel.embedContent(chunks[i]);
                                const chunkEmbedding = chunkEmbeddingResult.embedding.values;

                                const docRef = db.collection('youtube_transcripts').doc();
                                batch.set(docRef, {
                                    videoId,
                                    channelId,
                                    channelName,
                                    title: videoTitle,
                                    chunkIndex: i,
                                    text: chunks[i],
                                    embedding: chunkEmbedding,
                                    createdAt: admin.firestore.FieldValue.serverTimestamp(),
                                });
                            }
                            await batch.commit();
                            console.log(`  ✅ L3 청크 저장 완료: ${chunks.length}개`);
                        }
                    } catch (l3Err: any) {
                        // L3 저장 오류는 기존 파이프라인에 영향 없음
                        console.error(`  ❌ L3 청크 저장 오류 (스킵):`, l3Err?.message || l3Err);
                    }
                }

                // ── 5. Gemini 2.0 Flash 요약 (지수 백오프 적용) ────
                // SKIP_GEMINI_SUMMARY=true이면 Gemini 요약 생성을 스킵
                let summary = '';
                if (!skipGeminiSummary) {
                    const summaryResult = await withBackoff(() =>
                        model.generateContent(
                            `${YOUTUBE_SUMMARY_PROMPT}\n\n[자막 내용]\n${transcriptText!.slice(0, 200000)}`
                        )
                    );
                    summary = summaryResult.response.text();
                } else {
                    console.log(`  ⏭️ Gemini 요약 스킵 (전문만 저장 모드)`);
                }

                // ── 6. Google Drive 마크다운 업로드 (요약 + 전문 분리) ───
                let driveUrl = '';
                let driveTranscriptUrl = '';
                if (drive) {
                    try {
                        // 요약 파일 업로드 — Gemini 요약이 있을 때만
                        if (!skipGeminiSummary && summary) {
                            const markdownContent = buildMarkdownContent(
                                summary, videoTitle, channelName, videoId, publishedAt, duration, hasTranscript,
                                transcriptionSource, transcriptText ? transcriptText.length : 0,
                                captionLanguage, captionType
                            );
                            driveUrl = await uploadMarkdownToDrive(
                                drive, markdownContent, channelName, videoTitle, videoDate, rootFolderId, '_요약'
                            );
                            console.log(`  ✅ Drive 요약 업로드 완료: ${driveUrl}`);
                        }

                        // 전문(자막 전체) 파일 업로드 — 실제 자막이 있을 때만 (항상 수행)
                        if (hasTranscript && transcriptText) {
                            const transcriptContent = buildTranscriptContent(
                                transcriptText, videoTitle, channelName, videoId, publishedAt, duration, transcriptionSource
                            );
                            driveTranscriptUrl = await uploadMarkdownToDrive(
                                drive, transcriptContent, channelName, videoTitle, videoDate, rootFolderId, '_전문'
                            );
                            console.log(`  ✅ Drive 전문 업로드 완료: ${driveTranscriptUrl}`);
                        }
                    } catch (driveErr) {
                        console.error(`  ❌ Drive 업로드 실패:`, driveErr);
                    }
                } else {
                    console.log('  ℹ️ Drive 업로드 스킵 (OAuth 미설정)');
                }

                // ── 7. Embedding + insurance_chunks 저장 ──────────
                // SKIP_GEMINI_SUMMARY 모드에서는 요약 기반 임베딩/저장 스킵
                let embedding: number[] = [];
                let conflictsWithPolicy = false;
                const conflictDetail: string | null = null;

                if (!skipGeminiSummary && summary) {
                    const embeddingResult = await embedModel.embedContent(
                        summary.slice(0, 8000) // 임베딩은 요약 텍스트 사용
                    );
                    embedding = embeddingResult.embedding.values;

                    // 약관 청크와 충돌 감지 (유사도 0.92 이상이면 내용 비교)
                    try {
                        const conflictSnap = await db.collection('insurance_chunks')
                            .where('sourceType', 'in', ['policy', 'newsletter'])
                            .findNearest({
                                vectorField: 'embedding',
                                queryVector: embedding,
                                limit: 3,
                                distanceMeasure: 'COSINE',
                                distanceResultField: '_distance',
                            }).get();

                        // 유사도 0.95 이상이면서 내용이 다를 가능성 체크
                        const highSimilarChunks = conflictSnap.docs.filter(
                            (d: any) => (1 - (d.data()._distance || 1)) >= 0.95
                        );
                        if (highSimilarChunks.length > 0) {
                            // 실제 내용 비교는 추후 구현 — 현재는 플래그만
                            conflictsWithPolicy = false; // Gemini로 비교 필요 (Phase 2)
                        }
                    } catch (vecErr: any) {
                        // Vector index 미설정 또는 쿼리 오류 시 스킵
                        console.warn('  ⚠️ Vector 충돌 감지 스킵:', vecErr?.message || vecErr);
                    }

                    // insurance_chunks에 저장
                    await db.collection('insurance_chunks').add({
                        companyId: '',
                        companyName: channelName,
                        productId: videoId,
                        productName: videoTitle,
                        pageNumber: 0,
                        chunkText: summary.slice(0, 2000),
                        embedding,
                        coverageNames: [],
                        sourceType: 'youtube',
                        effectiveDate: videoDate,
                        driveFileId: driveUrl,
                        conflictsWithPolicy,
                        createdAt: admin.firestore.FieldValue.serverTimestamp(),
                    });
                }

                // ── 8. youtube_knowledge 저장 ─────────────────────
                const youtubeKnowledgeData: Record<string, any> = {
                    videoId,
                    channelId,
                    channelName,
                    title: videoTitle,
                    publishedAt: admin.firestore.Timestamp.fromDate(new Date(publishedAt)),
                    chunkText: skipGeminiSummary ? '' : summary.slice(0, 2000),
                    embedding: skipGeminiSummary ? [] : embedding,
                    relatedCompanyIds: [],
                    relatedProductIds: [],
                    conflictsWithPolicy: skipGeminiSummary ? false : conflictsWithPolicy,
                    sourceType: 'youtube' as const,
                    driveUrl,
                    hasTranscript,
                    transcriptionSource,  // 'youtube_caption' | 'whisper_stt' | 'title_description'
                    driveTranscriptUrl,   // 전문 Drive URL (신규)
                    summaryStatus: skipGeminiSummary ? 'pending' : 'done', // 요약 상태
                    transcriptionLength: transcriptText ? transcriptText.length : 0,
                    transcriptionModel: transcriptionSource === 'whisper_stt' ? 'whisper-medium-gpu' : null,
                    audioSizeMb: null,  // yt-dlp가 서비스 측에서 처리하므로 클라이언트에서는 미수집
                    processingTimeMs: Date.now() - videoStartTime,
                    createdAt: admin.firestore.FieldValue.serverTimestamp(),
                };
                // conflictDetail이 null이 아니면 필드 추가
                if (conflictDetail !== null) {
                    youtubeKnowledgeData.conflictDetail = conflictDetail;
                }

                await db.collection('youtube_knowledge').add(youtubeKnowledgeData);

                // 요약 대기 영상 추적
                if (skipGeminiSummary) {
                    pendingVideoIds.push(videoId);
                }

                // ── [3-Layer] L2 구조화 요약 생성 및 youtube_summaries 저장 ──
                // SKIP_GEMINI_SUMMARY 모드에서는 L2 생성도 스킵
                if (!skipGeminiSummary && summary) {
                    try {
                        // 기존 6섹션 요약 텍스트를 입력으로 L2 구조화 요약 생성
                        const l2Result = await withBackoff(() =>
                            model.generateContent(
                                `${YOUTUBE_L2_SUMMARY_PROMPT}\n\n[6섹션 요약 원문]\n${summary.slice(0, 30000)}`
                            )
                        );
                        const l2Summary = l2Result.response.text();

                        // L2 임베딩 생성
                        const l2EmbeddingResult = await embedModel.embedContent(
                            l2Summary.slice(0, 8000)
                        );
                        const l2Embedding = l2EmbeddingResult.embedding.values;

                        await db.collection('youtube_summaries').add({
                            videoId,
                            channelId,
                            channelName,
                            title: videoTitle,
                            level: 'L2',
                            summary: l2Summary,
                            embedding: l2Embedding,
                            publishedAt: admin.firestore.Timestamp.fromDate(new Date(publishedAt)),
                            hasTranscript,
                            createdAt: admin.firestore.FieldValue.serverTimestamp(),
                        });
                        console.log(`  📊 L2 구조화 요약 저장 완료`);
                    } catch (l2Err: any) {
                        console.error(`  ⚠️ L2 요약 실패 (기존 흐름 영향 없음):`, l2Err?.message);
                    }
                }

                console.log(`  🎉 완료: ${videoTitle}`);
                runStats.processed++;
                runStats.totalVideos++;

                // ── 처리 로그 수집 ────────────────────────────────
                const videoElapsedMs = Date.now() - videoStartTime;
                processingLogs.push({
                    index: videoIndexCounter,
                    videoId,
                    title: videoTitle,
                    processingMethod: transcriptionSource,
                    transcriptLength: transcriptText ? transcriptText.length : 0,
                    summaryLength: summary ? summary.length : 0,
                    status: hasTranscript ? 'completed' : 'quality_warning',
                    driveUrl: driveUrl || undefined,
                    processingTimeMs: videoElapsedMs,
                    captionLanguage,
                    captionType,
                    ...(transcriptionSource === 'whisper_stt' ? { whisperModel: 'whisper-medium-gpu' } : {}),
                });

                // Rate limit 방지 (Gemini Flash 15req/min) — SKIP 모드에서는 대기 불필요
                if (!skipGeminiSummary) {
                    await new Promise(r => setTimeout(r, 4000));
                }
            }

            // ── 9. lastCrawledAt 갱신 ─────────────────────────────
            await channelDoc.ref.update({
                lastCrawledAt: admin.firestore.FieldValue.serverTimestamp(),
            });

        } catch (err) {
            console.error(`❌ 채널 처리 오류 (${channelName}):`, err);
            runStats.failed++;
            runStats.errors.push(`${channelName}: ${(err as Error).message}`);
        }
    }

    // ── 처리 로그 Drive 업로드 (모든 채널 처리 후) ──
    if (drive && processingLogs.length > 0) {
        try {
            const logMarkdown = buildProcessingLogMarkdown(
                processingLogs,
                processedChannelNames,
                pipelineStartTime
            );
            const logDateStr = pipelineStartTime.toISOString().slice(0, 10);
            const logFileName = `${logDateStr}_크롤링로그`;
            const logFolderName = '_처리로그';

            // 04_유튜브요약/_처리로그/ 폴더 확인/생성
            const youtubeFolder = await findOrCreateFolder(drive, rootFolderId, '04_유튜브요약');
            const logFolder = await findOrCreateFolder(drive, youtubeFolder, logFolderName);

            // 기존 로그 파일 확인 (중복 방지)
            const existingLogFile = await drive.files.list({
                q: `'${logFolder}' in parents and name='${logFileName}.md' and trashed=false`,
                fields: 'files(id)',
                pageSize: 1,
            });

            if (existingLogFile.data.files && existingLogFile.data.files.length > 0) {
                // 기존 파일 업데이트
                await drive.files.update({
                    fileId: existingLogFile.data.files[0].id!,
                    media: {
                        mimeType: 'text/plain',
                        body: logMarkdown,
                    },
                });
                console.log(`\n📋 처리 로그 Drive 업데이트 완료: ${logFileName}.md`);
            } else {
                // 새 파일 생성
                await drive.files.create({
                    requestBody: {
                        name: `${logFileName}.md`,
                        mimeType: 'text/plain',
                        parents: [logFolder],
                    },
                    media: {
                        mimeType: 'text/plain',
                        body: logMarkdown,
                    },
                    fields: 'id',
                });
                console.log(`\n📋 처리 로그 Drive 업로드 완료: ${logFileName}.md`);
            }
        } catch (logErr: any) {
            console.error('⚠️ 처리 로그 Drive 업로드 실패 (파이프라인 계속):', logErr?.message || logErr);
        }
    }

    // ── 요약 대기 영상 이벤트 기록 (SKIP_GEMINI_SUMMARY 모드) ──
    if (skipGeminiSummary && pendingVideoIds.length > 0) {
        console.log(`\n📋 요약 대기 영상 ${pendingVideoIds.length}개: ${pendingVideoIds.join(', ')}`);
        // Firestore youtube_summary_queue에 대기 목록 저장 (아누 조회용)
        const queueBatch = db.batch();
        for (const vid of pendingVideoIds) {
            const queueRef = db.collection('youtube_summary_queue').doc(vid);
            queueBatch.set(queueRef, {
                videoId: vid,
                status: 'pending',
                createdAt: admin.firestore.FieldValue.serverTimestamp(),
            }, { merge: true } as any);
        }
        await queueBatch.commit();
        console.log(`  ✅ youtube_summary_queue에 ${pendingVideoIds.length}개 등록 완료`);
    }

    // ── 파이프라인 로그 완료 업데이트 ──
    await pipelineLogRef.update({
        status: 'completed',
        completedAt: admin.firestore.FieldValue.serverTimestamp(),
        stats: runStats,
        ...(skipGeminiSummary && pendingVideoIds.length > 0 ? { pendingVideoIds } : {}),
    });

    console.log('\n✅ 전체 크롤링 완료!');
});
