GitHub | Full Issue Thread Markdown Exporter/Downloader

Exports a complete GitHub issue (title, body, all comments, metadata) to clean Markdown via API. Features draggable FAB, Shadow DOM settings panel, and configurable output.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         GitHub | Full Issue Thread Markdown Exporter/Downloader
// @namespace    https://greasyfork.org/en/users/1462137-piknockyou
// @version      2.1
// @author       Piknockyou
// @license      AGPL-3.0
// @description  Exports a complete GitHub issue (title, body, all comments, metadata) to clean Markdown via API. Features draggable FAB, Shadow DOM settings panel, and configurable output.
// @match        https://github.com/*/*/issues/*
// @icon         https://github.githubassets.com/favicons/favicon.svg
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_deleteValue
// @grant        GM_addStyle
// @run-at       document-idle
// ==/UserScript==

(function() {
    'use strict';

    // ═══════════════════════════════════════════════════════════════════════════
    // CONFIGURATION & DEFAULTS
    // ═══════════════════════════════════════════════════════════════════════════

    const CONFIG = {
        // ─── Content Options ─────────────────────────────────────────────────
        INCLUDE_HEADER: true,              // Frontmatter with repo/issue info
        INCLUDE_LABELS: true,              // Show labels in header
        INCLUDE_ASSIGNEES: true,           // Show assignees in header
        INCLUDE_MILESTONE: true,           // Show milestone in header
        INCLUDE_TIMESTAMPS: true,          // Show created/updated times
        INCLUDE_AUTHOR_INFO: true,         // Show @username for issue/comments
        INCLUDE_REACTIONS: true,           // Show reaction counts
        INCLUDE_COMMENT_IDS: false,        // Show comment IDs (for linking)

        // ─── References (Deduped, Recommended) ─────────────────────────────
        INCLUDE_REFERENCES_SECTION: true,  // Adds a dedicated "References" section (recommended)
        REF_INCLUDE_CROSS_REFERENCED: true,// Uses timeline "cross-referenced" events as source of truth
        REF_INCLUDE_SAME_REPO: true,       // Include references within the same repo
        REF_INCLUDE_CROSS_REPO: true,      // Include references from other repos
        REF_INCLUDE_ISSUES: true,          // Include referenced Issues
        REF_INCLUDE_PRS: true,             // Include referenced Pull Requests
        REF_INCLUDE_DUPLICATES: true,      // Derive duplicates from cross-references with label "duplicate"
        REF_INCLUDE_COMMITS: true,         // Include commit references (timeline "referenced" events)
        REF_FETCH_COMMIT_DETAILS: false,   // Fetch commit message/details (extra API calls) — default OFF

        // ─── Timeline (Verbose / Audit Log) ───────────────────────────────
        INCLUDE_TIMELINE_SECTION: false,   // Adds optional verbose chronological timeline section
        TL_INCLUDE_CROSS_REFERENCED: true, // Include cross-referenced items in timeline
        TL_INCLUDE_REFERENCED: true,       // Include commit references in timeline
        TL_INCLUDE_RENAMED: true,          // Include title changes (renamed)
        TL_INCLUDE_LABEL_CHANGES: true,    // Include labeled/unlabeled
        TL_INCLUDE_PROJECT_V2: false,      // Include project v2 events (often low-detail)
        TL_INCLUDE_SUBSCRIBE_EVENTS: false,// Include subscribed/unsubscribed (noise)
        TL_INCLUDE_MENTIONED_EVENTS: false,// Include mentioned (noise)
        TL_INCLUDE_MARKED_DUPLICATE_EVENTS: true, // Include marked_as_duplicate events
        TL_INCLUDE_CLOSED_EVENTS: true,    // Include closed/reopened events

        COLLAPSIBLE_LONG_COMMENTS: false,  // Wrap comments >500 chars in <details>
        LONG_COMMENT_THRESHOLD: 500,       // Character threshold for collapsible

        // ─── Formatting ──────────────────────────────────────────────────────
        COMMENT_SEPARATOR: '---',          // Separator between comments
        USE_HTML_DETAILS: true,            // Use <details> tags (vs blockquotes)

        // ─── API Settings ────────────────────────────────────────────────────
        API_BASE: 'https://api.github.com',
        PER_PAGE: 100,                     // Max allowed by GitHub
        FETCH_DELAY_MS: 100,               // Delay between paginated requests
        REQUEST_TIMEOUT_MS: 30000,         // 30 second timeout

        // ─── UI Settings ─────────────────────────────────────────────────────
        BUTTON_SIZE: 50,
        BUTTON_COLOR_READY: '#238636',     // GitHub green
        BUTTON_COLOR_LOADING: '#f59e0b',   // Amber
        BUTTON_COLOR_ERROR: '#da3633',     // GitHub red
        BUTTON_COLOR_SUCCESS: '#238636',   // GitHub green
        Z_INDEX: 2147483647,
        POSITION_STORAGE_KEY: 'gh_issue_export_pos',
        SETTINGS_STORAGE_KEY: 'gh_issue_export_config',
        ONBOARDING_DISMISSED_KEY: 'gh_issue_export_onboarding_dismissed',

        // ─── Debug ───────────────────────────────────────────────────────────
        DEBUG: false
    };

    // ═══════════════════════════════════════════════════════════════════════════
    // TRUSTED TYPES POLICY
    // ═══════════════════════════════════════════════════════════════════════════

    let trustedTypesPolicy = null;
    if (window.trustedTypes && window.trustedTypes.createPolicy) {
        try {
            trustedTypesPolicy = window.trustedTypes.createPolicy('gh-issue-export-policy', {
                createHTML: (string) => string
            });
        } catch (e) {
            // Policy may already exist or creation blocked
        }
    }

    /**
     * Safely set innerHTML with Trusted Types support
     */
    function safeSetInnerHTML(element, htmlString) {
        if (trustedTypesPolicy) {
            element.innerHTML = trustedTypesPolicy.createHTML(htmlString);
        } else {
            element.innerHTML = htmlString;
        }
    }

    // ═══════════════════════════════════════════════════════════════════════════
    // LOGGING
    // ═══════════════════════════════════════════════════════════════════════════

    const LOG_PREFIX = '[GH Issue Export]';

    const log = (...args) => {
        if (CONFIG.DEBUG) console.log(LOG_PREFIX, ...args);
    };

    const warn = (...args) => {
        console.warn(LOG_PREFIX, ...args);
    };

    const error = (...args) => {
        console.error(LOG_PREFIX, ...args);
    };

    // ═══════════════════════════════════════════════════════════════════════════
    // SETTINGS STORAGE
    // ═══════════════════════════════════════════════════════════════════════════

    const Settings = {
        _cache: null,

        load() {
            if (this._cache) return this._cache;
            try {
                const saved = GM_getValue(CONFIG.SETTINGS_STORAGE_KEY, null);
                if (saved) {
                    const overrides = typeof saved === 'string' ? JSON.parse(saved) : saved;
                    this._cache = { ...CONFIG, ...overrides };
                    log('Settings loaded:', this._cache);
                    return this._cache;
                }
            } catch (e) {
                warn('Failed to load settings:', e);
            }
            this._cache = { ...CONFIG };
            return this._cache;
        },

        save(key, value) {
            try {
                let overrides = {};
                const saved = GM_getValue(CONFIG.SETTINGS_STORAGE_KEY, null);
                if (saved) {
                    overrides = typeof saved === 'string' ? JSON.parse(saved) : saved;
                }
                overrides[key] = value;
                GM_setValue(CONFIG.SETTINGS_STORAGE_KEY, overrides);
                this._cache = null; // Invalidate cache
                log('Setting saved:', key, '=', value);
            } catch (e) {
                warn('Failed to save setting:', e);
            }
        },

        get(key) {
            const settings = this.load();
            return settings[key];
        },

        reset() {
            GM_deleteValue(CONFIG.SETTINGS_STORAGE_KEY);
            this._cache = null;
            log('Settings reset to defaults');
        }
    };

    // ═══════════════════════════════════════════════════════════════════════════
    // UTILITY FUNCTIONS
    // ═══════════════════════════════════════════════════════════════════════════

    const Utils = {
        /**
         * Extract owner, repo, and issue number from current URL
         */
        parseIssueUrl() {
            const match = window.location.pathname.match(/^\/([^\/]+)\/([^\/]+)\/issues\/(\d+)/);
            if (!match) return null;
            return {
                owner: match[1],
                repo: match[2],
                issueNumber: parseInt(match[3], 10)
            };
        },

        /**
         * Sanitize text for use as filename
         */
        sanitizeFilename(text, maxLen = 80) {
            if (!text) return 'github_issue';
            return text
                .replace(/[<>:"/\\|?*\x00-\x1f]/g, '')
                .replace(/\s+/g, '_')
                .replace(/_+/g, '_')
                .replace(/^_+|_+$/g, '')
                .trim()
                .substring(0, maxLen) || 'github_issue';
        },

        /**
         * Format ISO date to readable string
         */
        formatDate(isoString, includeTime = false) {
            if (!isoString) return '';
            try {
                const date = new Date(isoString);
                if (isNaN(date.getTime())) return isoString;

                const options = {
                    year: 'numeric',
                    month: 'short',
                    day: 'numeric'
                };

                if (includeTime) {
                    options.hour = '2-digit';
                    options.minute = '2-digit';
                }

                return date.toLocaleDateString('en-US', options);
            } catch (e) {
                return isoString;
            }
        },

        /**
         * Format reactions object to emoji string
         */
        formatReactions(reactions) {
            if (!reactions) return '';

            const emojiMap = {
                '+1': '👍',
                '-1': '👎',
                'laugh': '😄',
                'hooray': '🎉',
                'confused': '😕',
                'heart': '❤️',
                'rocket': '🚀',
                'eyes': '👀'
            };

            const parts = [];
            for (const [key, emoji] of Object.entries(emojiMap)) {
                const count = reactions[key];
                if (count && count > 0) {
                    parts.push(`${emoji} ${count}`);
                }
            }

            return parts.join(' · ');
        },

        /**
         * Escape HTML entities
         */
        escapeHtml(text) {
            if (!text) return '';
            return String(text)
                .replace(/&/g, '&amp;')
                .replace(/</g, '&lt;')
                .replace(/>/g, '&gt;')
                .replace(/"/g, '&quot;');
        },

        /**
         * Create delay promise
         */
        delay(ms) {
            return new Promise(resolve => setTimeout(resolve, ms));
        }
    };

    // ═══════════════════════════════════════════════════════════════════════════
    // GITHUB API CLIENT
    // ═══════════════════════════════════════════════════════════════════════════

    const GitHubAPI = {
        /**
         * Make authenticated API request
         */
        async request(endpoint, options = {}) {
            const url = endpoint.startsWith('http')
                ? endpoint
                : `${CONFIG.API_BASE}${endpoint}`;

            const headers = {
                'Accept': 'application/vnd.github+json',
                'X-GitHub-Api-Version': '2022-11-28',
                ...options.headers
            };

            log('API Request:', url);

            // Our internal controller (timeout + optional external abort bridging)
            const controller = new AbortController();
            const timeoutId = setTimeout(() => controller.abort(), CONFIG.REQUEST_TIMEOUT_MS);

            // If caller supplies an AbortSignal, bridge it into our controller
            // (so UI "Cancel export" actually aborts ongoing network requests)
            const externalSignal = options.signal;
            let onExternalAbort = null;
            if (externalSignal && typeof externalSignal.addEventListener === 'function') {
                if (externalSignal.aborted) {
                    controller.abort();
                } else {
                    onExternalAbort = () => controller.abort();
                    externalSignal.addEventListener('abort', onExternalAbort, { once: true });
                }
            }

            try {
                // Never pass caller's signal directly (we always use controller.signal)
                const { signal, ...restOptions } = options;

                const response = await fetch(url, {
                    ...restOptions,
                    headers,
                    signal: controller.signal
                });

                clearTimeout(timeoutId);

                if (onExternalAbort && externalSignal) {
                    try { externalSignal.removeEventListener('abort', onExternalAbort); } catch (e) {}
                }

                // Check rate limit
                const remaining = response.headers.get('X-RateLimit-Remaining');
                const resetTime = response.headers.get('X-RateLimit-Reset');

                if (remaining !== null) {
                    log(`Rate limit remaining: ${remaining}`);
                    if (parseInt(remaining, 10) < 5) {
                        const resetDate = new Date(parseInt(resetTime, 10) * 1000);
                        warn(`Rate limit nearly exhausted! Resets at ${resetDate.toLocaleTimeString()}`);
                    }
                }

                if (!response.ok) {
                    if (response.status === 403 && remaining === '0') {
                        throw new Error(`Rate limit exceeded. Resets at ${new Date(parseInt(resetTime, 10) * 1000).toLocaleTimeString()}`);
                    }
                    throw new Error(`API error: ${response.status} ${response.statusText}`);
                }

                return {
                    data: await response.json(),
                    headers: response.headers
                };
            } catch (err) {
                clearTimeout(timeoutId);

                if (onExternalAbort && externalSignal) {
                    try { externalSignal.removeEventListener('abort', onExternalAbort); } catch (e) {}
                }

                if (err.name === 'AbortError') {
                    // Prefer "Cancelled" if external aborted; otherwise timeout
                    if (externalSignal && externalSignal.aborted) {
                        throw new Error('Cancelled');
                    }
                    throw new Error('Request timeout');
                }
                throw err;
            }
        },

        /**
         * Fetch issue details
         */
        async fetchIssue(owner, repo, issueNumber, signal = null) {
            const endpoint = `/repos/${owner}/${repo}/issues/${issueNumber}`;
            const { data } = await this.request(endpoint, signal ? { signal } : {});
            return data;
        },

        /**
         * Fetch all comments with pagination
         */
        async fetchAllComments(owner, repo, issueNumber, onProgress = null, signal = null) {
            const allComments = [];
            let page = 1;
            let hasMore = true;

            while (hasMore) {
                const endpoint = `/repos/${owner}/${repo}/issues/${issueNumber}/comments?per_page=${CONFIG.PER_PAGE}&page=${page}`;
                const { data, headers } = await this.request(endpoint, signal ? { signal } : {});

                allComments.push(...data);

                if (onProgress) {
                    onProgress(allComments.length);
                }

                // Check for next page via Link header
                const linkHeader = headers.get('Link');
                hasMore = linkHeader && linkHeader.includes('rel="next"');

                if (hasMore) {
                    page++;
                    await Utils.delay(CONFIG.FETCH_DELAY_MS);
                }
            }

            log(`Fetched ${allComments.length} comments across ${page} pages`);
            return allComments;
        },

        /**
         * Fetch timeline events (optional)
         */
        async fetchTimeline(owner, repo, issueNumber, signal = null) {
            const allEvents = [];
            let page = 1;
            let hasMore = true;

            while (hasMore) {
                const endpoint = `/repos/${owner}/${repo}/issues/${issueNumber}/timeline?per_page=${CONFIG.PER_PAGE}&page=${page}`;
                try {
                    const { data, headers } = await this.request(endpoint, signal ? { signal } : {});
                    allEvents.push(...data);

                    const linkHeader = headers.get('Link');
                    hasMore = linkHeader && linkHeader.includes('rel="next"');

                    if (hasMore) {
                        page++;
                        await Utils.delay(CONFIG.FETCH_DELAY_MS);
                    }
                } catch (e) {
                    // Timeline API might not be available for all repos
                    warn('Timeline fetch failed:', e.message);
                    break;
                }
            }

            return allEvents;
        },

        /**
         * Fetch commit details (optional, extra API calls)
         */
        async fetchCommit(commitApiUrl, signal = null) {
            const { data } = await this.request(commitApiUrl, signal ? { signal } : {});
            return data;
        }
    };

    // ═══════════════════════════════════════════════════════════════════════════
    // MARKDOWN GENERATOR
    // ═══════════════════════════════════════════════════════════════════════════

    const MarkdownGenerator = {
        /**
         * Generate complete Markdown document.
         * @param {object} issue GitHub issue object (REST)
         * @param {Array<object>} comments Issue comments (REST)
         * @param {Array<object>} timeline Timeline events (REST)
         * @param {object} commitDetailsBySha Optional map: sha -> { html_url, message }
         * @param {object|null} cfg Settings
         */
        generate(issue, comments, timeline = [], commitDetailsBySha = {}, cfg = null) {
            const settings = cfg || Settings.load();
            const parts = [];

            // Title
            parts.push(`# ${issue.title}\n`);

            // Header/Frontmatter
            if (settings.INCLUDE_HEADER) {
                parts.push(this.generateHeader(issue, settings));
            }

            parts.push('---\n');

            // Issue Description
            parts.push('## Description\n');

            if (settings.INCLUDE_AUTHOR_INFO) {
                const authorLine = `*Opened by [@${issue.user.login}](${issue.user.html_url})*`;
                if (settings.INCLUDE_TIMESTAMPS && issue.created_at) {
                    parts.push(`${authorLine} *on ${Utils.formatDate(issue.created_at)}*\n`);
                } else {
                    parts.push(`${authorLine}\n`);
                }
            }

            // Issue body (already Markdown)
            if (issue.body && issue.body.trim()) {
                parts.push(`\n${issue.body.trim()}\n`);
            } else {
                parts.push('\n*No description provided.*\n');
            }

            // Issue reactions
            if (settings.INCLUDE_REACTIONS && issue.reactions) {
                const reactionStr = Utils.formatReactions(issue.reactions);
                if (reactionStr) {
                    parts.push(`\n${reactionStr}\n`);
                }
            }

            // References (high signal, deduped)
            if (settings.INCLUDE_REFERENCES_SECTION && Array.isArray(timeline) && timeline.length > 0) {
                const refMd = this.generateReferencesSection(issue, timeline, commitDetailsBySha, settings);
                if (refMd) parts.push(refMd);
            }

            // Timeline (verbose / audit)
            if (settings.INCLUDE_TIMELINE_SECTION && Array.isArray(timeline) && timeline.length > 0) {
                const tlMd = this.generateTimelineSection(issue, timeline, commitDetailsBySha, settings);
                if (tlMd) parts.push(tlMd);
            }

            // Comments
            if (comments.length > 0) {
                parts.push('\n---\n');
                parts.push(`## Comments (${comments.length})\n`);

                comments.forEach((comment, index) => {
                    parts.push(this.generateComment(comment, index + 1, settings));

                    // Only add separators BETWEEN comments (not after the last one).
                    // This avoids redundant separators right before the export footer.
                    if (index !== comments.length - 1) {
                        parts.push(`\n${settings.COMMENT_SEPARATOR}\n`);
                    }
                });
            }

            // Footer
            parts.push('\n---\n');
            parts.push(`*Exported on ${new Date().toLocaleString()} from [${issue.html_url}](${issue.html_url})*\n`);

            return parts.join('\n');
        },

        /**
         * Generate header/frontmatter section
         */
        generateHeader(issue, settings) {
            const lines = [];

            // Parse owner/repo from URL
            const urlMatch = issue.html_url.match(/github\.com\/([^\/]+)\/([^\/]+)/);
            const owner = urlMatch ? urlMatch[1] : '';
            const repo = urlMatch ? urlMatch[2] : '';

            lines.push(`> **Repository:** [${owner}/${repo}](https://github.com/${owner}/${repo})  `);
            lines.push(`> **Issue:** [#${issue.number}](${issue.html_url})  `);

            // State with appropriate styling
            const stateEmoji = issue.state === 'open' ? '🟢' : (issue.state_reason === 'completed' ? '🟣' : '🔴');
            const stateLabel = issue.state === 'closed'
                ? (issue.state_reason ? `closed (${issue.state_reason})` : 'closed')
                : 'open';
            lines.push(`> **State:** ${stateEmoji} ${stateLabel}  `);

            // Labels
            if (settings.INCLUDE_LABELS && issue.labels && issue.labels.length > 0) {
                const labelNames = issue.labels.map(l => `\`${l.name}\``).join(', ');
                lines.push(`> **Labels:** ${labelNames}  `);
            }

            // Assignees
            if (settings.INCLUDE_ASSIGNEES && issue.assignees && issue.assignees.length > 0) {
                const assigneeLinks = issue.assignees.map(a => `[@${a.login}](${a.html_url})`).join(', ');
                lines.push(`> **Assignees:** ${assigneeLinks}  `);
            }

            // Milestone
            if (settings.INCLUDE_MILESTONE && issue.milestone) {
                lines.push(`> **Milestone:** ${issue.milestone.title}  `);
            }

            // Timestamps
            if (settings.INCLUDE_TIMESTAMPS) {
                lines.push(`> **Created:** ${Utils.formatDate(issue.created_at)}  `);
                if (issue.updated_at && issue.updated_at !== issue.created_at) {
                    lines.push(`> **Updated:** ${Utils.formatDate(issue.updated_at)}  `);
                }
                if (issue.closed_at) {
                    lines.push(`> **Closed:** ${Utils.formatDate(issue.closed_at)}  `);
                }
            }

            lines.push('');
            return lines.join('\n');
        },

        /**
         * Generate a single comment block
         */
        generateComment(comment, index, settings) {
            const parts = [];

            // Comment header
            let header = `### Comment #${index}`;

            if (settings.INCLUDE_AUTHOR_INFO) {
                header += ` — [@${comment.user.login}](${comment.user.html_url})`;
            }

            if (settings.INCLUDE_TIMESTAMPS && comment.created_at) {
                header += ` · ${Utils.formatDate(comment.created_at)}`;
            }

            // Check if comment should be collapsible
            const isLong = settings.COLLAPSIBLE_LONG_COMMENTS &&
                          comment.body &&
                          comment.body.length > settings.LONG_COMMENT_THRESHOLD;

            if (isLong && settings.USE_HTML_DETAILS) {
                // Collapsible comment
                const preview = comment.body.substring(0, 100).replace(/\n/g, ' ') + '...';
                parts.push(`<details>`);
                parts.push(`<summary><strong>${header}</strong> — <em>${Utils.escapeHtml(preview)}</em></summary>\n`);
                parts.push(comment.body.trim());

                // Reactions inside details
                if (settings.INCLUDE_REACTIONS && comment.reactions) {
                    const reactionStr = Utils.formatReactions(comment.reactions);
                    if (reactionStr) {
                        parts.push(`\n${reactionStr}`);
                    }
                }

                parts.push(`\n</details>\n`);
            } else {
                // Normal comment
                parts.push(`${header}\n`);

                if (settings.INCLUDE_COMMENT_IDS) {
                    parts.push(`\`ID: ${comment.id}\`\n`);
                }

                parts.push(`\n${comment.body.trim()}\n`);

                // Reactions
                if (settings.INCLUDE_REACTIONS && comment.reactions) {
                    const reactionStr = Utils.formatReactions(comment.reactions);
                    if (reactionStr) {
                        parts.push(`\n${reactionStr}\n`);
                    }
                }
            }

            return parts.join('\n');
        },

        // ───────────────────────────────────────────────────────────────────
        // References (deduped, high signal)
        // ───────────────────────────────────────────────────────────────────

        _getCurrentRepoFullName(issue) {
            // Prefer parsing from html_url: https://github.com/owner/repo/issues/123
            const m = (issue?.html_url || '').match(/github\.com\/([^\/]+)\/([^\/]+)/);
            return m ? `${m[1]}/${m[2]}` : '';
        },

        _hasLabel(issueObj, labelName) {
            const labels = issueObj?.labels || [];
            const target = String(labelName || '').toLowerCase();
            return labels.some(l => String(l?.name || '').toLowerCase() === target);
        },

        _shortSha(sha) {
            const s = String(sha || '');
            return s.length > 7 ? s.slice(0, 7) : s;
        },

        _commitApiToHtmlUrl(commitApiUrl, sha) {
            // https://api.github.com/repos/OWNER/REPO/commits/SHA
            // -> https://github.com/OWNER/REPO/commit/SHA
            try {
                const m = String(commitApiUrl || '').match(/api\.github\.com\/repos\/([^\/]+)\/([^\/]+)\/commits\/([a-f0-9]+)/i);
                if (m) return `https://github.com/${m[1]}/${m[2]}/commit/${m[3]}`;
            } catch (e) {}
            if (sha) return `https://github.com/commit/${sha}`;
            return String(commitApiUrl || '');
        },

        generateReferencesSection(issue, timeline, commitDetailsBySha, settings) {
            if (!settings.REF_INCLUDE_CROSS_REFERENCED && !settings.REF_INCLUDE_COMMITS) return '';

            const currentRepo = this._getCurrentRepoFullName(issue);
            const lines = [];
            const sectionParts = [];

            // Collect cross references (issues + PRs)
            const crossRefs = (timeline || [])
                .filter(e => e && e.event === 'cross-referenced' && e.source && e.source.issue)
                .map(e => {
                    const src = e.source.issue;
                    const repoFull = src?.repository?.full_name || '';
                    const isSameRepo = currentRepo && repoFull ? (repoFull.toLowerCase() === currentRepo.toLowerCase()) : false;
                    const isPR = !!src?.pull_request;
                    const isDup = this._hasLabel(src, 'duplicate');

                    return {
                        created_at: e.created_at,
                        actor: e.actor?.login || '',
                        repoFull,
                        isSameRepo,
                        isPR,
                        isDup,
                        number: src.number,
                        title: src.title || '',
                        url: src.html_url || '',
                        state: src.state || '',
                        closed_at: src.closed_at || null,
                        merged_at: src.pull_request?.merged_at || null
                    };
                });

            const crossRefFiltered = [];
            if (settings.REF_INCLUDE_CROSS_REFERENCED) {
                for (const r of crossRefs) {
                    if (!r.url) continue;

                    // Same-repo vs cross-repo filters
                    if (r.isSameRepo && !settings.REF_INCLUDE_SAME_REPO) continue;
                    if (!r.isSameRepo && !settings.REF_INCLUDE_CROSS_REPO) continue;

                    // Issue vs PR filters
                    if (r.isPR && !settings.REF_INCLUDE_PRS) continue;
                    if (!r.isPR && !settings.REF_INCLUDE_ISSUES) continue;

                    // Duplicate filter (for "main" related lists we exclude duplicates if duplicates are enabled)
                    // We'll keep them and decide per-section below.
                    crossRefFiltered.push(r);
                }
            }

            // De-dupe by URL
            const dedupeByUrl = (arr) => {
                const seen = new Set();
                const out = [];
                for (const it of arr) {
                    if (!it.url) continue;
                    if (seen.has(it.url)) continue;
                    seen.add(it.url);
                    out.push(it);
                }
                return out;
            };

            const relatedPRs = dedupeByUrl(crossRefFiltered.filter(r => r.isPR && (!r.isDup || !settings.REF_INCLUDE_DUPLICATES)));
            const relatedIssues = dedupeByUrl(crossRefFiltered.filter(r => !r.isPR && (!r.isDup || !settings.REF_INCLUDE_DUPLICATES)));
            const duplicates = settings.REF_INCLUDE_DUPLICATES ? dedupeByUrl(crossRefFiltered.filter(r => r.isDup)) : [];

            // Commits (timeline referenced)
            const commits = [];
            if (settings.REF_INCLUDE_COMMITS) {
                const referencedEvents = (timeline || []).filter(e => e && e.event === 'referenced' && e.commit_id);
                const seenSha = new Set();
                for (const e of referencedEvents) {
                    const sha = String(e.commit_id || '');
                    if (!sha || seenSha.has(sha)) continue;
                    seenSha.add(sha);

                    const apiUrl = e.commit_url || '';
                    const htmlUrl = (commitDetailsBySha && commitDetailsBySha[sha] && commitDetailsBySha[sha].html_url)
                        ? commitDetailsBySha[sha].html_url
                        : this._commitApiToHtmlUrl(apiUrl, sha);

                    const fullMsg = (commitDetailsBySha && commitDetailsBySha[sha] && commitDetailsBySha[sha].message)
                        ? commitDetailsBySha[sha].message
                        : '';

                    const subject = fullMsg ? String(fullMsg).split('\n')[0].trim() : '';

                    commits.push({
                        sha,
                        htmlUrl,
                        subject
                    });
                }
            }

            // Build output only if we have something
            const hasAnything =
                relatedPRs.length || relatedIssues.length || duplicates.length || commits.length;

            if (!hasAnything) return '';

            sectionParts.push('\n---\n');
            sectionParts.push('## References\n');

            // Related PRs
            if (relatedPRs.length > 0) {
                sectionParts.push(`### Related Pull Requests (${relatedPRs.length})\n`);
                relatedPRs.forEach(pr => {
                    const repoPrefix = pr.repoFull ? `${pr.repoFull}#${pr.number}` : `#${pr.number}`;
                    const title = pr.title ? ` — ${pr.title}` : '';
                    const status = pr.merged_at ? ` (merged ${Utils.formatDate(pr.merged_at)})` : (pr.state ? ` (${pr.state})` : '');
                    sectionParts.push(`- [${repoPrefix}](${pr.url})${title}${status}`);
                });
                sectionParts.push('');
            }

            // Related Issues
            if (relatedIssues.length > 0) {
                sectionParts.push(`### Related Issues (${relatedIssues.length})\n`);
                relatedIssues.forEach(it => {
                    const repoPrefix = it.repoFull ? `${it.repoFull}#${it.number}` : `#${it.number}`;
                    const title = it.title ? ` — ${it.title}` : '';
                    const status = it.state ? ` (${it.state})` : '';
                    sectionParts.push(`- [${repoPrefix}](${it.url})${title}${status}`);
                });
                sectionParts.push('');
            }

            // Duplicates
            if (duplicates.length > 0) {
                sectionParts.push(`### Duplicates (${duplicates.length})\n`);
                duplicates.forEach(it => {
                    const repoPrefix = it.repoFull ? `${it.repoFull}#${it.number}` : `#${it.number}`;
                    const title = it.title ? ` — ${it.title}` : '';
                    const status = it.state ? ` (${it.state})` : '';
                    sectionParts.push(`- [${repoPrefix}](${it.url})${title}${status}`);
                });
                sectionParts.push('');
            }

            // Commits
            if (commits.length > 0) {
                sectionParts.push(`### Commits (${commits.length})\n`);
                commits.forEach(c => {
                    const short = this._shortSha(c.sha);
                    const subject = c.subject ? ` — ${c.subject}` : '';
                    sectionParts.push(`- [${short}](${c.htmlUrl})${subject}`);
                });
                sectionParts.push('');
            }

            lines.push(sectionParts.join('\n'));
            return lines.join('\n');
        },

        // ───────────────────────────────────────────────────────────────────
        // Timeline (verbose / audit log)
        // ───────────────────────────────────────────────────────────────────

        generateTimelineSection(issue, timeline, commitDetailsBySha, settings) {
            const events = (timeline || []).filter(Boolean);

            // Always exclude commented from timeline: we export comments separately
            const filtered = events.filter(e => e.event !== 'commented');

            const lines = [];
            lines.push('\n---\n');
            lines.push('## Timeline (Verbose)\n');

            const includeEvent = (ev) => {
                switch (ev.event) {
                    case 'cross-referenced': return !!settings.TL_INCLUDE_CROSS_REFERENCED;
                    case 'referenced': return !!settings.TL_INCLUDE_REFERENCED;
                    case 'renamed': return !!settings.TL_INCLUDE_RENAMED;
                    case 'labeled':
                    case 'unlabeled': return !!settings.TL_INCLUDE_LABEL_CHANGES;
                    case 'added_to_project_v2':
                    case 'project_v2_item_status_changed': return !!settings.TL_INCLUDE_PROJECT_V2;
                    case 'subscribed':
                    case 'unsubscribed': return !!settings.TL_INCLUDE_SUBSCRIBE_EVENTS;
                    case 'mentioned': return !!settings.TL_INCLUDE_MENTIONED_EVENTS;
                    case 'marked_as_duplicate': return !!settings.TL_INCLUDE_MARKED_DUPLICATE_EVENTS;
                    case 'closed':
                    case 'reopened': return !!settings.TL_INCLUDE_CLOSED_EVENTS;
                    default: return false;
                }
            };

            const fmtActor = (ev) => ev?.actor?.login ? `@${ev.actor.login}` : 'someone';
            const fmtDate = (ev) => ev?.created_at ? Utils.formatDate(ev.created_at) : '';

            const fmtCrossRef = (ev) => {
                const src = ev?.source?.issue;
                if (!src) return '🔗 Cross-referenced another issue/PR';
                const repoFull = src?.repository?.full_name || '';
                const num = src?.number != null ? `#${src.number}` : '';
                const title = src?.title ? ` — ${src.title}` : '';
                const isPR = !!src?.pull_request;
                const kind = isPR ? 'PR' : 'Issue';
                return `🔗 ${kind} referenced: ${repoFull}${num}${title} (${src?.html_url || ''})`;
            };

            const fmtReferencedCommit = (ev) => {
                const sha = String(ev?.commit_id || '');
                const short = sha ? (sha.length > 7 ? sha.slice(0, 7) : sha) : 'commit';
                const info = (sha && commitDetailsBySha && commitDetailsBySha[sha]) ? commitDetailsBySha[sha] : null;
                const htmlUrl = info?.html_url || this._commitApiToHtmlUrl(ev?.commit_url || '', sha);
                const subject = info?.message ? String(info.message).split('\n')[0].trim() : '';
                const extra = subject ? ` — ${subject}` : '';
                return `🔗 Commit referenced: ${short} (${htmlUrl})${extra}`;
            };

            const fmtEvent = (ev) => {
                const actor = fmtActor(ev);
                switch (ev.event) {
                    case 'renamed': {
                        const from = ev?.rename?.from || '';
                        const to = ev?.rename?.to || '';
                        return `✏️ ${actor} renamed: "${from}" → "${to}"`;
                    }
                    case 'labeled':
                        return `🏷️ ${actor} added label \`${ev.label?.name || 'unknown'}\``;
                    case 'unlabeled':
                        return `🏷️ ${actor} removed label \`${ev.label?.name || 'unknown'}\``;
                    case 'closed':
                        return `🔴 Closed by ${actor}`;
                    case 'reopened':
                        return `🟢 Reopened by ${actor}`;
                    case 'marked_as_duplicate':
                        return `🔁 ${actor} marked an issue as duplicate`;
                    case 'added_to_project_v2':
                        return `📌 ${actor} added to project (Project v2)`;
                    case 'project_v2_item_status_changed':
                        return `📌 ${actor} changed project status (Project v2)`;
                    case 'subscribed':
                        return `🔔 ${actor} subscribed`;
                    case 'unsubscribed':
                        return `🔕 ${actor} unsubscribed`;
                    case 'mentioned':
                        return `💬 ${actor} mentioned someone`;
                    case 'cross-referenced':
                        return fmtCrossRef(ev);
                    case 'referenced':
                        return fmtReferencedCommit(ev);
                    default:
                        return `${ev.event} by ${actor}`;
                }
            };

            const relevant = filtered.filter(includeEvent);
            if (relevant.length === 0) {
                lines.push('*No timeline events selected (or none available).*');
                lines.push('');
                return lines.join('\n');
            }

            relevant.forEach(ev => {
                const date = fmtDate(ev);
                const desc = fmtEvent(ev);
                lines.push(`- ${date}: ${desc}`);
            });

            lines.push('');
            return lines.join('\n');
        }
    };

    // ═══════════════════════════════════════════════════════════════════════════
    // SETTINGS PANEL (Shadow DOM Isolated)
    // ═══════════════════════════════════════════════════════════════════════════

    const PANEL_STYLES = `
        :host {
            all: initial;
        }
        * {
            box-sizing: border-box;
            user-select: none;
        }
        .settings-panel {
            position: fixed;
            background: #0d1117;
            border: 1px solid #30363d;
            border-radius: 12px;
            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif;
            font-size: 13px;
            color: #e6edf3;
            box-shadow: 0 8px 24px rgba(0,0,0,0.4);
            min-width: 280px;
            max-width: 340px;
            user-select: none;
            pointer-events: auto;
            z-index: 2147483647;
            display: flex;
            flex-direction: column;
            overflow: hidden;
            opacity: 0;
            transform: translateY(-8px);
            transition: opacity 0.15s ease-out, transform 0.15s ease-out;
        }
        .settings-panel.visible {
            opacity: 1;
            transform: translateY(0);
        }
        .settings-panel .header {
            flex-shrink: 0;
            padding: 12px 16px;
            background: #161b22;
            border-bottom: 1px solid #30363d;
            display: flex;
            align-items: center;
            justify-content: space-between;
        }
        .settings-panel .header-title {
            font-weight: 600;
            font-size: 14px;
            color: #f0f6fc;
            display: flex;
            align-items: center;
            gap: 8px;
        }
        .settings-panel .header-title .badge {
            background: #238636;
            color: #fff;
            font-size: 10px;
            font-weight: 600;
            padding: 2px 6px;
            border-radius: 4px;
        }
        .settings-panel .close-button {
            width: 24px;
            height: 24px;
            border: none;
            background: transparent;
            color: #8b949e;
            font-size: 18px;
            cursor: pointer;
            display: flex;
            align-items: center;
            justify-content: center;
            border-radius: 6px;
            padding: 0;
            transition: all 0.15s;
        }
        .settings-panel .close-button:hover {
            background: #30363d;
            color: #f0f6fc;
        }
        .settings-panel .content {
            flex: 1;
            min-height: 0;
            overflow-y: auto;
            overscroll-behavior: contain;
            padding: 16px;
        }
        .settings-panel .content::-webkit-scrollbar {
            width: 8px;
        }
        .settings-panel .content::-webkit-scrollbar-track {
            background: #21262d;
            border-radius: 4px;
        }
        .settings-panel .content::-webkit-scrollbar-thumb {
            background: #484f58;
            border-radius: 4px;
        }
        .settings-panel .group {
            margin-bottom: 16px;
        }
        .settings-panel .group:last-child {
            margin-bottom: 0;
        }
        .settings-panel .group-title {
            font-size: 11px;
            font-weight: 600;
            color: #8b949e;
            text-transform: uppercase;
            letter-spacing: 0.5px;
            margin-bottom: 8px;
            padding-bottom: 6px;
            border-bottom: 1px solid #21262d;
        }
        .settings-panel label {
            display: flex;
            align-items: center;
            gap: 10px;
            padding: 6px 0;
            cursor: pointer;
            transition: color 0.15s;
        }
        .settings-panel label:hover {
            color: #58a6ff;
        }
        .settings-panel label.indent {
            padding-left: 24px;
            font-size: 12px;
            color: #8b949e;
        }
        .settings-panel label.disabled {
            opacity: 0.4;
            pointer-events: none;
        }
        .settings-panel label[data-tooltip] {
            position: relative;
        }
        .settings-panel label[data-tooltip]::after {
            content: attr(data-tooltip);
            position: absolute;
            visibility: hidden;
            opacity: 0;
            background: #161b22;
            color: #e6edf3;
            padding: 8px 12px;
            border-radius: 6px;
            font-size: 11px;
            line-height: 1.4;
            max-width: 220px;
            width: max-content;
            white-space: pre-line;
            z-index: 100;
            left: 0;
            bottom: calc(100% + 6px);
            box-shadow: 0 4px 12px rgba(0,0,0,0.4);
            border: 1px solid #30363d;
            pointer-events: none;
            transition: opacity 0.15s ease-in-out, visibility 0.15s ease-in-out;
            transition-delay: 0s;
        }
        .settings-panel label[data-tooltip]:hover::after {
            visibility: visible;
            opacity: 1;
            transition-delay: 0.8s;
        }
        .settings-panel input[type="checkbox"] {
            width: 16px;
            height: 16px;
            cursor: pointer;
            accent-color: #238636;
            flex-shrink: 0;
        }
        .settings-panel .footer {
            flex-shrink: 0;
            padding: 12px 16px;
            border-top: 1px solid #30363d;
            display: flex;
            justify-content: space-between;
            align-items: center;
            gap: 8px;
            background: #0d1117;
        }
        .settings-panel .btn {
            border: 1px solid #30363d;
            padding: 6px 14px;
            border-radius: 6px;
            font-size: 12px;
            font-weight: 500;
            cursor: pointer;
            transition: all 0.15s;
        }
        .settings-panel .btn-secondary {
            background: transparent;
            color: #8b949e;
        }
        .settings-panel .btn-secondary:hover {
            background: #21262d;
            color: #f0f6fc;
            border-color: #484f58;
        }
        .settings-panel .btn-primary {
            background: #238636;
            color: #fff;
            border-color: #238636;
        }
        .settings-panel .btn-primary:hover {
            background: #2ea043;
            border-color: #2ea043;
        }
        .settings-panel .kofi-btn {
            display: flex;
            align-items: center;
            gap: 6px;
            background: #238636;
            color: #fff;
            border: 1px solid #238636;
            padding: 6px 12px;
            border-radius: 6px;
            font-size: 12px;
            font-weight: 500;
            cursor: pointer;
            text-decoration: none;
            transition: all 0.15s;
        }
        .settings-panel .kofi-btn:hover {
            background: #2ea043;
            border-color: #2ea043;
        }

    `;

    const FLAG_METADATA = {
        // Content
        INCLUDE_HEADER: { label: 'Include Header', group: 'Content', tooltip: 'Show repository, issue number, state, labels at top' },
        INCLUDE_LABELS: { label: 'Show Labels', group: 'Content', indent: true },
        INCLUDE_ASSIGNEES: { label: 'Show Assignees', group: 'Content', indent: true },
        INCLUDE_MILESTONE: { label: 'Show Milestone', group: 'Content', indent: true },
        INCLUDE_AUTHOR_INFO: { label: 'Show Authors', group: 'Content', tooltip: 'Show @username for issue and comments' },
        INCLUDE_REACTIONS: { label: 'Show Reactions', group: 'Content', tooltip: 'Show 👍 ❤️ 🎉 counts' },

        // Timestamps
        INCLUDE_TIMESTAMPS: { label: 'Show Timestamps', group: 'Timestamps' },
        INCLUDE_COMMENT_IDS: { label: 'Show Comment IDs', group: 'Timestamps', tooltip: 'Useful for linking to specific comments' },

        // References (deduped)
        INCLUDE_REFERENCES_SECTION: { label: 'Include References Section', group: 'References', tooltip: 'Adds a deduped, high-signal References section (recommended).' },
        REF_INCLUDE_CROSS_REFERENCED: { label: 'Include Cross References', group: 'References', indent: true, tooltip: 'Use timeline "cross-referenced" events to list related issues/PRs.' },
        REF_INCLUDE_SAME_REPO: { label: 'Include Same-Repo References', group: 'References', indent: true },
        REF_INCLUDE_CROSS_REPO: { label: 'Include Cross-Repo References', group: 'References', indent: true },
        REF_INCLUDE_ISSUES: { label: 'Include Issues', group: 'References', indent: true },
        REF_INCLUDE_PRS: { label: 'Include Pull Requests', group: 'References', indent: true },
        REF_INCLUDE_DUPLICATES: { label: 'Include Duplicates (Derived)', group: 'References', indent: true, tooltip: 'Duplicates are derived from cross-referenced issues labeled "duplicate".' },
        REF_INCLUDE_COMMITS: { label: 'Include Commit References', group: 'References', indent: true, tooltip: 'Includes commit SHAs from timeline "referenced" events.' },
        REF_FETCH_COMMIT_DETAILS: { label: 'Fetch Commit Details (slower)', group: 'References', indent: true, tooltip: 'Fetches commit messages via commit_url (extra API calls). Default OFF.' },

        // Timeline (verbose)
        INCLUDE_TIMELINE_SECTION: { label: 'Include Timeline (Verbose)', group: 'Timeline', tooltip: 'Adds a chronological audit log. Usually noisier than References.' },
        TL_INCLUDE_CROSS_REFERENCED: { label: 'Cross References', group: 'Timeline', indent: true },
        TL_INCLUDE_REFERENCED: { label: 'Commit References', group: 'Timeline', indent: true },
        TL_INCLUDE_RENAMED: { label: 'Title Changes', group: 'Timeline', indent: true },
        TL_INCLUDE_LABEL_CHANGES: { label: 'Label Changes', group: 'Timeline', indent: true },
        TL_INCLUDE_PROJECT_V2: { label: 'Project v2 Events', group: 'Timeline', indent: true },
        TL_INCLUDE_SUBSCRIBE_EVENTS: { label: 'Subscribe Events', group: 'Timeline', indent: true },
        TL_INCLUDE_MENTIONED_EVENTS: { label: 'Mentioned Events', group: 'Timeline', indent: true },
        TL_INCLUDE_MARKED_DUPLICATE_EVENTS: { label: 'Marked as Duplicate', group: 'Timeline', indent: true },
        TL_INCLUDE_CLOSED_EVENTS: { label: 'Closed/Reopened', group: 'Timeline', indent: true },

        // Formatting
        COLLAPSIBLE_LONG_COMMENTS: { label: 'Collapse Long Comments', group: 'Formatting', tooltip: 'Wrap comments > 500 chars in <details>' }
    };

    const FLAG_GROUP_ORDER = ['Content', 'Timestamps', 'References', 'Timeline', 'Formatting'];

    const SettingsPanel = {
        shadowHost: null,
        shadowRoot: null,
        panel: null,
        isOpen: false,
        checkboxRefs: {},
        closeHandler: null,
        escapeHandler: null,

        init() {
            if (this.shadowHost) return;

            this.shadowHost = document.createElement('div');
            this.shadowHost.id = 'gh-issue-export-settings-host';
            Object.assign(this.shadowHost.style, {
                position: 'fixed',
                top: '0',
                left: '0',
                width: '0',
                height: '0',
                overflow: 'visible',
                zIndex: CONFIG.Z_INDEX.toString(),
                pointerEvents: 'none'
            });

            this.shadowRoot = this.shadowHost.attachShadow({ mode: 'closed' });

            const style = document.createElement('style');
            style.textContent = PANEL_STYLES;
            this.shadowRoot.appendChild(style);

            document.body.appendChild(this.shadowHost);
        },

        buildPanel() {
            if (this.panel) {
                this.panel.remove();
                this.panel = null;
            }
            this.checkboxRefs = {};

            this.panel = document.createElement('div');
            this.panel.className = 'settings-panel';

            // Header
            const header = document.createElement('div');
            header.className = 'header';

            const headerTitle = document.createElement('div');
            headerTitle.className = 'header-title';
            headerTitle.innerHTML = `<span>Export Settings</span><span class="badge">GitHub Issues</span>`;

            const closeButton = document.createElement('button');
            closeButton.className = 'close-button';
            closeButton.textContent = '✕';
            closeButton.addEventListener('click', (e) => {
                e.stopPropagation();
                this.hide();
            });

            header.appendChild(headerTitle);
            header.appendChild(closeButton);
            this.panel.appendChild(header);

            // Content
            const content = document.createElement('div');
            content.className = 'content';

            // Group flags
            const groupedFlags = {};
            Object.entries(FLAG_METADATA).forEach(([key, meta]) => {
                const group = meta.group || 'Other';
                if (!groupedFlags[group]) groupedFlags[group] = [];
                groupedFlags[group].push({ name: key, ...meta });
            });

            // Render groups
            FLAG_GROUP_ORDER.forEach(groupName => {
                const groupFlags = groupedFlags[groupName];
                if (!groupFlags || groupFlags.length === 0) return;

                const groupDiv = document.createElement('div');
                groupDiv.className = 'group';

                const groupTitle = document.createElement('div');
                groupTitle.className = 'group-title';
                groupTitle.textContent = groupName;
                groupDiv.appendChild(groupTitle);

                groupFlags.forEach(flag => {
                    const label = document.createElement('label');
                    if (flag.indent) label.classList.add('indent');
                    if (flag.tooltip) label.setAttribute('data-tooltip', flag.tooltip);

                    const checkbox = document.createElement('input');
                    checkbox.type = 'checkbox';
                    checkbox.id = `setting-${flag.name}`;
                    checkbox.checked = Settings.get(flag.name);

                    checkbox.addEventListener('change', (e) => {
                        e.stopPropagation();
                        Settings.save(flag.name, checkbox.checked);
                        this.updateDependentStates();
                    });

                    const text = document.createTextNode(flag.label);
                    label.appendChild(checkbox);
                    label.appendChild(text);
                    groupDiv.appendChild(label);

                    this.checkboxRefs[flag.name] = { checkbox, label };
                });

                content.appendChild(groupDiv);
            });

            this.panel.appendChild(content);

            // Footer
            const footer = document.createElement('div');
            footer.className = 'footer';

            const resetButton = document.createElement('button');
            resetButton.className = 'btn btn-secondary';
            resetButton.textContent = 'Reset';
            resetButton.addEventListener('click', (e) => {
                e.stopPropagation();
                Settings.reset();
                this.refreshCheckboxes();
            });

            // Ko-Fi donation button
            const kofiLink = document.createElement('a');
            kofiLink.className = 'kofi-btn';
            kofiLink.href = 'https://ko-fi.com/piknockyou';
            kofiLink.target = '_blank';
            kofiLink.rel = 'noopener noreferrer';
            kofiLink.title = 'Support this script on Ko-Fi';
            kofiLink.textContent = '☕ Support';
            kofiLink.addEventListener('click', (e) => {
                e.stopPropagation();
            });

            footer.appendChild(kofiLink);
            footer.appendChild(resetButton);
            this.panel.appendChild(footer);

            // Block events
            this.panel.addEventListener('mousedown', (e) => e.stopPropagation());
            this.panel.addEventListener('mouseup', (e) => e.stopPropagation());
            this.panel.addEventListener('click', (e) => e.stopPropagation());

            // Scroll containment - prevent page scroll when at panel boundaries
            this.panel.addEventListener('wheel', (e) => {
                const scrollable = e.target.closest('.content');
                if (scrollable) {
                    const isScrollable = scrollable.scrollHeight > scrollable.clientHeight;
                    const atTop = scrollable.scrollTop === 0;
                    const atBottom = scrollable.scrollTop + scrollable.clientHeight >= scrollable.scrollHeight - 1;

                    if (isScrollable) {
                        if ((e.deltaY < 0 && atTop) || (e.deltaY > 0 && atBottom)) {
                            e.preventDefault();
                        }
                    }
                }
                e.stopPropagation();
            }, { passive: false });

            this.shadowRoot.appendChild(this.panel);
            this.updateDependentStates();

            // Trigger animation
            requestAnimationFrame(() => {
                requestAnimationFrame(() => {
                    this.panel.classList.add('visible');
                });
            });

            return this.panel;
        },

        /**
         * Calculate the best position for the panel that keeps it fully visible.
         * Hard rule: never go off-screen (top/bottom/left/right).
         * Behavior: prefer the placement that provides the MOST vertical space.
         */
        calculateBestPosition(anchorRect, panelWidth) {
            const vw = window.innerWidth;
            const vh = window.innerHeight;
            const margin = 8;
            const minHeight = 150;

            // Available height within viewport bounds
            const fullHeight = vh - 2 * margin;

            // Available height above/below button while preserving margins
            const availAbove = anchorRect.top - 2 * margin;
            const availBelow = vh - anchorRect.bottom - 2 * margin;

            // Available width left/right of button while preserving margins
            const availRight = vw - anchorRect.right - 2 * margin;
            const availLeft = anchorRect.left - 2 * margin;

            const clampLeft = (left) => Math.max(margin, Math.min(left, vw - panelWidth - margin));

            // Align right edge of panel with right edge of anchor, clamped
            const alignedLeft = clampLeft(anchorRect.right - panelWidth);

            const candidates = [];

            // Right of button (full height)
            if (availRight >= panelWidth && fullHeight >= minHeight) {
                candidates.push({
                    kind: 'right',
                    left: anchorRect.right + margin,
                    top: margin,
                    height: fullHeight,
                    pref: 2
                });
            }

            // Left of button (full height)
            if (availLeft >= panelWidth && fullHeight >= minHeight) {
                candidates.push({
                    kind: 'left',
                    left: anchorRect.left - margin - panelWidth,
                    top: margin,
                    height: fullHeight,
                    pref: 1
                });
            }

            // Below button (max available below)
            if (availBelow >= minHeight) {
                candidates.push({
                    kind: 'below',
                    left: alignedLeft,
                    top: anchorRect.bottom + margin,
                    height: availBelow,
                    pref: 4
                });
            }

            // Above button (max available above)
            if (availAbove >= minHeight) {
                candidates.push({
                    kind: 'above',
                    left: alignedLeft,
                    top: margin,
                    height: availAbove,
                    pref: 3
                });
            }

            // Fallback: overlay (full height)
            if (candidates.length === 0) {
                candidates.push({
                    kind: 'overlay',
                    left: clampLeft((vw - panelWidth) / 2),
                    top: margin,
                    height: Math.max(minHeight, fullHeight),
                    pref: 0
                });
            }

            // Score: prioritize height, then preference
            candidates.sort((a, b) => {
                if (a.height !== b.height) return b.height - a.height;
                return b.pref - a.pref;
            });

            return candidates[0];
        },

        updateDependentStates() {
            const settings = Settings.load();

            // Header sub-options depend on INCLUDE_HEADER
            ['INCLUDE_LABELS', 'INCLUDE_ASSIGNEES', 'INCLUDE_MILESTONE'].forEach(key => {
                if (this.checkboxRefs[key]) {
                    const enabled = settings.INCLUDE_HEADER;
                    this.checkboxRefs[key].label.classList.toggle('disabled', !enabled);
                    this.checkboxRefs[key].checkbox.disabled = !enabled;
                }
            });

            // References sub-options depend on INCLUDE_REFERENCES_SECTION
            const refSub = [
                'REF_INCLUDE_CROSS_REFERENCED',
                'REF_INCLUDE_SAME_REPO',
                'REF_INCLUDE_CROSS_REPO',
                'REF_INCLUDE_ISSUES',
                'REF_INCLUDE_PRS',
                'REF_INCLUDE_DUPLICATES',
                'REF_INCLUDE_COMMITS',
                'REF_FETCH_COMMIT_DETAILS'
            ];
            refSub.forEach(key => {
                if (this.checkboxRefs[key]) {
                    const enabled = settings.INCLUDE_REFERENCES_SECTION;
                    this.checkboxRefs[key].label.classList.toggle('disabled', !enabled);
                    this.checkboxRefs[key].checkbox.disabled = !enabled;
                }
            });

            // Cross-reference filters depend on REF_INCLUDE_CROSS_REFERENCED
            const crossRefSub = [
                'REF_INCLUDE_SAME_REPO',
                'REF_INCLUDE_CROSS_REPO',
                'REF_INCLUDE_ISSUES',
                'REF_INCLUDE_PRS',
                'REF_INCLUDE_DUPLICATES'
            ];
            crossRefSub.forEach(key => {
                if (this.checkboxRefs[key]) {
                    const enabled = settings.INCLUDE_REFERENCES_SECTION && settings.REF_INCLUDE_CROSS_REFERENCED;
                    this.checkboxRefs[key].label.classList.toggle('disabled', !enabled);
                    this.checkboxRefs[key].checkbox.disabled = !enabled;
                }
            });

            // Commit details depends on REF_INCLUDE_COMMITS
            if (this.checkboxRefs.REF_FETCH_COMMIT_DETAILS) {
                const enabled = settings.INCLUDE_REFERENCES_SECTION && settings.REF_INCLUDE_COMMITS;
                this.checkboxRefs.REF_FETCH_COMMIT_DETAILS.label.classList.toggle('disabled', !enabled);
                this.checkboxRefs.REF_FETCH_COMMIT_DETAILS.checkbox.disabled = !enabled;
            }

            // Timeline sub-options depend on INCLUDE_TIMELINE_SECTION
            const tlSub = [
                'TL_INCLUDE_CROSS_REFERENCED',
                'TL_INCLUDE_REFERENCED',
                'TL_INCLUDE_RENAMED',
                'TL_INCLUDE_LABEL_CHANGES',
                'TL_INCLUDE_PROJECT_V2',
                'TL_INCLUDE_SUBSCRIBE_EVENTS',
                'TL_INCLUDE_MENTIONED_EVENTS',
                'TL_INCLUDE_MARKED_DUPLICATE_EVENTS',
                'TL_INCLUDE_CLOSED_EVENTS'
            ];
            tlSub.forEach(key => {
                if (this.checkboxRefs[key]) {
                    const enabled = settings.INCLUDE_TIMELINE_SECTION;
                    this.checkboxRefs[key].label.classList.toggle('disabled', !enabled);
                    this.checkboxRefs[key].checkbox.disabled = !enabled;
                }
            });
        },

        refreshCheckboxes() {
            const settings = Settings.load();
            Object.keys(this.checkboxRefs).forEach(key => {
                const ref = this.checkboxRefs[key];
                if (ref && ref.checkbox) {
                    ref.checkbox.checked = settings[key];
                }
            });
            this.updateDependentStates();
        },

        show(anchorElement) {
            if (!this.shadowHost) this.init();
            if (!document.body.contains(this.shadowHost)) {
                document.body.appendChild(this.shadowHost);
            }

            this.buildPanel();

            if (!this.panel) return;

            // Get button position and calculate best panel position
            const rect = anchorElement.getBoundingClientRect();
            const vw = window.innerWidth;
            const vh = window.innerHeight;
            const margin = 8;

            // Force a deterministic width that can never overflow the viewport
            const panelWidth = Math.max(220, Math.min(340, vw - 2 * margin));

            const pos = this.calculateBestPosition(rect, panelWidth);
            log('Best position calculated:', pos);

            // Apply size - width fixed, height auto with max
            this.panel.style.width = `${panelWidth}px`;
            this.panel.style.maxWidth = `${panelWidth}px`;
            this.panel.style.minWidth = '0px';
            this.panel.style.height = 'auto';
            this.panel.style.bottom = 'auto';

            // Apply horizontal position
            this.panel.style.left = `${Math.max(margin, Math.min(pos.left, vw - panelWidth - margin))}px`;
            this.panel.style.right = 'auto';

            // Apply vertical position and max-height based on placement kind
            if (pos.kind === 'below') {
                // Below button: anchor to button bottom, grow downward up to viewport bottom
                this.panel.style.top = `${rect.bottom + margin}px`;
                this.panel.style.maxHeight = `${vh - rect.bottom - 2 * margin}px`;
            } else if (pos.kind === 'above') {
                // Above button: anchor to button top, grow upward
                // Use bottom positioning so panel grows upward from anchor point
                this.panel.style.top = 'auto';
                this.panel.style.bottom = `${vh - rect.top + margin}px`;
                this.panel.style.maxHeight = `${rect.top - 2 * margin}px`;
            } else if (pos.kind === 'left' || pos.kind === 'right') {
                // Side placement: align top with button, but clamp to viewport
                // Max height is full viewport minus margins
                const maxH = vh - 2 * margin;
                this.panel.style.maxHeight = `${maxH}px`;

                // Start aligned with button top
                let top = rect.top;

                // Measure panel height after maxHeight is set
                this.panel.style.top = `${top}px`;
                const panelHeight = this.panel.getBoundingClientRect().height;

                // If panel would overflow bottom, shift it up
                if (top + panelHeight + margin > vh) {
                    top = vh - panelHeight - margin;
                }

                // Clamp to not go above viewport
                top = Math.max(margin, top);

                this.panel.style.top = `${top}px`;
            } else {
                // Overlay fallback: centered, full available height
                this.panel.style.top = `${margin}px`;
                this.panel.style.maxHeight = `${vh - 2 * margin}px`;
            }

            this.isOpen = true;

            // Close handlers
            if (this.closeHandler) {
                document.removeEventListener('mousedown', this.closeHandler, true);
            }

            this.closeHandler = (e) => {
                if (!this.isOpen) return;
                const path = e.composedPath();
                if (path.includes(this.shadowHost)) return;
                if (e.target === anchorElement || anchorElement.contains(e.target)) return;
                this.hide();
            };

            this.escapeHandler = (e) => {
                if (e.key === 'Escape' && this.isOpen) {
                    this.hide();
                }
            };

            setTimeout(() => {
                document.addEventListener('mousedown', this.closeHandler, true);
                document.addEventListener('keydown', this.escapeHandler, true);
            }, 50);
        },

        hide() {
            if (this.panel) {
                // Animate out
                this.panel.classList.remove('visible');
                setTimeout(() => {
                    if (this.panel) {
                        this.panel.remove();
                        this.panel = null;
                    }
                }, 150);
            }
            this.isOpen = false;
            this.checkboxRefs = {};

            if (this.closeHandler) {
                document.removeEventListener('mousedown', this.closeHandler, true);
                this.closeHandler = null;
            }
            if (this.escapeHandler) {
                document.removeEventListener('keydown', this.escapeHandler, true);
                this.escapeHandler = null;
            }
        },

        toggle(anchorElement) {
            if (this.isOpen) {
                this.hide();
            } else {
                this.show(anchorElement);
            }
        }
    };

    // ═══════════════════════════════════════════════════════════════════════════
    // CUSTOM TOOLTIP (Material Design style, viewport-aware positioning)
    // ═══════════════════════════════════════════════════════════════════════════

    const Tooltip = {
        element: null,
        currentTarget: null,
        showTimeout: null,

        init() {
            if (this.element) return;

            this.element = document.createElement('div');
            this.element.id = 'gh-issue-export-tooltip';
            Object.assign(this.element.style, {
                position: 'fixed',
                background: '#161b22',
                border: '1px solid #30363d',
                borderRadius: '6px',
                padding: '8px 12px',
                fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
                fontSize: '12px',
                fontWeight: '500',
                color: '#e6edf3',
                boxShadow: '0 4px 12px rgba(0,0,0,0.4)',
                zIndex: (CONFIG.Z_INDEX + 1).toString(), // Above FAB
                pointerEvents: 'none',
                opacity: '0',
                visibility: 'hidden',
                transition: 'opacity 0.15s ease, visibility 0.15s ease',
                whiteSpace: 'pre-line',
                textAlign: 'center',
                lineHeight: '1.4',
                maxWidth: '280px'
            });

            document.body.appendChild(this.element);
        },

        /**
         * Calculate best position for tooltip that keeps it fully visible
         */
        calculatePosition(targetRect) {
            const margin = 8;
            const gap = 10; // Gap between target and tooltip

            // Measure tooltip (temporarily show off-screen to get dimensions)
            this.element.style.visibility = 'hidden';
            this.element.style.opacity = '0';
            this.element.style.left = '-9999px';
            this.element.style.top = '-9999px';

            const tooltipRect = this.element.getBoundingClientRect();
            const tooltipWidth = tooltipRect.width;
            const tooltipHeight = tooltipRect.height;

            const vw = window.innerWidth;
            const vh = window.innerHeight;

            // Available space in each direction
            const spaceAbove = targetRect.top - margin;
            const spaceBelow = vh - targetRect.bottom - margin;
            const spaceLeft = targetRect.left - margin;
            const spaceRight = vw - targetRect.right - margin;

            let left, top;

            // Prefer positioning above, then below, then sides
            if (spaceAbove >= tooltipHeight + gap) {
                // Above target
                top = targetRect.top - tooltipHeight - gap;
                left = targetRect.left + (targetRect.width / 2) - (tooltipWidth / 2);
            } else if (spaceBelow >= tooltipHeight + gap) {
                // Below target
                top = targetRect.bottom + gap;
                left = targetRect.left + (targetRect.width / 2) - (tooltipWidth / 2);
            } else if (spaceRight >= tooltipWidth + gap) {
                // Right of target
                left = targetRect.right + gap;
                top = targetRect.top + (targetRect.height / 2) - (tooltipHeight / 2);
            } else if (spaceLeft >= tooltipWidth + gap) {
                // Left of target
                left = targetRect.left - tooltipWidth - gap;
                top = targetRect.top + (targetRect.height / 2) - (tooltipHeight / 2);
            } else {
                // Fallback: center in viewport
                left = (vw - tooltipWidth) / 2;
                top = Math.max(margin, targetRect.top - tooltipHeight - gap);
            }

            // Clamp to viewport
            left = Math.max(margin, Math.min(left, vw - tooltipWidth - margin));
            top = Math.max(margin, Math.min(top, vh - tooltipHeight - margin));

            return { left, top };
        },

        show(target, text) {
            if (!this.element) this.init();

            this.currentTarget = target;
            this.element.textContent = text;

            const rect = target.getBoundingClientRect();
            const pos = this.calculatePosition(rect);

            this.element.style.left = `${pos.left}px`;
            this.element.style.top = `${pos.top}px`;
            this.element.style.visibility = 'visible';
            this.element.style.opacity = '1';
        },

        hide() {
            if (this.showTimeout) {
                clearTimeout(this.showTimeout);
                this.showTimeout = null;
            }
            if (this.element) {
                this.element.style.opacity = '0';
                this.element.style.visibility = 'hidden';
            }
            this.currentTarget = null;
        },

        scheduleShow(target, text, delay = 500) {
            this.hide();
            this.showTimeout = setTimeout(() => {
                this.show(target, text);
            }, delay);
        },

        destroy() {
            this.hide();
            if (this.element) {
                this.element.remove();
                this.element = null;
            }
        }
    };

    // ═══════════════════════════════════════════════════════════════════════════
    // ONBOARDING BANNER (First-run hint)
    // ═══════════════════════════════════════════════════════════════════════════

    const OnboardingBanner = {
        element: null,

        isDismissed() {
            return GM_getValue(CONFIG.ONBOARDING_DISMISSED_KEY, false) === true;
        },

        dismiss(permanent = false) {
            if (permanent) {
                GM_setValue(CONFIG.ONBOARDING_DISMISSED_KEY, true);
            }
            if (this.element) {
                this.element.style.opacity = '0';
                this.element.style.transform = 'translateY(10px)';
                setTimeout(() => {
                    this.element?.remove();
                    this.element = null;
                }, 200);
            }
        },

        show(anchorEl) {
            if (this.isDismissed()) return;
            if (this.element) return;
            if (!anchorEl) return;

            const banner = document.createElement('div');
            banner.id = 'gh-issue-export-onboarding';

            Object.assign(banner.style, {
                position: 'fixed',
                background: 'linear-gradient(135deg, #238636 0%, #2ea043 100%)',
                color: '#fff',
                padding: '16px 20px',
                borderRadius: '12px',
                fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
                fontSize: '13px',
                zIndex: (CONFIG.Z_INDEX - 1).toString(),
                boxShadow: '0 8px 32px rgba(0,0,0,0.35)',
                lineHeight: '1.6',
                maxWidth: '320px',
                opacity: '0',
                transform: 'translateY(10px)',
                transition: 'opacity 0.3s ease-out, transform 0.3s ease-out'
            });

            banner.innerHTML = `
                <div style="font-weight: 700; font-size: 14px; margin-bottom: 12px; display: flex; align-items: center; gap: 8px;">
                    <span style="font-size: 18px;">📥</span>
                    <span>GitHub Issue Exporter</span>
                </div>
                <div style="margin-bottom: 14px;">
                    <div style="display: flex; align-items: center; gap: 8px; margin-bottom: 6px;">
                        <span>🖱️</span>
                        <span><strong>Left-click</strong> — Export to Markdown</span>
                    </div>
                    <div style="display: flex; align-items: center; gap: 8px; margin-bottom: 6px;">
                        <span>⚙️</span>
                        <span><strong>Right-click</strong> — Open settings</span>
                    </div>
                    <div style="display: flex; align-items: center; gap: 8px;">
                        <span>✋</span>
                        <span><strong>Right-drag</strong> — Move button</span>
                    </div>
                </div>
                <div style="display: flex; align-items: center; justify-content: space-between; padding-top: 12px; border-top: 1px solid rgba(255,255,255,0.2);">
                    <label style="display: flex; align-items: center; gap: 6px; font-size: 11px; opacity: 0.9; cursor: pointer;">
                        <input type="checkbox" id="gh-onboarding-dismiss" style="cursor: pointer; accent-color: #fff; width: 14px; height: 14px;">
                        Don't show again
                    </label>
                    <button id="gh-onboarding-close" style="background: rgba(255,255,255,0.2); border: none; color: #fff; padding: 6px 14px; border-radius: 6px; font-size: 12px; font-weight: 600; cursor: pointer; transition: background 0.15s;">
                        Got it!
                    </button>
                </div>
            `;

            document.body.appendChild(banner);
            this.element = banner;

            // Position relative to button
            const btnRect = anchorEl.getBoundingClientRect();
            const bannerWidth = 320;
            const margin = 16;

            // Determine best position
            const buttonInTopHalf = btnRect.top < window.innerHeight / 2;
            const buttonInLeftHalf = btnRect.left < window.innerWidth / 2;

            let top, left;

            if (buttonInTopHalf) {
                top = btnRect.bottom + margin;
            } else {
                top = btnRect.top - banner.offsetHeight - margin;
            }

            if (buttonInLeftHalf) {
                left = Math.max(margin, btnRect.left - 10);
            } else {
                left = Math.min(window.innerWidth - bannerWidth - margin, btnRect.right - bannerWidth + 10);
            }

            // Clamp to viewport
            top = Math.max(margin, Math.min(top, window.innerHeight - banner.offsetHeight - margin));
            left = Math.max(margin, Math.min(left, window.innerWidth - bannerWidth - margin));

            banner.style.top = `${top}px`;
            banner.style.left = `${left}px`;

            // Animate in
            requestAnimationFrame(() => {
                requestAnimationFrame(() => {
                    banner.style.opacity = '1';
                    banner.style.transform = 'translateY(0)';
                });
            });

            // Event handlers
            const checkbox = banner.querySelector('#gh-onboarding-dismiss');
            const closeBtn = banner.querySelector('#gh-onboarding-close');

            closeBtn.addEventListener('click', (e) => {
                e.stopPropagation();
                this.dismiss(checkbox?.checked || false);
            });

            closeBtn.addEventListener('mouseenter', () => {
                closeBtn.style.background = 'rgba(255,255,255,0.3)';
            });
            closeBtn.addEventListener('mouseleave', () => {
                closeBtn.style.background = 'rgba(255,255,255,0.2)';
            });

            // Auto-dismiss after 15 seconds
            setTimeout(() => {
                if (this.element) {
                    this.dismiss(false);
                }
            }, 15000);
        }
    };

    // ═══════════════════════════════════════════════════════════════════════════
    // UI COMPONENTS (Shadow DOM Isolated FAB)
    // ═══════════════════════════════════════════════════════════════════════════

    const UI = {
        shadowHost: null,
        shadowRoot: null,
        btn: null,
        dragOverlay: null,
        isDragging: false,
        isExporting: false,
        dragMoved: false,
        abortController: null,

        // SVG icons for different states
        ICONS: {
            download: `<svg viewBox="0 0 24 24" width="24" height="24" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round">
                <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
                <polyline points="7 10 12 15 17 10"/>
                <line x1="12" y1="15" x2="12" y2="3"/>
            </svg>`,
            loading: `<svg viewBox="0 0 24 24" width="24" height="24" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round" style="animation: gh-spin 1s linear infinite;">
                <circle cx="12" cy="12" r="10" stroke-dasharray="32" stroke-dashoffset="12"/>
            </svg>`,
            cancel: `<svg viewBox="0 0 24 24" width="24" height="24" stroke="currentColor" stroke-width="2.5" fill="none" stroke-linecap="round" stroke-linejoin="round">
                <circle cx="12" cy="12" r="10"/>
                <line x1="15" y1="9" x2="9" y2="15"/>
                <line x1="9" y1="9" x2="15" y2="15"/>
            </svg>`,
            success: `<svg viewBox="0 0 24 24" width="24" height="24" stroke="currentColor" stroke-width="2.5" fill="none" stroke-linecap="round" stroke-linejoin="round">
                <circle cx="12" cy="12" r="10"/>
                <polyline points="8 12 11 15 16 9"/>
            </svg>`,
            error: `<svg viewBox="0 0 24 24" width="24" height="24" stroke="currentColor" stroke-width="2.5" fill="none" stroke-linecap="round" stroke-linejoin="round">
                <circle cx="12" cy="12" r="10"/>
                <line x1="12" y1="8" x2="12" y2="12"/>
                <line x1="12" y1="16" x2="12.01" y2="16"/>
            </svg>`
        },

        init() {
            // Create Shadow DOM host for isolation
            this.shadowHost = document.createElement('div');
            this.shadowHost.id = 'gh-issue-export-host';
            Object.assign(this.shadowHost.style, {
                position: 'fixed',
                top: '0',
                left: '0',
                width: '0',
                height: '0',
                overflow: 'visible',
                zIndex: CONFIG.Z_INDEX.toString(),
                pointerEvents: 'none',
                // Selection prevention at host level
                userSelect: 'none',
                webkitUserSelect: 'none',
                msUserSelect: 'none',
                MozUserSelect: 'none'
            });

            this.shadowRoot = this.shadowHost.attachShadow({ mode: 'closed' });

            // Inject styles into shadow DOM
            const style = document.createElement('style');
            style.textContent = `
                :host {
                    all: initial;
                }
                * {
                    box-sizing: border-box;
                    user-select: none !important;
                }
                @keyframes gh-spin {
                    from { transform: rotate(0deg); }
                    to { transform: rotate(360deg); }
                }
                .fab {
                    position: fixed;
                    width: ${CONFIG.BUTTON_SIZE}px;
                    height: ${CONFIG.BUTTON_SIZE}px;
                    border-radius: 50%;
                    box-shadow: 0 4px 12px rgba(0,0,0,0.3), 0 0 0 1px rgba(255,255,255,0.1);
                    display: flex;
                    align-items: center;
                    justify-content: center;
                    cursor: pointer;
                    user-select: none;
                    transition: background-color 0.2s, transform 0.1s;
                    color: #fff;
                    pointer-events: auto;
                }
                .fab:hover {
                    transform: scale(1.05);
                }
                .fab.dragging {
                    cursor: grabbing;
                    transform: scale(1.02);
                }
                .fab svg {
                    width: 24px;
                    height: 24px;
                }
            `;
            this.shadowRoot.appendChild(style);

            // Create FAB button
            const btn = document.createElement('div');
            btn.className = 'fab';
            safeSetInnerHTML(btn, this.ICONS.download);

            this.loadPosition(btn);

            // Left-click: Export or Abort
            btn.addEventListener('click', (e) => {
                if (e.button === 0 && !this.isDragging && !this.dragMoved) {
                    Tooltip.hide();
                    if (this.isExporting && this.abortController) {
                        this.abortController.abort();
                        this.toast('Export cancelled', 'info');
                    } else {
                        this.export();
                    }
                }
            });

            // Right-click: Settings (if no drag)
            btn.addEventListener('contextmenu', (e) => {
                e.preventDefault();
                e.stopPropagation();
                Tooltip.hide();
                if (!this.dragMoved) {
                    SettingsPanel.toggle(btn);
                }
                this.dragMoved = false;
            });

            // Right-drag to move
            btn.addEventListener('mousedown', (e) => {
                if (e.button === 2) {
                    e.preventDefault();
                    this.dragMoved = false;
                    this.startDrag(e);
                }
            });

            // Hover: show custom tooltip
            btn.addEventListener('mouseenter', () => {
                if (!this.isDragging) {
                    if (!this.isExporting) {
                        Tooltip.scheduleShow(btn, 'Export Issue to Markdown\n\nLeft-click: Export\nRight-click: Settings\nRight-drag: Move', 600);
                    } else {
                        Tooltip.scheduleShow(btn, 'Click to cancel export', 300);
                    }
                }
            });

            btn.addEventListener('mouseleave', () => {
                if (!this.isDragging) {
                    Tooltip.hide();
                }
            });

            this.shadowRoot.appendChild(btn);
            document.body.appendChild(this.shadowHost);

            this.btn = btn;
            window.addEventListener('resize', () => this.applyPosition());
            this.updateButtonState('ready');

            // Show onboarding banner after short delay
            setTimeout(() => {
                OnboardingBanner.show(btn);
            }, 1000);
        },

        loadPosition(btn) {
            const saved = GM_getValue(CONFIG.POSITION_STORAGE_KEY, null);
            if (saved) {
                try {
                    const pos = typeof saved === 'string' ? JSON.parse(saved) : saved;
                    this.setPosition(btn, pos.ratioX, pos.ratioY);
                } catch (e) {
                    // Default: bottom-left
                    this.setPosition(btn, 0.02, 0.85);
                }
            } else {
                // Default: bottom-left
                this.setPosition(btn, 0.02, 0.85);
            }
        },

        setPosition(btn, rx, ry) {
            const maxX = window.innerWidth - CONFIG.BUTTON_SIZE - 10;
            const maxY = window.innerHeight - CONFIG.BUTTON_SIZE - 10;
            btn.style.left = `${Math.max(10, rx * maxX)}px`;
            btn.style.top = `${Math.max(10, ry * maxY)}px`;
        },

        applyPosition() {
            if (!this.btn) return;
            const saved = GM_getValue(CONFIG.POSITION_STORAGE_KEY, null);
            if (saved) {
                try {
                    const pos = typeof saved === 'string' ? JSON.parse(saved) : saved;
                    this.setPosition(this.btn, pos.ratioX, pos.ratioY);
                } catch (e) { /* ignore */ }
            }
        },

        /**
         * Create full-viewport overlay to capture all pointer events during drag
         */
        createDragOverlay() {
            const overlay = document.createElement('div');
            Object.assign(overlay.style, {
                position: 'fixed',
                top: '0',
                left: '0',
                width: '100vw',
                height: '100vh',
                zIndex: (CONFIG.Z_INDEX - 1).toString(),
                cursor: 'grabbing',
                background: 'transparent'
            });
            document.body.appendChild(overlay);
            return overlay;
        },

        startDrag(e) {
            this.isDragging = true;
            Tooltip.hide();

            const rect = this.btn.getBoundingClientRect();
            const startX = e.clientX;
            const startY = e.clientY;
            const offX = e.clientX - rect.left;
            const offY = e.clientY - rect.top;

            // Create overlay to prevent hover states on page elements
            this.dragOverlay = this.createDragOverlay();
            this.btn.classList.add('dragging');

            const onMove = (ev) => {
                ev.preventDefault();
                ev.stopPropagation();

                const dx = Math.abs(ev.clientX - startX);
                const dy = Math.abs(ev.clientY - startY);

                if (dx > 5 || dy > 5) {
                    this.dragMoved = true;
                }

                if (this.dragMoved) {
                    const x = Math.max(10, Math.min(window.innerWidth - CONFIG.BUTTON_SIZE - 10, ev.clientX - offX));
                    const y = Math.max(10, Math.min(window.innerHeight - CONFIG.BUTTON_SIZE - 10, ev.clientY - offY));
                    this.btn.style.left = `${x}px`;
                    this.btn.style.top = `${y}px`;
                }
            };

            const onUp = (ev) => {
                ev.preventDefault();
                ev.stopPropagation();

                // Remove overlay
                if (this.dragOverlay) {
                    this.dragOverlay.remove();
                    this.dragOverlay = null;
                }

                this.btn.classList.remove('dragging');
                document.removeEventListener('mousemove', onMove, true);
                document.removeEventListener('mouseup', onUp, true);

                if (this.dragMoved) {
                    const finalRect = this.btn.getBoundingClientRect();
                    const maxX = window.innerWidth - CONFIG.BUTTON_SIZE - 10;
                    const maxY = window.innerHeight - CONFIG.BUTTON_SIZE - 10;
                    const rx = maxX > 0 ? finalRect.left / maxX : 0.02;
                    const ry = maxY > 0 ? finalRect.top / maxY : 0.85;
                    GM_setValue(CONFIG.POSITION_STORAGE_KEY, { ratioX: rx, ratioY: ry });
                }

                // Delay clearing isDragging to prevent click from firing
                setTimeout(() => {
                    this.isDragging = false;
                }, 100);
            };

            // Use capture phase to intercept before page handlers
            document.addEventListener('mousemove', onMove, true);
            document.addEventListener('mouseup', onUp, true);
        },

        updateButtonState(state, message = '') {
            if (!this.btn) return;

            const colors = {
                ready: CONFIG.BUTTON_COLOR_READY,
                loading: CONFIG.BUTTON_COLOR_LOADING,
                success: CONFIG.BUTTON_COLOR_SUCCESS,
                error: CONFIG.BUTTON_COLOR_ERROR
            };

            const icons = {
                ready: this.ICONS.download,
                loading: this.ICONS.cancel,
                success: this.ICONS.success,
                error: this.ICONS.error
            };

            this.btn.style.backgroundColor = colors[state] || colors.ready;
            safeSetInnerHTML(this.btn, icons[state] || icons.ready);
        },

        async export() {
            const issueInfo = Utils.parseIssueUrl();
            if (!issueInfo) {
                this.toast('Not a valid issue page', 'error');
                return;
            }

            // Close settings and onboarding if open
            SettingsPanel.hide();
            OnboardingBanner.dismiss();

            this.isExporting = true;
            this.abortController = new AbortController();
            this.updateButtonState('loading');

            try {
                const { owner, repo, issueNumber } = issueInfo;
                const signal = this.abortController.signal;

                // Check for abort
                if (signal.aborted) throw new Error('Cancelled');

                // Fetch issue
                const issue = await GitHubAPI.fetchIssue(owner, repo, issueNumber, signal);

                if (signal.aborted) throw new Error('Cancelled');

                // Fetch comments
                let comments = [];
                if (issue.comments > 0) {
                    comments = await GitHubAPI.fetchAllComments(owner, repo, issueNumber, (count) => {
                        // Progress callback (optional)
                    }, signal);
                }

                if (signal.aborted) throw new Error('Cancelled');

                // Decide whether we need timeline at all
                const settings = Settings.load();

                const needsTimeline =
                    settings.INCLUDE_REFERENCES_SECTION ||
                    settings.INCLUDE_TIMELINE_SECTION;

                let timeline = [];
                if (needsTimeline) {
                    timeline = await GitHubAPI.fetchTimeline(owner, repo, issueNumber, signal);
                }

                if (signal.aborted) throw new Error('Cancelled');

                // Optional: Fetch commit details (extra API calls; default OFF)
                const commitDetailsBySha = {};
                if (needsTimeline && settings.INCLUDE_REFERENCES_SECTION && settings.REF_INCLUDE_COMMITS && settings.REF_FETCH_COMMIT_DETAILS) {
                    const referenced = (timeline || []).filter(e => e && e.event === 'referenced' && e.commit_id && e.commit_url);
                    const unique = new Map();
                    referenced.forEach(e => {
                        const sha = String(e.commit_id || '');
                        const url = String(e.commit_url || '');
                        if (sha && url && !unique.has(sha)) unique.set(sha, url);
                    });

                    for (const [sha, commitApiUrl] of unique.entries()) {
                        if (signal.aborted) throw new Error('Cancelled');

                        try {
                            const c = await GitHubAPI.fetchCommit(commitApiUrl, signal);
                            commitDetailsBySha[sha] = {
                                html_url: c?.html_url || null,
                                message: c?.commit?.message || null
                            };
                        } catch (e) {
                            // Graceful degrade: keep export working even if commit fetch fails / rate limit
                            warn('Commit fetch failed for', sha, e?.message || e);
                        }
                    }
                }

                if (signal.aborted) throw new Error('Cancelled');

                // Generate Markdown
                const markdown = MarkdownGenerator.generate(issue, comments, timeline, commitDetailsBySha, settings);

                // Download
                const filename = `${Utils.sanitizeFilename(issue.title)}_${owner}_${repo}_${issueNumber}.md`;
                this.downloadFile(markdown, filename);

                this.updateButtonState('success');
                this.toast(`Exported ${comments.length + 1} messages`, 'success');

                setTimeout(() => {
                    this.updateButtonState('ready');
                }, 2000);

            } catch (err) {
                if (err.message === 'Cancelled') {
                    this.updateButtonState('ready');
                } else {
                    error('Export failed:', err);
                    this.updateButtonState('error');
                    this.toast(`Export failed: ${err.message}`, 'error');

                    setTimeout(() => {
                        this.updateButtonState('ready');
                    }, 3000);
                }
            } finally {
                this.isExporting = false;
                this.abortController = null;
            }
        },

        downloadFile(content, filename) {
            const blob = new Blob([content], { type: 'text/markdown;charset=utf-8' });
            const url = URL.createObjectURL(blob);
            const a = document.createElement('a');
            a.href = url;
            a.download = filename;
            document.body.appendChild(a);
            a.click();
            document.body.removeChild(a);
            URL.revokeObjectURL(url);
        },

        toast(message, type = 'info') {
            // Remove existing toasts
            document.querySelectorAll('.gh-issue-export-toast').forEach(el => el.remove());

            const toast = document.createElement('div');
            toast.className = 'gh-issue-export-toast';
            toast.textContent = message;

            const bgColors = {
                info: '#1f6feb',
                success: '#238636',
                error: '#da3633'
            };

            // Position near the button if possible, with viewport awareness
            let bottom = '80px';
            let left = '50%';
            let transform = 'translateX(-50%)';

            if (this.btn) {
                const btnRect = this.btn.getBoundingClientRect();
                const margin = 20;
                const toastHeight = 50; // Approximate

                // Determine vertical position
                const spaceAbove = btnRect.top;
                const spaceBelow = window.innerHeight - btnRect.bottom;

                if (spaceBelow > toastHeight + margin) {
                    // Show below button
                    bottom = `${window.innerHeight - btnRect.bottom - toastHeight - margin}px`;
                } else if (spaceAbove > toastHeight + margin) {
                    // Show above button
                    bottom = `${window.innerHeight - btnRect.top + margin}px`;
                }

                // Horizontal alignment near button, clamped to viewport
                const toastWidth = 200; // Approximate
                let leftPos = btnRect.left + btnRect.width / 2;
                leftPos = Math.max(toastWidth / 2 + margin, Math.min(leftPos, window.innerWidth - toastWidth / 2 - margin));
                left = `${leftPos}px`;
            }

            Object.assign(toast.style, {
                position: 'fixed',
                bottom: bottom,
                left: left,
                transform: transform,
                background: bgColors[type] || bgColors.info,
                color: '#fff',
                padding: '12px 24px',
                borderRadius: '8px',
                fontSize: '14px',
                fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
                fontWeight: '500',
                zIndex: CONFIG.Z_INDEX.toString(),
                opacity: '0',
                transition: 'opacity 0.3s, transform 0.3s',
                boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
                pointerEvents: 'none'
            });

            document.body.appendChild(toast);

            requestAnimationFrame(() => {
                toast.style.opacity = '1';
            });

            setTimeout(() => {
                toast.style.opacity = '0';
                setTimeout(() => toast.remove(), 300);
            }, 3000);
        },

        /**
         * Clean up all UI elements
         */
        destroy() {
            Tooltip.destroy();

            if (this.dragOverlay) {
                this.dragOverlay.remove();
                this.dragOverlay = null;
            }

            if (this.shadowHost) {
                this.shadowHost.remove();
                this.shadowHost = null;
                this.shadowRoot = null;
                this.btn = null;
            }

            // Also clean up legacy non-shadow elements if they exist
            const legacyBtn = document.getElementById('gh-issue-export-btn');
            if (legacyBtn) legacyBtn.remove();

            const legacyTooltip = document.getElementById('gh-issue-export-tooltip');
            if (legacyTooltip) legacyTooltip.remove();
        }
    };

    // ═══════════════════════════════════════════════════════════════════════════
    // INITIALIZATION
    // ═══════════════════════════════════════════════════════════════════════════

    const init = () => {
        // Only show on issue pages
        const issueInfo = Utils.parseIssueUrl();
        if (!issueInfo) {
            log('Not an issue page, skipping initialization');
            return;
        }

        log(`Initializing for ${issueInfo.owner}/${issueInfo.repo}#${issueInfo.issueNumber}`);

        UI.init();
    };

    // Wait for DOM
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        init();
    }

    // Handle SPA navigation with proper cleanup
    let lastUrl = location.href;
    const observer = new MutationObserver(() => {
        if (location.href !== lastUrl) {
            lastUrl = location.href;
            log('Navigation detected:', location.href);

            // Clean up all existing UI elements
            UI.destroy();
            SettingsPanel.hide();
            OnboardingBanner.dismiss();

            // Re-initialize if on issue page
            setTimeout(init, 500);
        }
    });

    observer.observe(document.body, { childList: true, subtree: true });

})();