// ==UserScript==
// @name         Ntoki Downloader
// @namespace    https://github.com/crossSiteKikyo/tokiDownloader
// @version      0.2.0
// @description  뉴토끼(ntk01.com, sbxh*.com) 웹툰/만화 다운로더 (팝업 방식, JavaScript 렌더링 필요)
// @author       hehaho
// @match        https://ntk01.com/*
// @match        https://www.ntk01.com/*
// @match        https://sbxh1.com/*
// @match        https://sbxh2.com/*
// @match        https://sbxh3.com/*
// @match        https://sbxh4.com/*
// @match        https://sbxh5.com/*
// @icon         https://github.com/user-attachments/assets/99f5bb36-4ef8-40cc-8ae5-e3bf1c7952ad
// @grant        GM_registerMenuCommand
// @grant        GM_unregisterMenuCommand
// @grant        GM_xmlhttpRequest
// @grant        GM_download
// @require      https://cdnjs.cloudflare.com/ajax/libs/jszip/3.7.1/jszip.min.js
// @require      https://cdnjs.cloudflare.com/ajax/libs/jszip-utils/0.1.0/jszip-utils.js
// @run-at       document-end
// @license      MIT
// ==/UserScript==

(function () {
    'use strict';

    const LOG = [];
    const log = (...args) => {
        const msg = args.map(a => {
            if (a instanceof Error) return a.message;
            if (typeof a === 'object' && a !== null) {
                try { return JSON.stringify(a); } catch(e) { return String(a); }
            }
            return String(a);
        }).join(' ');
        LOG.push(`[${new Date().toISOString().slice(11,19)}] ${msg}`);
    };

    const currentURL = document.URL;

    log(`🚀 Ntoki Downloader v0.2.0 시작됨`);
    log(`📍 URL: ${currentURL}`);
    log(`📍 호스트: ${new URL(currentURL).hostname}`);

    // 알려진 웹툰 이미지 CDN 도메인 (화이트리스트)
    const ALLOWED_IMAGE_DOMAINS = ['i.toonflix.app', 'img.ntk01.com', '11toon8.com', 'sbxh1.com', 'sbxh2.com', 'sbxh3.com'];

    const isAllowedImageHost = (url) => {
        try {
            const absoluteUrl = url.startsWith('http://') || url.startsWith('https://')
                ? url
                : new URL(url, currentURL).href;
            const hostname = new URL(absoluteUrl).hostname;
            const pageHost = new URL(currentURL).hostname;
            if (hostname === pageHost || hostname.endsWith('.' + pageHost)) return true;
            return ALLOWED_IMAGE_DOMAINS.some(d => hostname === d || hostname.endsWith('.' + d));
        } catch(e) {
            return false;
        }
    };

    // 이미지 해상도 기반 검증 (만화 페이지는 세로>가로, 최소 500px)
    const isComicPageBlob = (blob) => {
        return createImageBitmap(blob).then(bitmap => {
            // 배너만 필터: 가로가 세로의 2배 이상(전형적인 배너) 또는 높이 200px 미만
            const valid = !(bitmap.width > bitmap.height * 2 || bitmap.height < 200);
            bitmap.close();
            return valid;
        }).catch(() => true);
    };

    // 이미지 로딩을 기다리고 lazy load 트리거
    const waitForImagesToLoad = (targetDocument, timeoutMs = 20000) => {
        return new Promise(resolve => {
            const container = targetDocument.querySelector('.vw-imgs, .vw-images, #bo_v_con, .view-content, #toon_img, .board-view') || targetDocument.body;
            const allImages = container.querySelectorAll('img');
            const baseUrl = targetDocument.URL;
            allImages.forEach(img => {
                if (img.getAttribute('loading') === 'lazy') {
                    img.setAttribute('loading', 'eager');
                }
                ['data-src', 'data-original', 'data-lazy-src', 'data-lazy'].forEach(attr => {
                    const val = img.getAttribute(attr);
                    if (val && !img.src) {
                        try {
                            img.src = new URL(val, baseUrl).href;
                        } catch {}
                    }
                });
            });
            const loading = Array.from(allImages).filter(img => !img.complete || img.naturalWidth === 0);
            if (loading.length === 0) { resolve(); return; }
            let remaining = loading.length;
            const timeout = setTimeout(resolve, timeoutMs);
            loading.forEach(img => {
                const done = () => {
                    remaining--;
                    if (remaining <= 0) { clearTimeout(timeout); resolve(); }
                };
                if (img.complete && img.naturalWidth > 0) done();
                else {
                    img.addEventListener('load', done, { once: true });
                    img.addEventListener('error', done, { once: true });
                }
            });
        });
    };

    // 페이지 타입 감지
    const pathMatch = currentURL.match(/\/manhwa\/([^/]+)(?:\/(\d+))?/);
    const isEpisodePage = pathMatch && pathMatch[2] ? true : false;
    const isListPage = pathMatch && !pathMatch[2] ? true : false;

    // 파일 다운로드 헬퍼
    const downloadZip = (blob, filename) => {
        const url = URL.createObjectURL(blob);
        if (typeof GM_download !== "undefined") {
            GM_download({
                url: url,
                name: filename,
                onload: () => URL.revokeObjectURL(url),
                onerror: () => {
                    const link = document.createElement("a");
                    link.href = url;
                    link.download = filename;
                    link.click();
                    URL.revokeObjectURL(url);
                }
            });
        } else {
            const link = document.createElement("a");
            link.href = url;
            link.download = filename;
            link.click();
            URL.revokeObjectURL(url);
        }
    };

    const downloadLogFile = () => { return; // 비활성화
        const now = new Date();
        const time = String(now.getHours()).padStart(2,'0') + String(now.getMinutes()).padStart(2,'0') + String(now.getSeconds()).padStart(2,'0');
        const content = LOG.join('\n');
        if (!content) return;
        const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
        const url = URL.createObjectURL(blob);
        const a = document.createElement('a');
        a.href = url;
        a.download = `Download_${time}.log`;
        a.click();
        URL.revokeObjectURL(url);
    };

    // XML 특수문자 이스케이프
    const escapeXml = (str) => {
        if (!str) return '';
        return str
            .replace(/&/g, '&amp;')
            .replace(/</g, '&lt;')
            .replace(/>/g, '&gt;')
            .replace(/"/g, '&quot;')
            .replace(/'/g, '&apos;');
    };

    // comicinfo.xml 생성
    const generateComicInfoXml = (title, seriesTitle = '', episodeNumber = '', writer = '', imageCount) => {
        const comicInfo = `<?xml version="1.0" encoding="utf-8"?>
<ComicInfo xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
  <Title>${escapeXml(title)}</Title>
  <Series>${escapeXml(seriesTitle)}</Series>
  <Number>${escapeXml(episodeNumber)}</Number>
  <Writer>${escapeXml(writer)}</Writer>
  <PageCount>${imageCount}</PageCount>
  <LanguageISO>ko</LanguageISO>
  <Format>Manga</Format>
</ComicInfo>`;
        return comicInfo;
    };

    // JSON-LD에서 정보 추출 (말줄임표 없는 완전한 제목)
    const getJsonLdData = () => {
        const scripts = document.querySelectorAll('script[type="application/ld+json"]');
        for (const script of scripts) {
            try {
                const data = JSON.parse(script.textContent);
                if (data) return data;
            } catch (e) {}
        }
        return null;
    };

    // DOM에서 정보 추출
    const getSeriesTitle = () => {
        // JSON-LD에서 먼저 시도
        const jsonLd = getJsonLdData();
        if (jsonLd) {
            // ComicIssue 타입인 경우 isPartOf.name 사용
            if (jsonLd.isPartOf && jsonLd.isPartOf.name) {
                return jsonLd.isPartOf.name;
            }
            // Book 타입인 경우 name 사용
            if (jsonLd['@type'] === 'Book' && jsonLd.name) {
                return jsonLd.name;
            }
        }
        // DOM에서 fallback
        const workEl = document.querySelector('.vw-work');
        if (workEl) return workEl.textContent.trim();
        const heroTitle = document.querySelector('.hero-v2-title');
        if (heroTitle) return heroTitle.textContent.trim();
        return '';
    };

    const getEpisodeTitle = () => {
        // JSON-LD에서 먼저 시도 - name이 headline보다 완전한 제목임
        const jsonLd = getJsonLdData();
        if (jsonLd && (jsonLd['@type'] === 'ComicIssue' || jsonLd['@type'] === 'ComicStory')) {
            if (jsonLd.name) return jsonLd.name;
        }
        // DOM에서 fallback
        const epEl = document.querySelector('.vw-ep');
        if (!epEl) return '';
        const span = epEl.querySelector('span');
        return span ? span.textContent.trim().replace(/^[\s\-]+/, '') : '';
    };

    const getEpisodeNumber = () => {
        const epEl = document.querySelector('.vw-ep');
        if (!epEl) return '';
        const strong = epEl.querySelector('strong');
        return strong ? strong.textContent.trim() : '';
    };

    // 이미지 URL 추출 공통 함수
    const extractImageUrls = (container = document, baseUrl) => {
        const imageUrls = [];
        const seen = new Set();
        const resolveUrl = (url) => {
            if (!url) return '';
            try {
                if (url.startsWith('http://') || url.startsWith('https://')) return url;
                if (url.startsWith('//')) return new URL(url, 'https:').href;
                if (url.startsWith('/')) return new URL(url, baseUrl || document.URL).href;
                return new URL(url, baseUrl || document.URL).href;
            } catch { return ''; }
        };

        const addUrl = (url) => {
            if (!url || seen.has(url)) return;
            if (url.includes('logo') || url.includes('banner') || url.includes('icon') || url.includes('avatar')) return;
            if (!isAllowedImageHost(url)) return;
            seen.add(url);
            imageUrls.push(url);
        };

        // 방법 1: .vw-imgs 내의 img 태그
        const imgContainer = container.querySelector('.vw-imgs') || container;
        const imgElements = imgContainer.querySelectorAll('img');
        imgElements.forEach(img => {
            const sources = [
                img.src,
                img.currentSrc,
                img.dataset.src,
                img.dataset.original,
                img.dataset.lazySrc,
                img.dataset.lazy,
                img.getAttribute('data-src'),
                img.getAttribute('data-original'),
                img.getAttribute('srcset')?.split(' ')[0],
                img.getAttribute('data-srcset')?.split(' ')[0],
            ];
            sources.forEach(s => { const u = resolveUrl(s); if (u) addUrl(u); });
        });

        // 방법 2: preload 링크
        if (imageUrls.length === 0) {
            const preloadLinks = container.querySelectorAll('link[rel="preload"][as="image"]');
            preloadLinks.forEach(link => {
                const u = resolveUrl(link.getAttribute('href'));
                if (u) addUrl(u);
            });
        }

        // 방법 3: picture > source srcset
        if (imageUrls.length === 0) {
            const sources = container.querySelectorAll('picture source[srcset], source[data-srcset]');
            sources.forEach(src => {
                const srcset = src.getAttribute('srcset') || src.dataset.srcset;
                if (srcset) {
                    srcset.split(',').forEach(s => {
                        const u = resolveUrl(s.trim().split(' ')[0]);
                        if (u) addUrl(u);
                    });
                }
            });
        }

        return imageUrls;
    };

    // 현재 에피소드 이미지 다운로드
    const downloadImages = async () => {
        // 페이지 이미지 로딩 완료 대기
        await waitForImagesToLoad(document);
        const imageUrls = extractImageUrls();

        if (imageUrls.length === 0) {
            log('❌ 다운로드할 이미지를 찾을 수 없음');
            const allImgs = document.querySelectorAll('.vw-imgs img');
            log('.vw-imgs img 개수:', allImgs.length);
            allImgs.forEach((img, i) => {
                log(`img[${i}] src=${img.src || '(empty)'}, data-src=${img.dataset?.src || '(none)'}, complete=${img.complete}, naturalWidth=${img.naturalWidth}`);
            });
            const preloadLinks = document.querySelectorAll('link[rel="preload"][as="image"]');
            log('preload links:', preloadLinks.length);
            preloadLinks.forEach((link, i) => {
                log(`link[${i}] href=${link.getAttribute('href')}`);
            });
            log('👉 다운로드 로그를 저장합니다.');
            try { downloadLogFile(); } catch (_) {}
            alert(`❌ 다운로드할 이미지를 찾을 수 없습니다.\n🔍 저장된 로그 파일(Download_xxxxxx.log)을 확인하세요.`);
            return;
        }

        let title = getEpisodeTitle() || document.title.split('|')[0].trim() || 'webtoon_download';
        title = title.replace(/[/\\:*?"<>|]/g, '_');
        let episodeNum = getEpisodeNumber();

        log(`발견된 이미지: ${imageUrls.length}개, 제목: ${title}`);

        const zip = new JSZip();
        let imageCount = 0;
        let failedCount = 0;

        for (let i = 0; i < imageUrls.length; i++) {
            const imageUrl = imageUrls[i];
            log(`다운로드 중... ${i + 1}/${imageUrls.length}`);

            try {
                const blob = await downloadImageBlob(imageUrl);
                if (blob && blob.size > 0) {
                    const filename = `${String(imageCount).padStart(4, '0')}_page_${i + 1}.jpg`;
                    zip.file(filename, blob);
                    imageCount++;
                    log(`✓ 이미지 다운로드 완료: ${i + 1}/${imageUrls.length}`);
                } else {
                    failedCount++;
                    log(`⚠ 이미지 다운로드 실패 (${i + 1}번째): 빈 응답`);
                }
            } catch (error) {
                log(`⚠ 이미지 다운로드 실패 (${i + 1}번째):`, error);
                failedCount++;
            }
        }

        if (imageCount > 0) {
            const seriesTitle = getSeriesTitle();
            const comicInfoXml = generateComicInfoXml(title, seriesTitle, episodeNum, '', imageCount);
            zip.file('ComicInfo.xml', comicInfoXml);
            const blob = await zip.generateAsync({ type: 'blob' });
            downloadZip(blob, `${title}.zip`);
            downloadLogFile();
            alert(`✓ ${imageCount}개 이미지 + ComicInfo.xml 다운로드 완료\n(실패: ${failedCount}개)\n\n파일명: ${title}.zip`);
        } else {
            alert(`❌ 다운로드 가능한 이미지 없음\n발견: ${imageUrls.length}개, 성공: ${imageCount}개, 실패: ${failedCount}개`);
        }
    };

    // sleep 헬퍼
    const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));

    // 팝업 창으로 에피소드 페이지를 렌더링 후 이미지 URL 추출 (JavaScript 실행 + 스크롤 필요)
    const downloadEpisodeImagesViaPopup = async (episodeUrl, episodeTitle) => {
        return new Promise((resolve) => {
            log(`🔄 팝업 열기: ${episodeTitle}`);

            const popup = window.open(episodeUrl, 'episodeViewer', 'width=800,height=600,left=-9999,top=-9999');

            if (!popup) {
                log(`⚠️ 팝업 차단됨, HTTP 방식 fallback: ${episodeTitle}`);
                fetchHtmlAndResolve(episodeUrl, episodeTitle, resolve);
                return;
            }

            let resolved = false;
            const MAX_WAIT = 60000;

            const done = (result) => {
                if (resolved) return;
                resolved = true;
                // 팝업은 닫지 않고 result에 closePopup 전달 (호출자가 다운로드 완료 후 닫음)
                result.closePopup = closePopup;
                resolve(result);
            };

            let closePopup = () => { try { popup.close(); } catch (_) {} };

            // 1단계: .vw-imgs가 나타날 때까지 대기 (최대 30초)
            const waitForViewer = () => {
                let waited = 0;
                let viewerTimer = null;

                const pollTimer = setInterval(() => {
                    waited += 500;

                    try {
                        if (popup.closed) {
                            clearInterval(pollTimer);
                            log(`⚠️ 팝업이 일찍 닫힘: ${episodeTitle}`);
                            done({ success: false, imageUrls: [], imageCount: 0, chapterTitle: episodeTitle });
                            return;
                        }

                        const doc = popup.document;
                        const popupUrl = popup.location ? popup.location.href : '';

                        // about:blank면 아직 로딩 전 -> 계속 대기
                        if (!popupUrl || popupUrl === 'about:blank') return;

                        const vwImgs = doc.querySelector('.vw-imgs');

                        if (vwImgs) {
                            clearInterval(pollTimer);
                            if (viewerTimer) clearTimeout(viewerTimer);
                            log(`✅ .vw-imgs 발견, 스크롤 시작: ${episodeTitle}`);
                            scrollToLoadImages(doc, done);
                            return;
                        }

                        // 25초 이상 지나도 vw-imgs 없으면 HTTP fallback
                        if (waited >= 25000) {
                            clearInterval(pollTimer);
                            if (viewerTimer) clearTimeout(viewerTimer);
                            log(`⚠️ .vw-imgs 없음 (${waited}ms 대기): ${episodeTitle}`);
                            try { popup.close(); } catch (_) {}
                            fetchHtmlAndResolve(episodeUrl, episodeTitle, done);
                            return;
                        }
                    } catch (e) {
                        // 아직 cross-origin 상태 (초기 로딩 중) - 무시
                    }
                }, 500);

                viewerTimer = setTimeout(() => {
                    clearInterval(pollTimer);
                    if (!resolved) {
                        log(`⏰ 뷰어 대기 타임아웃 (30s): ${episodeTitle}`);
                        try { popup.close(); } catch (_) {}
                        fetchHtmlAndResolve(episodeUrl, episodeTitle, done);
                    }
                }, 30000);
            };

            // 2단계: 모든 이미지 eager 로딩 설정 후 천천히 스크롤
            const scrollToLoadImages = (doc, callback) => {
                const win = popup;
                let lastScroll = -1;
                let scrollCount = 0;
                let goingDown = true;
                const SCROLL_STEP = 700;
                const SCROLL_INTERVAL = 600;

                // 모든 이미지를 eager로 설정하고 data-src → src 복사
                const allImgs = doc.querySelectorAll('.vw-imgs img, .vw-images img, [class*="viewer"] img, [class*="comic"] img');
                let imgCount = allImgs.length;
                log(`📸 .vw-imgs 내 img 태그: ${imgCount}개`);
                allImgs.forEach(img => {
                    img.setAttribute('loading', 'eager');
                    ['data-src', 'data-original', 'data-lazy-src', 'data-lazy'].forEach(attr => {
                        const val = img.getAttribute(attr);
                        if (val && !img.src) {
                            try { img.src = new URL(val, episodeUrl).href; } catch {}
                        }
                    });
                });

                const scrollTimer = setInterval(() => {
                    try {
                        if (popup.closed) {
                            clearInterval(scrollTimer);
                            callback({ success: false, imageUrls: [], imageCount: 0, chapterTitle: episodeTitle });
                            return;
                        }

                        if (goingDown) {
                            win.scrollBy(0, SCROLL_STEP);
                        } else {
                            win.scrollBy(0, -SCROLL_STEP * 2);
                        }
                        scrollCount++;

                        const curScroll = win.scrollY || win.pageYOffset || 0;
                        const maxScroll = Math.max(
                            doc.documentElement.scrollHeight - win.innerHeight,
                            doc.body.scrollHeight - win.innerHeight,
                            0
                        );

                        if (goingDown && (curScroll >= maxScroll || curScroll === lastScroll || scrollCount > 80)) {
                            // 바닥 도달 → 위로 스크롤 시작
                            goingDown = false;
                            lastScroll = curScroll;
                            log(`📜 바닥 도달, 위로 스크롤 (${scrollCount}회): ${episodeTitle}`);
                        } else if (!goingDown && curScroll <= 0) {
                            // 맨 위 도달 → 완료
                            clearInterval(scrollTimer);
                            log(`📜 왕복 스크롤 완료 (${scrollCount}회): ${episodeTitle}`);
                            log(`⏳ 5초간 이미지 로딩 대기...`);
                            setTimeout(() => {
                                waitForImagesToLoad(doc).then(() => {
                                    extractFromPopup(doc, callback);
                                }).catch(() => {
                                    extractFromPopup(doc, callback);
                                });
                            }, 5000);
                            return;
                        }

                        lastScroll = curScroll;
                    } catch (e) {
                        if (scrollCount > 160) {
                            clearInterval(scrollTimer);
                            extractFromPopup(doc, callback);
                        }
                    }
                }, SCROLL_INTERVAL);

                setTimeout(() => {
                    clearInterval(scrollTimer);
                    if (!resolved) {
                        log(`⏰ 스크롤 타임아웃: ${episodeTitle}`);
                        extractFromPopup(doc, callback);
                    }
                }, 180000);
            };

            // 3단계: 팝업 DOM에서 이미지 URL 추출 + 누락 페이지 보강
            const extractFromPopup = async (doc, callback) => {
                try {
                    log(`🔍 팝업 DOM 분석: ${episodeTitle}`);
                    const vwImgs = doc.querySelector('.vw-imgs');
                    const imgEls = vwImgs ? vwImgs.querySelectorAll('img') : [];
                    log(`.vw-imgs 존재: ${!!vwImgs}, img 개수: ${imgEls.length}`);

                    let imageUrls = extractImageUrls(doc, episodeUrl);
                    log(`✓ ${imageUrls.length}개 이미지 URL 추출 (팝업)`);

                    // pNNN 패턴이 있으면 누락 페이지 HEAD 요청으로 찾기
                    if (imageUrls.some(u => /\/p\d{3}\./.test(u))) {
                        imageUrls = await fillMissingPages(imageUrls, episodeUrl);
                    }

                    let chapterTitle = episodeTitle;

                    if (imageUrls.length > 0) {
                        log(`호스트 목록: ${[...new Set(imageUrls.map(u => new URL(u).hostname))].join(', ')}`);
                    }

                    try {
                        const epEl = doc.querySelector('.vw-ep');
                        if (epEl) {
                            const strong = epEl.querySelector('strong');
                            const span = epEl.querySelector('span');
                            const episodeNum = strong ? strong.textContent.trim() : '';
                            const episodeSubtitle = span ? span.textContent.trim().replace(/^[\s\-]+/, '') : '';
                            chapterTitle = episodeNum ? `${episodeNum} - ${episodeSubtitle}` : episodeSubtitle;
                        }
                    } catch (e) {}

                    callback({
                        success: imageUrls.length > 0,
                        imageUrls, imageCount: imageUrls.length, chapterTitle
                    });
                } catch (error) {
                    log(`❌ 팝업 추출 실패: ${episodeTitle}`, error);
                    callback({ success: false, imageUrls: [], imageCount: 0, chapterTitle: episodeTitle });
                }
            };

            // 시작
            waitForViewer();
        });
    };

    // HTTP fetch fallback (메타데이터 + 가능한 이미지)
    const fetchHtmlAndResolve = async (episodeUrl, episodeTitle, resolve) => {
        try {
            const controller = new AbortController();
            const timeoutId = setTimeout(() => controller.abort(), 15000);

            const response = await fetch(episodeUrl, {
                credentials: 'include',
                headers: {
                    'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
                    'Referer': document.URL,
                    'Accept-Language': 'ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7'
                }
            });
            clearTimeout(timeoutId);

            const html = await response.text();
            if (!html || html.length < 500) {
                resolve({ success: false, imageUrls: [], imageCount: 0, chapterTitle: episodeTitle });
                return;
            }

            const parser = new DOMParser();
            const doc = parser.parseFromString(html, 'text/html');
            const imageUrls = extractImageUrls(doc, episodeUrl);

            let chapterTitle = episodeTitle;
            if (imageUrls.length > 0) {
                log(`✓ [${episodeTitle}] ${imageUrls.length}개 이미지 URL 추출 (HTTP fallback)`);
            } else {
                log(`⚠️ 이미지 없음 (HTTP fallback): ${episodeTitle}`);
            }

            resolve({
                success: imageUrls.length > 0,
                imageUrls: imageUrls,
                imageCount: imageUrls.length,
                chapterTitle: chapterTitle
            });
        } catch (error) {
            log(`❌ HTTP fallback 실패: ${episodeTitle}`, error);
            resolve({ success: false, imageUrls: [], imageCount: 0, chapterTitle: episodeTitle });
        }
    };

    // URL로부터 이미지 다운로드
    const downloadImageBlob = (url) => {
        return new Promise((resolve) => {
            GM_xmlhttpRequest({
                method: 'GET',
                url: url,
                responseType: 'arraybuffer',
                headers: {
                    'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
                    'Referer': new URL(document.URL).origin + '/'
                },
                onload: (response) => {
                    try {
                        const responseText = response.responseHeaders || '';
                        const contentType = (responseText.match(/content-type:\s*([^\r\n;]+)/i) || [])[1] || '';
                        if (contentType && !contentType.startsWith('image/')) {
                            log(`⚠️ 이미지가 아닌 응답 스킵:`, contentType);
                            resolve(null);
                            return;
                        }
                        const blob = new Blob([response.response], { type: 'image/jpeg' });
                        isComicPageBlob(blob).then(valid => {
                            if (valid) {
                                resolve(blob);
                            } else {
                                log(`⚠️ 광고 배너로 의심됨: ${url}`);
                                resolve(null);
                            }
                        });
                    } catch (error) {
                        log(`⚠️ 이미지 Blob 변환 실패:`, error);
                        resolve(null);
                    }
                },
                onerror: (error) => {
                    log(`⚠️ 이미지 다운로드 실패: ${url}`);
                    resolve(null);
                }
            });
        });
    };

    // 누락 페이지 URL 자동 생성 (pNNN 패턴, HEAD 불필요)
    const fillMissingPages = async (imageUrls, episodeUrl) => {
        const pUrls = imageUrls.filter(u => /\/p\d{3}\./.test(u));
        if (pUrls.length === 0) return imageUrls;

        const baseMatch = episodeUrl.match(/\/manhwa\/([^/]+)\/(\d+)/);
        if (!baseMatch) return imageUrls;

        let maxPage = 0;
        const extMap = new Map();
        for (const url of pUrls) {
            const m = url.match(/\/p(\d{3})\.(jpg|png|webp)/);
            if (m) {
                const num = parseInt(m[1], 10);
                maxPage = Math.max(maxPage, num);
                extMap.set(url, m[2]);
            }
        }

        log(`🔍 누락 페이지 확인: 최대 p${String(maxPage).padStart(3,'0')}까지 있음, ${maxPage}번 이후 확장자 패턴 분석 중...`);

        // 마지막 3개 이미지의 확장자 패턴으로 다음 확장자 추론
        const sortedPages = [...pUrls]
            .map(u => { const m = u.match(/\/p(\d{3})\.(jpg|png|webp)/); return m ? {url: u, num: parseInt(m[1],10), ext: m[2]} : null; })
            .filter(Boolean)
            .sort((a,b) => a.num - b.num);

        const lastFew = sortedPages.slice(-5);
        log(`   마지막 ${lastFew.length}개 패턴: ${lastFew.map(p => `p${p.num}.${p.ext}`).join(', ')}`);

        const results = [...imageUrls];
        const seen = new Set(imageUrls);

        for (let page = maxPage + 1; page <= maxPage + 10; page++) {
            const pageStr = String(page).padStart(3, '0');

            // 이전 페이지들의 확장자 패턴 확인
            const prevExts = [];
            for (let p = Math.max(1, page - 5); p < page && p <= maxPage; p++) {
                const prevUrl = imageUrls.find(u => u.includes(`/p${String(p).padStart(3,'0')}.`));
                if (prevUrl) {
                    const m = prevUrl.match(/\.(jpg|png|webp)$/);
                    if (m) prevExts.push(m[1]);
                }
            }

            // 가장 자주 나온 확장자 선호
            const extCount = {};
            let bestExt = '.jpg';
            for (const e of prevExts) { extCount[e] = (extCount[e]||0)+1; }
            const entries = Object.entries(extCount);
            if (entries.length > 0) {
                bestExt = entries.sort((a,b) => b[1] - a[1])[0][0];
            }

            const url = `https://i.toonflix.app/manhwa/${baseMatch[1]}/${baseMatch[2]}/p${pageStr}.${bestExt}`;
            if (!seen.has(url)) {
                log(`  ➕ 생성: p${pageStr}.${bestExt} (패턴 기반)`);
                results.push(url);
                seen.add(url);
            }
        }

        const added = results.length - imageUrls.length;
        if (added > 0) log(`✅ ${added}개 페이지 URL 추가 생성 (총 ${results.length}개)`);
        else log(`   추가 생성 없음`);
        return results;
    };

    // 에피소드 목록 페이지에서 링크 수집
    const getEpisodeList = () => {
        const episodeElements = document.querySelectorAll('a.ep-row-v2-link');
        if (episodeElements.length === 0) return null;

        const seriesTitle = getSeriesTitle();
        const writerFromPage = document.querySelector('.hero-v2-author');
        const seriesWriter = writerFromPage ? writerFromPage.textContent.trim() : '';

        const episodeLinks = Array.from(episodeElements).map((a) => {
            const titleElement = a.querySelector('.ep-row-v2-title strong');
            const title = titleElement ? titleElement.textContent.trim() : a.textContent.trim();
            const noElement = a.querySelector('.ep-row-v2-no');
            const episodeNum = noElement ? noElement.textContent.trim() : '';
            return { url: a.href, title, episodeNum };
        });

        episodeLinks.reverse();
        return { episodeLinks, seriesTitle, seriesWriter };
    };

    // 에피소드 목록을 순차 다운로드 (공통 코어)
    const downloadEpisodeList = async (episodeLinks, seriesTitle, seriesWriter) => {
        let downloadedCount = 0;
        let failedCount = 0;
        const MAX_RETRIES = 2;
        let failedEpisodes = [];

        const downloadSingleEpisode = async (episode, retryCount = 0) => {
            let result;
            try {
                log(`⏳ [${retryCount > 0 ? `재시도 ${retryCount}/${MAX_RETRIES}` : ''}] 다운로드 중: ${episode.title}`);

                result = await downloadEpisodeImagesViaPopup(episode.url, episode.title);

                if (result.success && result.imageUrls && result.imageUrls.length > 0) {
                    const zip = new JSZip();
                    let downloadedImages = 0;

                    for (let j = 0; j < result.imageUrls.length; j++) {
                        const imageUrl = result.imageUrls[j];
                        log(`  📥 이미지 ${j + 1}/${result.imageUrls.length} 다운로드 중...`);

                        const blob = await downloadImageBlob(imageUrl);
                        if (blob) {
                            const filename = `${String(j).padStart(4, '0')}_page_${j + 1}.jpg`;
                            zip.file(filename, blob);
                            downloadedImages++;
                        }
                    }

                    if (downloadedImages > 0) {
                        let chapterTitle = result.chapterTitle || episode.title;

                        const cleanTitle = chapterTitle.replace(/[/\\:*?"<>|]/g, '_').slice(0, 100);

                        let epNumber = episode.episodeNum;
                        if (!epNumber) {
                            const numMatch = episode.title.match(/^(\d+)/);
                            epNumber = numMatch ? numMatch[1] : '0';
                        }

                        const comicInfoXml = generateComicInfoXml(
                            cleanTitle,
                            seriesTitle,
                            epNumber,
                            seriesWriter,
                            downloadedImages
                        );
                        zip.file('ComicInfo.xml', comicInfoXml);

                        const blob = await zip.generateAsync({ type: 'blob' });
                        downloadZip(blob, `${cleanTitle}.zip`);
                        log(`✅ ${cleanTitle} (${downloadedImages}/${result.imageCount}개 이미지) 다운로드 완료`);
                        if (result.closePopup) result.closePopup();
                        return true;
                    }
                }
                if (result && result.closePopup) result.closePopup();
                return false;
            } catch (error) {
                log(`❌ 에피소드 처리 실패: ${episode.title}`, error);
                if (result && result.closePopup) result.closePopup();
                return false;
            }
        };

        // 1차 다운로드
        for (let i = 0; i < episodeLinks.length; i++) {
            const episode = episodeLinks[i];
            const success = await downloadSingleEpisode(episode);
            if (success) {
                downloadedCount++;
            } else {
                failedCount++;
                failedEpisodes.push(episode);
            }
            await new Promise(resolve => setTimeout(resolve, 20000));
        }

        // 실패한 에피소드 재시도
        for (let retry = 0; retry < MAX_RETRIES && failedEpisodes.length > 0; retry++) {
            log(`🔄 재시도 ${retry + 1}/${MAX_RETRIES}: ${failedEpisodes.length}개 에피소드`);
            const stillFailed = [];

            for (const episode of failedEpisodes) {
                const success = await downloadSingleEpisode(episode, retry + 1);
                if (success) {
                    downloadedCount++;
                    failedCount--;
                } else {
                    stillFailed.push(episode);
                }
                await new Promise(resolve => setTimeout(resolve, 30000));
            }

            failedEpisodes = stillFailed;
            if (failedEpisodes.length === 0) break;
        }

        // 사용자 재시도 루프
        while (failedEpisodes.length > 0) {
            const result = confirm(`🎉 1차 완료!\n\n✅ 다운로드: ${downloadedCount}개\n⚠️  실패: ${failedEpisodes.length}개\n📊 총 에피소드: ${episodeLinks.length}개\n\n실패한 에피소드를 다시 시도할까요?`);
            if (!result) break;

            const stillFailed = [];
            for (const episode of failedEpisodes) {
                log(`🔄 사용자 재시도: ${episode.title}`);
                const success = await downloadSingleEpisode(episode, '재시도');
                if (success) {
                    downloadedCount++;
                    failedCount--;
                } else {
                    stillFailed.push(episode);
                }
                await new Promise(resolve => setTimeout(resolve, 30000));
            }
            failedEpisodes = stillFailed;
        }

        log(`✅ 최종 완료: ${downloadedCount}개 성공, ${failedEpisodes.length}개 실패 (총 ${episodeLinks.length}개)`);
        alert(`🎉 최종 완료!\n\n✅ 다운로드: ${downloadedCount}개\n⚠️  실패: ${failedEpisodes.length}개\n📊 총 에피소드: ${episodeLinks.length}개`);
    };

    // 전체 에피소드 다운로드
    const downloadAllEpisodesAsZips = async () => {
        const list = getEpisodeList();
        if (!list) {
            log(`❌ 에피소드 링크를 찾을 수 없음 (선택자: a.ep-row-v2-link)`);
            const allLinks = document.querySelectorAll('a[href*="/manhwa/"]');
            log(`/manhwa/ 링크 개수: ${allLinks.length}`);
            allLinks.forEach((a, i) => {
                if (i < 10) log(`  링크[${i}] class="${a.className}" href="${a.href}"`);
            });
            alert(`❌ 다운로드할 에피소드를 찾을 수 없습니다.`);
            return;
        }
        log(`📊 ${list.episodeLinks.length}개의 에피소드 발견 (첫화순으로 정렬)`);
        log(`📚 시리즈: ${list.seriesTitle}, 작가: ${list.seriesWriter}`);
        await downloadEpisodeList(list.episodeLinks, list.seriesTitle, list.seriesWriter);
    };

    // 에피소드 번호 추출 헬퍼 (숫자만)
    const parseEpisodeNumber = (ep) => {
        const digits = ep.episodeNum.replace(/[^\d]/g, '');
        if (digits) return parseInt(digits, 10);
        const m = ep.title.match(/(\d+)/);
        return m ? parseInt(m[1], 10) : null;
    };

    // N화 이후 다운로드
    const downloadEpisodesFromInput = async () => {
        const list = getEpisodeList();
        if (!list) {
            alert(`❌ 에피소드 목록을 찾을 수 없습니다.`);
            return;
        }
        const input = prompt(`다운로드할 시작 에피소드 번호를 입력하세요.\n(예: 10 → 10화부터 마지막까지)`, '');
        if (input === null) return;
        const startNum = parseInt(input.replace(/[^\d]/g, ''), 10);
        if (isNaN(startNum) || startNum < 1) {
            alert(`올바른 숫자를 입력하세요.`);
            return;
        }
        const filtered = list.episodeLinks.filter(ep => {
            const n = parseEpisodeNumber(ep);
            return n !== null && n >= startNum;
        });
        if (filtered.length === 0) {
            alert(`${startNum}화 이후의 에피소드가 없습니다.`);
            return;
        }
        log(`📊 ${list.episodeLinks.length}개 중 ${startNum}화 이후 ${filtered.length}개 에피소드`);
        log(`📚 시리즈: ${list.seriesTitle}, 작가: ${list.seriesWriter}`);
        await downloadEpisodeList(filtered, list.seriesTitle, list.seriesWriter);
    };

    // 에피소드 범위 다운로드
    const downloadEpisodesRangeInput = async () => {
        const list = getEpisodeList();
        if (!list) {
            alert(`❌ 에피소드 목록을 찾을 수 없습니다.`);
            return;
        }
        const startInput = prompt(`다운로드할 시작 에피소드 번호를 입력하세요.`, '');
        if (startInput === null) return;
        const startNum = parseInt(startInput.replace(/[^\d]/g, ''), 10);
        if (isNaN(startNum) || startNum < 1) {
            alert(`올바른 숫자를 입력하세요.`);
            return;
        }
        const endInput = prompt(`다운로드할 마지막 에피소드 번호를 입력하세요.`, '');
        if (endInput === null) return;
        const endNum = parseInt(endInput.replace(/[^\d]/g, ''), 10);
        if (isNaN(endNum) || endNum < startNum) {
            alert(`마지막 번호는 ${startNum} 이상이어야 합니다.`);
            return;
        }
        const filtered = list.episodeLinks.filter(ep => {
            const n = parseEpisodeNumber(ep);
            return n !== null && n >= startNum && n <= endNum;
        });
        if (filtered.length === 0) {
            alert(`${startNum}화~${endNum}화 범위의 에피소드가 없습니다.`);
            return;
        }
        log(`📊 ${list.episodeLinks.length}개 중 ${startNum}화~${endNum}화 범위 ${filtered.length}개 에피소드`);
        log(`📚 시리즈: ${list.seriesTitle}, 작가: ${list.seriesWriter}`);
        await downloadEpisodeList(filtered, list.seriesTitle, list.seriesWriter);
    };

    // 메뉴 명령 ID 관리 (중복 등록 방지)
    let menuCmdIds = [];

    // 안전한 메뉴 콜백 래퍼: 모든 에러를 LOG로 기록하고 로그 파일 다운로드
    const safeCallback = (fn) => async (...args) => {
        try {
            await fn(...args);
        } catch (e) {
            log('❌ 치명적 오류:', e);
            log('Stack:', e instanceof Error ? e.stack : '');
            log('로그를 저장합니다.');
            try { downloadLogFile(); } catch (_) {}
        }
    };

    // 메뉴 명령 등록 함수
    const registerMenuCommands = () => {
        // 이전 명령 해제
        menuCmdIds.forEach(id => GM_unregisterMenuCommand(id));
        menuCmdIds = [];

        const currentUrl = document.URL;
        const hostname = new URL(currentUrl).hostname;
        const pathMatch = currentUrl.match(/\/manhwa\/([^/]+)(?:\/(\d+))?/);
        const isEp = pathMatch && pathMatch[2];
        const isList = pathMatch && !pathMatch[2];

        log(`📍 현재 URL: ${currentUrl}`);
        log(`📍 호스트: ${hostname}, 목록: ${isList}, 에피소드: ${isEp}`);

        if (isEp) {
            menuCmdIds.push(GM_registerMenuCommand('📥 현재 에피소드 다운로드', safeCallback(downloadImages)));
            log('✅ 에피소드 페이지 메뉴 등록 완료');
        } else if (isList) {
            menuCmdIds.push(GM_registerMenuCommand('📥 전체 에피소드 다운로드', safeCallback(downloadAllEpisodesAsZips)));
            menuCmdIds.push(GM_registerMenuCommand('📥 N화 이후 다운로드', safeCallback(downloadEpisodesFromInput)));
            menuCmdIds.push(GM_registerMenuCommand('📥 범위 다운로드', safeCallback(downloadEpisodesRangeInput)));
            log('✅ 목록 페이지 메뉴 등록 완료 (3개)');
        } else {
            log('⏸️ 현재 페이지는 다운로드 대상이 아닙니다. 로그 저장 메뉴만 표시됩니다.');
        }

        // 항상 로그 저장 메뉴 등록
        menuCmdIds.push(GM_registerMenuCommand('📋 로그 저장', () => downloadLogFile()));
    };

    // 초기 메뉴 등록
    registerMenuCommands();

    // SPA 네비게이션 감지 (Next.js)
    const startNavObserver = () => {
        const observer = new MutationObserver(() => {
            registerMenuCommands();
        });
        observer.observe(document.body, { childList: true, subtree: true });
    };

    // 히스토리 API 변경 감지
    const origPushState = history.pushState;
    const origReplaceState = history.replaceState;
    history.pushState = function() {
        origPushState.apply(this, arguments);
        setTimeout(registerMenuCommands, 300);
    };
    history.replaceState = function() {
        origReplaceState.apply(this, arguments);
        setTimeout(registerMenuCommands, 300);
    };
    window.addEventListener('popstate', () => setTimeout(registerMenuCommands, 300));

    startNavObserver();

    log(`Ntoki Downloader v0.2.0 loaded`);
    log(`URL: ${document.URL}`);
    log(`User-Agent: ${navigator.userAgent}`);
    log(`지원 도메인: sbxh1/2/3.com, ntk01.com`);
    log('팝업 방식 (JavaScript 렌더링) + 오류 시 로그 자동 저장');
    const jszipCheck = typeof JSZip !== 'undefined';
    log(`JSZip 라이브러리: ${jszipCheck ? '✅ 로드됨' : '❌ 로드 안 됨'}`);
    if (!jszipCheck) {
        log('⚠️ JSZip이 로드되지 않았습니다. CDN 차단 또는 네트워크 문제일 수 있습니다.');
    }
})();
