GitHub | Issues, PRs, Discussions & Releases Exporter

Export full GitHub issues, PRs, discussions, and release notes to Markdown, HTML, or ZIP — includes all comments, reviews, diffs, and timelines. Single-page or batch export with pause/resume.

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         GitHub | Issues, PRs, Discussions & Releases Exporter
// @namespace    https://greasyfork.org/en/users/1462137-piknockyou
// @version      6.0.1
// @author       Piknockyou
// @license      AGPL-3.0
// @description  Export full GitHub issues, PRs, discussions, and release notes to Markdown, HTML, or ZIP — includes all comments, reviews, diffs, and timelines. Single-page or batch export with pause/resume.
// @match        *://github.com/*/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=github.com
// @grant        GM_openInTab
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_deleteValue
// @grant        GM_addStyle
// @require      https://cdn.jsdelivr.net/npm/[email protected]/lib/marked.umd.js
// @require      https://cdn.jsdelivr.net/npm/[email protected]/dist/jszip.min.js
// @run-at       document-idle
// ==/UserScript==

// biome-ignore-all lint/style/useTemplate: style preference, string concat is fine
// biome-ignore-all lint/complexity/useLiteralKeys: style preference

(() => {
	// ════════════════════════════════════════════════════════════════════════════
	// DEVELOPER GUIDELINES — READ BEFORE MAKING ANY CHANGES
	// ════════════════════════════════════════════════════════════════════════════
	//
	// This comment block is the single source of truth for contribution rules.
	// Every developer (human or AI) MUST follow these guidelines.
	//
	// ── 1. VERSION & CHANGELOG ───────────────────────────────────────────────
	//
	//   • Every change MUST bump the version in BOTH the @version header AND
	//     the changelog section below. No exceptions.
	//   • Changelog entries must be concise but specific. State what changed,
	//     not why. Example: "Fix: ZIP hangs in sandbox" not "Fixed a bug".
	//   • The help/README window (openHelpWindow) must stay in sync with
	//     any new features, removed features, or changed workflows.
	//
	// ── 2. DRY — DO NOT REPEAT YOURSELF ──────────────────────────────────────
	//
	//   • Before adding code, search the entire file for existing utilities.
	//     Key shared resources:
	//       - API.request() / API.fetchAllPages() / API.graphql() — all HTTP
	//       - Utils.*            — formatting, files, delays, parsing
	//       - createDebugLogger  — debug log instances
	//       - logClearHandlers   — log clear callback registry
	//       - wireLogActions()   — log button wiring (copy + clear)
	//       - ThreadConfig       — export settings (load/get/set)
	//       - EXP_FLAGS / EXP_DEPS / EXP_CHILD_TO_PARENT — settings metadata
	//       - safeSetInnerHTML() — Trusted Types safe innerHTML
	//       - showToast()        — user notifications
	//   • If two sections share similar logic, extract it into a shared
	//     function. Never copy-paste code between sections.
	//   • If you remove a feature, remove ALL traces: code, CSS, HTML, storage
	//     keys, changelog references, help text. Grep the file to confirm.
	//
	// ── 3. ARCHITECTURE RULES ────────────────────────────────────────────────
	//
	//   • REST-first: prefer REST API. Only use GraphQL when it provides data
	//     REST cannot (polls, review thread state, discussion timeline, etc.).
	//   • All HTTP goes through API.request() — never use raw fetch().
	//   • All rate limit tracking is automatic via API.rateLimit.core/.graphql.
	//   • Modules (ReleasesModule, RepoIndexModule, ThreadExportModule) own
	//     their data and expose it via accessors. UI reads, never writes.
	//   • The Panel layer builds HTML, wires events, and reads module state.
	//     It must not contain fetch logic or data transformation.
	//
	// ── 4. UI / UX CONSISTENCY ───────────────────────────────────────────────
	//
	//   • Every button MUST have a title="..." tooltip explaining its action.
	//   • Every section header's ? icon MUST have a .ghe-tip with:
	//     what's included, how to use (steps), and any caveats.
	//   • Use the 3-button pattern for long-running operations:
	//     [Start/Resume (green/blue)] [⏸ Pause (red)] [✕ Reset (grey)]
	//   • Download buttons: 🔗 = open in tab, 💾 = save file, 📦 = ZIP.
	//   • Status text colors: #3fb950 = success/ready, #d29922 = in progress,
	//     #f85149 = error/warning, #8b949e = neutral/hint.
	//   • All CSS must work in both dark and light modes. For every dark rule,
	//     add a corresponding #ghe-panel.ghe-light override.
	//
	// ── 5. STORAGE ───────────────────────────────────────────────────────────
	//
	//   • Every persistent checkbox/preference MUST use Prefs.get/set or
	//     ThreadConfig.get/set. Search for all checkboxes and verify.
	//   • Storage keys are defined in STORAGE_KEYS. Do not create ad-hoc keys.
	//   • When removing a feature that used storage, add a GM_deleteValue()
	//     call in the legacy cleanup section near the top of the file.
	//
	// ── 6. TOOLTIPS ──────────────────────────────────────────────────────────
	//
	//   • Every interactive element (button, checkbox, input, link) that is
	//     not self-explanatory MUST have a tooltip — either a native
	//     title="..." or a .ghe-tip inside a .ghe-info for long text.
	//   • When adding ANY new button or control, add a tooltip immediately.
	//     Do not defer. Do not "add tooltips later". It is part of the work.
	//   • Section ? icons use .ghe-tip (custom styled, position-aware).
	//     Small controls use native title="..." (sufficient for short hints).
	//   • After any UI change, scan the affected section for elements that
	//     lack tooltips. If you find one, add it in the same patch.
	//
	// ── 7. PATCH WORKFLOW (for AI-assisted changes) ──────────────────────────
	//
	//   • Always use VRTP patches — never rewrite the full file.
	//   • First patch block MUST bump the version.
	//   • Use <<<...>>> anchors in SEARCH blocks to skip unchanged sections.
	//   • Never patch-on-patch: don't target text you just created in an
	//     earlier REPLACE block within the same response.
	//   • After patching, mentally verify: no duplicate functions, no orphaned
	//     HTML IDs, no CSS selectors targeting removed elements.
	//
	// ── 8. TESTING CHECKLIST ─────────────────────────────────────────────────
	//
	//   • Does it work without a token? (public repo, REST only)
	//   • Does it work with a token? (GraphQL features enabled)
	//   • Does light mode look correct? (toggle and check all new elements)
	//   • Does SPA navigation reset state? (click between repos)
	//   • Do all debug logs persist their checkbox state across sessions?
	//   • Do all tooltips display and not clip outside the panel?
	//
	// ── 9. MAINTAINING THESE GUIDELINES ──────────────────────────────────────
	//
	//   • These guidelines are a living document. Update them when:
	//     - A new shared utility is added (list it in section 2)
	//     - A new UI pattern is established (document it in section 4)
	//     - A new storage key is introduced (note it in section 5)
	//     - A recurring mistake is discovered (add a rule to prevent it)
	//   • Keep guidelines concise and actionable. Rules without rationale
	//     are fine — brevity > justification.
	//   • If a guideline is no longer relevant, remove it. Dead rules erode
	//     trust in live rules.
	//
	// ════════════════════════════════════════════════════════════════════════════

	// ════════════════════════════════════════════════════════════════════════════
	// CHANGELOG
	// ════════════════════════════════════════════════════════════════════════════
	//
	// v0.1.0
	// - Initial combined script: shared foundation, token, API client, utilities
	//
	// v0.2.0
	// - Added ReleasesModule (ported from Repo Lister, wired to shared API client)
	//
	// v0.3.0
	// - Added RepoIndexModule (Issues/PRs/Discussions index, ported from Repo Lister)
	//
	// v0.4.0
	// - Added ThreadExportModule (full issue/PR/discussion export orchestrator)
	// - Added MdGen (Markdown generators for issues, PRs, discussions)
	//
	// v0.5.0
	// - Added MdGen.generateDiscussion(), MdGen.generate() master assembly
	// - Added ThreadExportModule orchestrator (fetch + generate for issue/PR/discussion)
	//
	// v0.6.0
	// - Unified UI: single panel with token, releases, index, thread export sections
	// - Added toast notifications
	// - Fixed notifications newline bug in parsePage
	//
	// v0.7.0
	// - Added unified panel with all sections: token, releases, index, thread export
	// - Thread export section with full settings + Export This Page button
	// - Navigation/init with SPA support
	//
	// v0.7.1
	// - Fixed tofu debug log toggle: replaced emoji with text label + tooltip
	//
	// v0.7.2
	// - Fixed openBlob: use document.write for HTML to avoid Firefox blob URL issues
	//
	// v0.7.3
	// - Added single release export on /releases/tag/ pages
	// - parsePage now detects release tag pages
	//
	// v0.7.4
	// - Single release export now reuses ReleasesModule rendering (DRY)
	// - ReleasesModule gains buildFrom(items) for external use
	//
	// v0.7.5
	// - Efficient single/small-range index: direct REST lookup when no token + small range
	//
	// v0.7.6
	// - Release filtering: tag patterns (v1.*, v1.0.0-v2.0.0) and date ranges (2024-01..2025-01)
	//
	// v0.7.7
	// - Export settings: dependent state logic — sub-options disable when parent is unchecked
	//
	// v0.7.8
	// - Export settings: hover tooltips on every checkbox explaining what each flag does
	//
	// v0.7.9
	// - Discussion GraphQL enrichment: cursor-based pagination for 100+ comments
	//
	// v0.8.0
	// - Batch full-content export from index section (concatenated Markdown)
	// - Progress UI with cancel support
	// - Token recommendation warning for batch export
	//
	// v0.8.1
	// - Removed legacy storage keys (fabPos, onboardingDone)
	// - Unified rate limit indicator at top of panel, replacing per-section displays
	//
	// v0.8.2
	// - Batch export: resume from last position after rate limit or cancel
	// - Batch export: partial download available mid-export
	//
	// v0.8.3
	// - DRY: extracted shared DebugLogger factory (replaces duplicate in Releases + Index)
	// - DRY: migrated ReleasesModule.fetchAll to use API.request()
	// - DRY: migrated RepoIndexModule fetch loops to use API.request()
	// - DRY: migrated RepoIndexModule GraphQL calls to use API.graphql()
	//
	// v0.8.4
	// - Fix: debugEnabled reference error in RepoIndexModule.setRange()
	// - Fix: missing _getItems accessor in RepoIndexModule return
	// - Fix: removed stale #ghe-rel-rl reference in wireReleases
	// - Added export preview: view Markdown in new tab before downloading
	//
	// v0.8.5
	// - Fix: syntax error in parseReleaseFilter regex (unbalanced character class)
	//
	// v0.8.6
	// - Fix: RepoIndexModule.reset() now resets running state
	// - Fix: SPA navigation now resets all module state (prevents stale data across repos)
	// - Fix: batch export re-snapshots items if index was re-run since last batch
	// - Enhanced preview: renders Markdown as styled HTML using marked.js instead of raw text
	// - Preview includes table of contents generation for H1/H2/H3 headings
	//
	// v0.9.0
	// - Selective batch export: checkboxes to choose which items to export
	// - Batch item list with type badges and state indicators
	// - Select All / None / Invert controls for batch selection
	// - Live count of selected items updates as checkboxes change
	// - Scrollable item picker with search/filter
	// - Batch export respects selection (only checked items are exported)
	//
	// v0.9.1
	// - Fix: wired batch picker JS (batchSelected, buildPicker, etc. were missing)
	// - Fix: batch export now works with selective picker end-to-end
	// - Batch export "Export All" auto-indexes if no index exists (single-click workflow)
	// - Batch section promoted: visible even before index, with auto-index capability
	// - Improved UX: two paths — quick "Export All" or selective via index-first
	//
	// v0.9.2
	// - Fix: wired all batch picker JS into wireBatchExport (batchSelected, picker controls, etc.)
	// - Fix: batch export button references batchSelected correctly
	// - Auto-index: batch export auto-runs index if none exists yet
	//
	// v0.9.3
	// - Batch section always visible with explanation — no longer hidden until index runs
	// - Batch section shows disabled state with hint when no index exists
	// - Panel left-edge resize sash: drag to make panel wider
	// - Panel width persisted across sessions
	//
	// v0.9.4
	// - Added JSZip @require for ZIP bundle downloads
	// - Batch export: "Download as ZIP" button — individual .md files per item in a zip
	// - ZIP filenames include item number, type, and sanitized title
	// - ZIP includes an index.md with links to all exported files
	//
	// v0.9.5
	// - Clarity: every section header has an info tooltip explaining what it exports
	// - Releases section: clarified as "Release Notes & Changelogs" with tooltip
	// - Issues/PRs/Discussions section: clarified as "Index" with tooltip explaining metadata-only
	// - Batch section: tooltip explains full-content export vs index
	// - Single release/thread sections: tooltips for clarity
	// - Section headers use help cursor on hover for discoverability
	//
	// v0.9.6
	// - Fix: _generation counter now increments correctly (was on return object, unreachable)
	// - Progress feedback during single-thread export (shows current fetch step)
	// - Export settings accessible via collapsible panel in batch section too
	//
	// v0.9.7
	// - Export settings collapsible added to batch section (same flags as single-thread)
	// - Minimized issue/PR comments detected via GraphQL and marked in output
	// - New setting: ISSUE_INCLUDE_MINIMIZED_COMMENTS to show/hide minimized comments
	// - GraphQL query fetches minimized state for issue/PR comments when token available
	// - Discussion timeline via GraphQL timelineItems (REST returns 404)
	// - Release notes: ZIP download button for releases export
	//
	// v0.9.8
	// - Fix: ZIP download button no longer gets stuck on "Building ZIP"
	// - Fix: rate limit display no longer jumps between REST/GraphQL pools
	// - Fix: batch completion now clears progress detail (no stale "Exporting #X" text)
	// - Separate REST (core) and GraphQL rate limit pool tracking
	// - Inline rate limit indicator in batch export progress area
	// - Batch debug log (same pattern as releases/index debug logs)
	// - State filter buttons in batch picker (Open/Closed/Merged/Answered)
	// - State indicator cleanup: compact inline emoji, no oval container
	// - Panel default width increased from 340px to 400px for better readability
	// - Light/dark mode: comprehensive audit of all UI elements for consistency
	// - Renamed ZIP button label for clarity
	//
	// v0.9.9
	// - Fix: ZIP generation now uses uint8array+Blob to avoid sandbox timeout
	// - Batch picker: proper table layout with aligned columns
	// - Batch picker: resizable height (drag like log textareas)
	// - State filter buttons reflect actual selection state automatically
	// - Copy-to-clipboard button on all debug log textareas
	// - Cancel/Resume/Reset buttons in batch export section
	//
	// v0.10.0
	// - Cancel/Resume/Reset UI pattern applied to Releases and Index sections
	// - Releases: resume after rate limit instead of losing progress
	// - Index: Reset button to clear state and start fresh
	// - Consistent 3-button pattern (Go/Cancel/Reset) across all export sections
	//
	// v0.10.1
	// - Manual pause/resume for Releases and Index (not just rate-limit)
	// - Cancel always allows resume from where it stopped
	// - DRY: unified paused state replaces rateLimited-only resume gate
	//
	// v0.10.2
	// - Renamed Cancel → Pause across all sections (Releases, Index, Batch)
	// - Button labels now match behavior: Pause stops with resume, not discard
	//
	// v0.10.3
	// - Fix: batch debug log checkbox now persists across sessions
	//
	// v0.10.4
	// - Fix: downgrade JSZip 3.10.1 → 3.9.1 (3.10+ hangs in userscript sandbox
	//   due to setimmediate postMessage check failing in isolated context)
	//
	// v0.10.5
	// - DRY: unified log actions (clear + copy) as compact icon buttons in top-right
	// - Removed separate Clear buttons from all log sections
	// - Log action buttons: 20% opacity, full on hover
	// - Single wireLogActions() handles all log textareas
	//
	// v0.10.6
	// - Custom styled tooltips on all section ? buttons (replaces native title attr)
	// - Tooltips position-aware: shift left/right to avoid panel edge clipping
	// - Removed redundant batch subtitle (info moved to tooltip)
	// - DRY: single CSS tooltip system via .ghe-tip data attribute
	//
	// v0.10.7
	// - Fix: batch tooltip now visible (ghe-info CSS no longer requires h3 parent)
	// - Log action buttons: proper square sizing with visible icons
	// - Consistent ghe-info styling across all contexts (h3, span, div)
	//
	// v0.10.8
	// - Batch hint: compact inline status next to count (red/green, not centered block)
	// - Release ZIP: now creates per-release .md files instead of single html+md bundle
	//
	// v0.10.9
	// - Batch status: actionable hint text guides users through workflow steps
	// - Help button in panel header opens full documentation rendered via marked.js
	//
	// v0.11.0
	// - Tooltips on every interactive button and control
	// - Rate limit bar: clear label explaining what it shows
	// - Token badge: tooltip explaining status
	// - Section ? tooltips: expanded with step-by-step workflow instructions
	// - All download/action buttons have descriptive tooltips
	// - Ambiguous UI elements clarified with context
	//
	// v0.11.1
	// - Added developer guidelines comment block
	//
	// v0.11.2
	// - Developer guidelines: added self-update rule and tooltip mandate
	// - Fix: tooltips flip below when they would clip above the panel viewport
	// - wireTooltipPositioning: now handles both horizontal and vertical overflow
	//
	// v0.11.3
	// - Fix: release HTML output now much closer to GitHub's native rendering
	// - Fix: images in release notes now render on their own line (not inline with list items)
	// - Fix: release HTML uses GitHub's markdown-body class with proper GitHub-like CSS
	// - Fix: release card layout now uses 2-column flex (sidebar + content) matching GitHub
	// - Fix: pre-release/draft badges rendered as styled labels matching GitHub
	// - Fix: tag and commit links shown in sidebar like GitHub's layout
	// - Fix: contributors section rendered in footer like GitHub
	// - Fix: reactions rendered as pill buttons matching GitHub's style
	//
	// v0.11.4
	// - Release filter: added ? tooltip with full format documentation and examples
	// - Release filter: support single year (2009), single month (2009-06), year range (2009..2010)
	// - Release filter: partial dates auto-expand (2009 → 2009-01-01..2009-12-31)
	// - Release filter: both .. and - accepted as range separators for dates
	// - Release filter: improved placeholder text for discoverability
	//
	// v0.11.5
	// - Fix: images in release notes now properly render on their own line
	//   (more aggressive <br>+image and image-after-text detection in preProcessMdBody)
	// - Fix: release filter date ranges use only .. separator (- is ambiguous with YYYY-MM-DD)
	// - Fix: filter tooltip corrected — no longer claims - works for date ranges
	// - Fix: tag range detection requires v-prefix or dots to avoid pure-number ambiguity
	// - Release filter: expandPartialDate helper for single year/month/day support
        //
        // v6.0
        // - Renamed script to "GitHub Issues, PRs, Discussions & Releases Exporter"
        // - Updated LOG_PREFIX and internal SCRIPT_NAME to match new branding
        // - Renamed all storage keys and CSS policy to match new branding

	// ════════════════════════════════════════════════════════════════════════════
	// CONSTANTS
	// ════════════════════════════════════════════════════════════════════════════

	const SCRIPT_NAME = "GitHub | Issues, PRs, Discussions & Releases Exporter";
	const LOG_PREFIX = "[GH-IPDR-Exporter]";

	// GM storage keys (unified namespace)
	const STORAGE_KEYS = {
		TOKEN: "ghIPDR_token",
		PREFS: "ghIPDR_prefs",
		THREAD_EXPORT_CONFIG: "ghIPDR_threadConfig",
	};

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

	const log = (...a) => console.log(LOG_PREFIX, ...a);
	const warn = (...a) => console.warn(LOG_PREFIX, ...a);
	const error = (...a) => console.error(LOG_PREFIX, ...a);

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

	let trustedTypesPolicy = null;
	if (window.trustedTypes?.createPolicy) {
		try {
			trustedTypesPolicy = window.trustedTypes.createPolicy(
				"gh-ipdr-exporter-policy",
				{
					createHTML: (s) => s,
				},
			);
			// biome-ignore lint/correctness/noUnusedVariables: catch variable required by syntax
		} catch (e) {
			/* policy may already exist */
		}
	}

	function safeSetInnerHTML(el, htmlStr) {
		if (trustedTypesPolicy)
			el.innerHTML = trustedTypesPolicy.createHTML(htmlStr);
		else el.innerHTML = htmlStr;
	}

	// ════════════════════════════════════════════════════════════════════════════
	// TOKEN MANAGEMENT (unified)
	// ════════════════════════════════════════════════════════════════════════════

	const Token = {
		get() {
			return (GM_getValue(STORAGE_KEYS.TOKEN, "") || "").trim();
		},
		set(t) {
			GM_setValue(STORAGE_KEYS.TOKEN, (t || "").trim());
		},
		clear() {
			GM_setValue(STORAGE_KEYS.TOKEN, "");
		},
		has() {
			return this.get().length > 0;
		},
	};

	// ════════════════════════════════════════════════════════════════════════════
	// PREFERENCES (unified, two-tier: general prefs + thread-export config)
	// ════════════════════════════════════════════════════════════════════════════

	// General prefs (panel checkboxes, UI state, etc.)
	const Prefs = {
		_load() {
			try {
				return JSON.parse(GM_getValue(STORAGE_KEYS.PREFS, "{}"));
			} catch {
				return {};
			}
		},
		_save(obj) {
			GM_setValue(STORAGE_KEYS.PREFS, JSON.stringify(obj));
		},
		get(k, def) {
			return this._load()[k] ?? def;
		},
		set(k, v) {
			const p = this._load();
			p[k] = v;
			this._save(p);
		},
		reset() {
			GM_setValue(STORAGE_KEYS.PREFS, "{}");
		},
	};

	// Thread export config (the detailed issue/PR/discussion export settings)
	// This is the equivalent of CONFIG + Settings from the Issue Exporter
	const THREAD_EXPORT_DEFAULTS = {
		// ─── Content ─────────────────────────────────────────────────────────
		INCLUDE_HEADER: true,
		INCLUDE_LABELS: true,
		INCLUDE_ASSIGNEES: true,
		INCLUDE_MILESTONE: true,
		INCLUDE_TIMESTAMPS: true,
		INCLUDE_AUTHOR_INFO: true,
		INCLUDE_REACTIONS: true,
		INCLUDE_COMMENT_IDS: false,

		// ─── Issue-specific ──────────────────────────────────────────────────
		ISSUE_INCLUDE_TYPE: true,
		ISSUE_INCLUDE_CLOSED_BY: true,
		ISSUE_INCLUDE_LOCK_REASON: true,
		ISSUE_INCLUDE_DEPENDENCIES: true,
		ISSUE_INCLUDE_SUB_ISSUES: true,
		ISSUE_INCLUDE_PINNED_COMMENT: true,

		// ─── Pull Request ────────────────────────────────────────────────────
		PR_INCLUDE_MERGE_STATUS: true,
		PR_INCLUDE_BRANCH_INFO: true,
		PR_INCLUDE_DIFF_STATS: true,
		PR_INCLUDE_REQUESTED_REVIEWERS: true,
		PR_INCLUDE_MERGE_REQUIREMENTS: true,
		PR_INCLUDE_CHECKS_SECTION: true,
		PR_INCLUDE_REVIEWS: true,
		PR_INCLUDE_REVIEW_COMMENTS: true,
		PR_INCLUDE_REVIEW_THREAD_STATE: true,
		PR_REVIEW_COMMENTS_GROUP_BY_THREAD: true,
		PR_INCLUDE_PR_COMMITS: false,
		PR_INCLUDE_CHANGED_FILES: false,
		PR_INCLUDE_CHANGED_FILES_PATCHES: false,

		// ─── Discussion-specific ─────────────────────────────────────────────
		DISC_INCLUDE_CATEGORY: true,
		DISC_INCLUDE_ANSWER: true,
		DISC_INCLUDE_POLL: true,
		DISC_INCLUDE_UPVOTES: true,
		DISC_INCLUDE_COMMENT_REPLIES: true,
		DISC_INCLUDE_TIMELINE: false,

		// ─── References ──────────────────────────────────────────────────────
		INCLUDE_REFERENCES_SECTION: true,
		REF_INCLUDE_CROSS_REFERENCED: true,
		REF_INCLUDE_SAME_REPO: true,
		REF_INCLUDE_CROSS_REPO: true,
		REF_INCLUDE_ISSUES: true,
		REF_INCLUDE_PRS: true,
		REF_INCLUDE_DUPLICATES: true,
		REF_INCLUDE_COMMITS: true,
		REF_FETCH_COMMIT_DETAILS: false,

		// ─── Timeline ────────────────────────────────────────────────────────
		INCLUDE_TIMELINE_SECTION: false,
		TL_INCLUDE_CROSS_REFERENCED: true,
		TL_INCLUDE_REFERENCED: true,
		TL_INCLUDE_RENAMED: true,
		TL_INCLUDE_LABEL_CHANGES: true,
		TL_INCLUDE_PROJECT_V2: false,
		TL_INCLUDE_SUBSCRIBE_EVENTS: false,
		TL_INCLUDE_MENTIONED_EVENTS: false,
		TL_INCLUDE_MARKED_DUPLICATE_EVENTS: true,
		TL_INCLUDE_CLOSED_EVENTS: true,
		TL_INCLUDE_REVIEWED: true,
		TL_INCLUDE_COMMITTED: true,
		TL_INCLUDE_MERGED: true,
		TL_INCLUDE_HEAD_REF_EVENTS: true,
		TL_INCLUDE_REVIEW_REQUESTED: true,

		// ─── Minimized ───────────────────────────────────────────────────────
		INCLUDE_MINIMIZED_COMMENTS: true,

		// ─── Formatting ──────────────────────────────────────────────────────
		COLLAPSIBLE_LONG_COMMENTS: false,
		LONG_COMMENT_THRESHOLD: 500,
		COMMENT_SEPARATOR: "---",
		USE_HTML_DETAILS: true,
	};

	const ThreadConfig = {
		_cache: null,
		_load() {
			if (this._cache) return this._cache;
			try {
				const saved = GM_getValue(STORAGE_KEYS.THREAD_EXPORT_CONFIG, null);
				if (saved) {
					const overrides =
						typeof saved === "string" ? JSON.parse(saved) : saved;
					this._cache = { ...THREAD_EXPORT_DEFAULTS, ...overrides };
					return this._cache;
				}
			} catch (e) {
				warn("ThreadConfig load error:", e);
			}
			this._cache = { ...THREAD_EXPORT_DEFAULTS };
			return this._cache;
		},
		get(k) {
			return this._load()[k];
		},
		set(k, v) {
			try {
				let overrides = {};
				const saved = GM_getValue(STORAGE_KEYS.THREAD_EXPORT_CONFIG, null);
				if (saved)
					overrides = typeof saved === "string" ? JSON.parse(saved) : saved;
				overrides[k] = v;
				GM_setValue(STORAGE_KEYS.THREAD_EXPORT_CONFIG, overrides);
				this._cache = null;
			} catch (e) {
				warn("ThreadConfig save error:", e);
			}
		},
		load() {
			return this._load();
		},
		reset() {
			GM_deleteValue(STORAGE_KEYS.THREAD_EXPORT_CONFIG);
			this._cache = null;
		},
	};

	// ════════════════════════════════════════════════════════════════════════════
	// SHARED UTILITIES
	// ════════════════════════════════════════════════════════════════════════════

	const Utils = {
		/**
		 * Parse current URL to determine page type and extract identifiers.
		 * Returns null if not a recognized page.
		 */
		parsePage() {
			const parts = location.pathname.split("/").filter(Boolean);
			if (parts.length < 2) return null;

			// Exclude special top-level pages
			const special = [
				"settings",
				"organizations",
				"orgs",
				"login",
				"signup",
				"explore",
				"topics",
				"trending",
				"collections",
				"events",
				"sponsors",
				"notifications",
				"new",
				"codespaces",
				"marketplace",
			];
			if (special.includes(parts[0])) return null;

			const owner = parts[0];
			const repo = parts[1];
			const fullName = `${owner}/${repo}`;

			// Specific page types
			if (parts.length >= 4) {
				if (parts[2] === "issues" && /^\d+$/.test(parts[3])) {
					return {
						type: "issue",
						owner,
						repo,
						fullName,
						number: parseInt(parts[3], 10),
					};
				}
				if (parts[2] === "pull" && /^\d+$/.test(parts[3])) {
					return {
						type: "pr",
						owner,
						repo,
						fullName,
						number: parseInt(parts[3], 10),
					};
				}
				if (parts[2] === "discussions" && /^\d+$/.test(parts[3])) {
					return {
						type: "discussion",
						owner,
						repo,
						fullName,
						number: parseInt(parts[3], 10),
					};
				}
				if (
					parts[2] === "releases" &&
					parts[3] === "tag" &&
					parts.length >= 5
				) {
					return {
						type: "release",
						owner,
						repo,
						fullName,
						number: null,
						tag: parts.slice(4).join("/"),
					};
				}
			}

			// Generic repo page (code, releases list, issues list, etc.)
			return { type: "repo", owner, repo, fullName, number: null };
		},

		/** Repo full name from URL */
		repoFullName() {
			const p = this.parsePage();
			return p ? p.fullName : null;
		},

		/** Is current page a single-thread page (issue, PR, or discussion)? */
		isThreadPage() {
			const p = this.parsePage();
			return (
				p && (p.type === "issue" || p.type === "pr" || p.type === "discussion")
			);
		},

		/** Is current page any repo page? */
		isRepoPage() {
			return this.parsePage() !== null;
		},

		/** Sanitize text for filenames */
		sanitizeFilename(text, maxLen = 80) {
			if (!text) return "github_export";
			return (
				text
					.replace(/·/g, "-")
					// biome-ignore lint/suspicious/noControlCharactersInRegex: intentional range to strip control chars from filenames
					.replace(/[<>:"/\\|?*\x00-\x1f]/g, "_")
					.replace(/\s+/g, "_")
					.replace(/_+/g, "_")
					.replace(/^_+|_+$/g, "")
					.trim()
					.substring(0, maxLen) || "github_export"
			);
		},

		/** Format ISO date */
		formatDate(isoString, includeTime = false) {
			if (!isoString) return "";
			try {
				const date = new Date(isoString);
				if (Number.isNaN(date.getTime())) return isoString;
				const opts = { year: "numeric", month: "short", day: "numeric" };
				if (includeTime) {
					opts.hour = "2-digit";
					opts.minute = "2-digit";
				}
				return date.toLocaleDateString("en-US", opts);
			} catch {
				return isoString;
			}
		},

		/** Format rate-limit reset timestamp */
		formatReset(ts) {
			return ts
				? new Date(ts * 1000).toLocaleTimeString([], {
						hour: "2-digit",
						minute: "2-digit",
						second: "2-digit",
					})
				: "?";
		},

		/** Format reactions object → emoji string */
		formatReactions(reactions) {
			if (!reactions) return "";
			const map = {
				"+1": "👍",
				"-1": "👎",
				laugh: "😄",
				hooray: "🎉",
				confused: "😕",
				heart: "❤️",
				rocket: "🚀",
				eyes: "👀",
			};
			const parts = [];
			for (const [key, emoji] of Object.entries(map)) {
				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;");
		},

		/** Format bytes to human-readable */
		formatBytes(bytes) {
			if (!bytes || bytes === 0) return "";
			if (bytes < 1024) return bytes + " B";
			if (bytes < 1048576) return (bytes / 1024).toFixed(1) + " KB";
			if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + " MB";
			return (bytes / 1073741824).toFixed(2) + " GB";
		},

		/** Delay helper */
		delay(ms) {
			return new Promise((r) => setTimeout(r, ms));
		},

		/** Download content as a file */
		downloadBlob(content, filename, mime) {
			const b = new Blob([content], { type: `${mime};charset=utf-8` });
			const u = URL.createObjectURL(b);
			const a = Object.assign(document.createElement("a"), {
				href: u,
				download: filename,
			});
			document.body.appendChild(a);
			a.click();
			document.body.removeChild(a);
			URL.revokeObjectURL(u);
		},

		/** Open content in new tab */
		openBlob(content, mime) {
			// For HTML content, use document.write to avoid Firefox blob URL restrictions
			if (mime.includes("html")) {
				try {
					const tab = window.open("", "_blank");
					if (tab) {
						tab.document.open();
						tab.document.write(content);
						tab.document.close();
						return;
					}
					// biome-ignore lint/correctness/noUnusedVariables: catch variable required by syntax
				} catch (e) {
					/* fall through to blob approach */
				}
			}
			// Fallback for non-HTML or if window.open was blocked
			const b = new Blob([content], { type: `${mime};charset=utf-8` });
			const u = URL.createObjectURL(b);
			try {
				if (typeof GM_openInTab === "function")
					GM_openInTab(u, { active: true, setParent: true });
				else window.open(u, "_blank");
				// biome-ignore lint/correctness/noUnusedVariables: catch variable required by syntax
			} catch (e) {
				// Last resort: download it instead
				this.downloadBlob(content, "export.html", mime);
			}
			setTimeout(() => URL.revokeObjectURL(u), 15000);
		},
	};

	// ════════════════════════════════════════════════════════════════════════════
	// SHARED API CLIENT
	// ════════════════════════════════════════════════════════════════════════════

	const API_BASE = "https://api.github.com";
	const PER_PAGE = 100;
	const FETCH_DELAY_MS = 100;
	const REQUEST_TIMEOUT_MS = 30000;

	const API = {
		/** Last known rate-limit values — separate pools for REST (core) and GraphQL */
		rateLimit: {
			core: { remaining: null, reset: null },
			graphql: { remaining: null, reset: null },
		},

		/** Convenience: return the lower remaining count across pools (for UI display) */
		get rateLimitSummary() {
			const c = this.rateLimit.core;
			const g = this.rateLimit.graphql;
			if (c.remaining === null && g.remaining === null)
				return { remaining: null, reset: null, pool: null };
			if (c.remaining === null)
				return { remaining: g.remaining, reset: g.reset, pool: "graphql" };
			if (g.remaining === null)
				return { remaining: c.remaining, reset: c.reset, pool: "core" };
			if (c.remaining <= g.remaining)
				return { remaining: c.remaining, reset: c.reset, pool: "core" };
			return { remaining: g.remaining, reset: g.reset, pool: "graphql" };
		},

		/** Build auth headers using shared token */
		_headers(extra = {}) {
			const h = {
				Accept: "application/vnd.github+json",
				"X-GitHub-Api-Version": "2022-11-28",
				...extra,
			};
			const t = Token.get();
			if (t) h["Authorization"] = `Bearer ${t}`;
			return h;
		},

		/**
		 * Core request method.
		 * @param {string} endpoint  Absolute URL or path starting with /
		 * @param {object} opts      fetch options + optional `signal`
		 * @returns {{ data: any, headers: Headers }}
		 */
		async request(endpoint, opts = {}) {
			const url = endpoint.startsWith("http")
				? endpoint
				: `${API_BASE}${endpoint}`;

			const headers = this._headers(opts.headers || {});

			const controller = new AbortController();
			const timeoutId = setTimeout(
				() => controller.abort(),
				REQUEST_TIMEOUT_MS,
			);

			// Bridge external abort signal
			const externalSignal = opts.signal;
			let onExtAbort = null;
			if (externalSignal) {
				if (externalSignal.aborted) {
					controller.abort();
				} else {
					onExtAbort = () => controller.abort();
					externalSignal.addEventListener("abort", onExtAbort, { once: true });
				}
			}

			try {
				const { signal: _s, headers: _h, ...rest } = opts;
				const response = await fetch(url, {
					...rest,
					headers,
					signal: controller.signal,
				});
				clearTimeout(timeoutId);
				if (onExtAbort && externalSignal) {
					try {
						externalSignal.removeEventListener("abort", onExtAbort);
					} catch {}
				}

				// Track rate limit — assign to correct pool
				const rlRemain = response.headers.get("X-RateLimit-Remaining");
				const rlReset = response.headers.get("X-RateLimit-Reset");
				if (rlRemain !== null) {
					const isGql = url.includes("/graphql");
					const pool = isGql ? this.rateLimit.graphql : this.rateLimit.core;
					pool.remaining = parseInt(rlRemain, 10);
					pool.reset = parseInt(rlReset, 10);
				}

				if (!response.ok) {
					const summary = this.rateLimitSummary;
					if (
						response.status === 403 &&
						summary.remaining !== null &&
						summary.remaining <= 0
					) {
						throw new Error(
							`Rate limit exceeded. Resets at ${Utils.formatReset(summary.reset)}`,
						);
					}
					throw new Error(
						`API error: ${response.status} ${response.statusText}`,
					);
				}

				const ct = response.headers.get("content-type") || "";
				const data = ct.includes("json")
					? await response.json()
					: await response.text();
				return { data, headers: response.headers };
			} catch (err) {
				clearTimeout(timeoutId);
				if (onExtAbort && externalSignal) {
					try {
						externalSignal.removeEventListener("abort", onExtAbort);
					} catch {}
				}
				if (err.name === "AbortError") {
					throw new Error(
						externalSignal?.aborted ? "Cancelled" : "Request timeout",
					);
				}
				throw err;
			}
		},

		/**
		 * Fetch all pages of a paginated REST endpoint.
		 * @param {string} endpoint  Base endpoint (without page params)
		 * @param {object} opts      { signal, onProgress(count), maxPages }
		 * @returns {Array}
		 */
		async fetchAllPages(endpoint, opts = {}) {
			const all = [];
			let page = 1;
			const maxPages = opts.maxPages || 500;
			const sep = endpoint.includes("?") ? "&" : "?";

			while (page <= maxPages) {
				const url = `${endpoint}${sep}per_page=${PER_PAGE}&page=${page}`;
				const { data, headers } = await this.request(url, {
					signal: opts.signal,
				});

				const items = Array.isArray(data) ? data : [];
				all.push(...items);
				if (opts.onProgress) opts.onProgress(all.length);

				const link = headers.get("Link");
				if (!link?.includes('rel="next"') || items.length < PER_PAGE) break;
				page++;
				await Utils.delay(FETCH_DELAY_MS);
			}
			return all;
		},

		/**
		 * Execute a GraphQL query.
		 * Requires token (GraphQL always needs auth).
		 */
		async graphql(query, variables = {}, signal = null) {
			if (!Token.has()) throw new Error("GraphQL requires a GitHub token");
			const { data } = await this.request("/graphql", {
				method: "POST",
				headers: { "Content-Type": "application/json" },
				body: JSON.stringify({ query, variables }),
				...(signal ? { signal } : {}),
			});
			if (data?.errors?.length) {
				throw new Error(
					`GraphQL error: ${data.errors.map((e) => e.message).join("; ")}`,
				);
			}
			return data?.data || null;
		},

		// ──────────────────────────────────────────────────────────────────────
		// Issue / PR endpoints (from Issue Exporter)
		// ──────────────────────────────────────────────────────────────────────

		async fetchIssue(owner, repo, number, signal) {
			const { data } = await this.request(
				`/repos/${owner}/${repo}/issues/${number}`,
				signal ? { signal } : {},
			);
			return data;
		},

		async fetchPullRequest(owner, repo, number, signal) {
			const { data } = await this.request(
				`/repos/${owner}/${repo}/pulls/${number}`,
				signal ? { signal } : {},
			);
			return data;
		},

		async fetchIssueComments(owner, repo, number, signal) {
			return this.fetchAllPages(
				`/repos/${owner}/${repo}/issues/${number}/comments`,
				{ signal },
			);
		},

		async fetchTimeline(owner, repo, number, signal) {
			try {
				return await this.fetchAllPages(
					`/repos/${owner}/${repo}/issues/${number}/timeline`,
					{ signal },
				);
			} catch (e) {
				warn("Timeline fetch failed:", e.message);
				return [];
			}
		},

		async fetchIssueDependenciesBlockedBy(owner, repo, number, signal) {
			try {
				const { data } = await this.request(
					`/repos/${owner}/${repo}/issues/${number}/dependencies/blocked_by?per_page=${PER_PAGE}`,
					signal ? { signal } : {},
				);
				return Array.isArray(data) ? data : [];
			} catch {
				return [];
			}
		},

		async fetchIssueDependenciesBlocking(owner, repo, number, signal) {
			try {
				const { data } = await this.request(
					`/repos/${owner}/${repo}/issues/${number}/dependencies/blocking?per_page=${PER_PAGE}`,
					signal ? { signal } : {},
				);
				return Array.isArray(data) ? data : [];
			} catch {
				return [];
			}
		},

		async fetchIssueSubIssues(owner, repo, number, signal) {
			try {
				const { data } = await this.request(
					`/repos/${owner}/${repo}/issues/${number}/sub_issues?per_page=${PER_PAGE}`,
					signal ? { signal } : {},
				);
				return Array.isArray(data) ? data : [];
			} catch {
				return [];
			}
		},

		// ──────────────────────────────────────────────────────────────────────
		// PR-specific endpoints
		// ──────────────────────────────────────────────────────────────────────

		async fetchRequestedReviewers(owner, repo, number, signal) {
			const { data } = await this.request(
				`/repos/${owner}/${repo}/pulls/${number}/requested_reviewers`,
				signal ? { signal } : {},
			);
			return data;
		},

		async fetchBranchRules(owner, repo, branch, signal) {
			if (!branch) return null;
			try {
				const { data } = await this.request(
					`/repos/${owner}/${repo}/rules/branches/${encodeURIComponent(branch)}`,
					signal ? { signal } : {},
				);
				return data;
			} catch {
				return null;
			}
		},

		async fetchCombinedStatus(owner, repo, ref, signal) {
			if (!ref) return null;
			try {
				const { data } = await this.request(
					`/repos/${owner}/${repo}/commits/${ref}/status`,
					signal ? { signal } : {},
				);
				return data;
			} catch {
				return null;
			}
		},

		async fetchCheckRuns(owner, repo, ref, signal) {
			if (!ref) return null;
			try {
				const { data } = await this.request(
					`/repos/${owner}/${repo}/commits/${ref}/check-runs?per_page=${PER_PAGE}`,
					signal ? { signal } : {},
				);
				return data;
			} catch {
				return null;
			}
		},

		async fetchReviews(owner, repo, number, signal) {
			return this.fetchAllPages(
				`/repos/${owner}/${repo}/pulls/${number}/reviews`,
				{ signal },
			);
		},

		async fetchReviewComments(owner, repo, number, signal) {
			return this.fetchAllPages(
				`/repos/${owner}/${repo}/pulls/${number}/comments`,
				{ signal },
			);
		},

		async fetchPRCommits(owner, repo, number, signal) {
			return this.fetchAllPages(
				`/repos/${owner}/${repo}/pulls/${number}/commits`,
				{ signal },
			);
		},

		async fetchPRFiles(owner, repo, number, signal) {
			return this.fetchAllPages(
				`/repos/${owner}/${repo}/pulls/${number}/files`,
				{ signal },
			);
		},

		async fetchCommit(commitApiUrl, signal) {
			const { data } = await this.request(
				commitApiUrl,
				signal ? { signal } : {},
			);
			return data;
		},

		// ──────────────────────────────────────────────────────────────────────
		// Discussion REST endpoints (NEW — confirmed by probe)
		// ──────────────────────────────────────────────────────────────────────

		async fetchDiscussion(owner, repo, number, signal) {
			const { data } = await this.request(
				`/repos/${owner}/${repo}/discussions/${number}`,
				signal ? { signal } : {},
			);
			return data;
		},

		async fetchDiscussionComments(owner, repo, number, signal) {
			return this.fetchAllPages(
				`/repos/${owner}/${repo}/discussions/${number}/comments`,
				{ signal },
			);
		},

		// ──────────────────────────────────────────────────────────────────────
		// GraphQL enrichment endpoints
		// ──────────────────────────────────────────────────────────────────────

		/**
		 * Fetch minimized state for issue/PR comments via GraphQL.
		 * Returns a Map of comment databaseId → { isMinimized, minimizedReason }.
		 */
		async fetchCommentMinimizedState(owner, repo, number, signal) {
			const allComments = [];
			let cursor = null;
			let hasNext = true;

			while (hasNext) {
				if (signal?.aborted) throw new Error("Cancelled");
				const query = `
          query($owner: String!, $repo: String!, $number: Int!${cursor ? ", $after: String!" : ""}) {
            repository(owner: $owner, name: $repo) {
              issueOrPullRequest(number: $number) {
                ... on Issue {
                  comments(first: 100${cursor ? ", after: $after" : ""}) {
                    pageInfo { hasNextPage endCursor }
                    nodes { databaseId isMinimized minimizedReason }
                  }
                }
                ... on PullRequest {
                  comments(first: 100${cursor ? ", after: $after" : ""}) {
                    pageInfo { hasNextPage endCursor }
                    nodes { databaseId isMinimized minimizedReason }
                  }
                }
              }
            }
          }`;
				try {
					const vars = { owner, repo, number };
					if (cursor) vars.after = cursor;
					const data = await this.graphql(query, vars, signal);
					const item = data?.repository?.issueOrPullRequest;
					const comments = item?.comments;
					if (!comments?.nodes) break;
					allComments.push(...comments.nodes);
					hasNext = !!comments.pageInfo?.hasNextPage;
					cursor = comments.pageInfo?.endCursor || null;
					if (hasNext) await Utils.delay(FETCH_DELAY_MS);
				} catch (e) {
					warn("Comment minimized state GQL failed:", e.message);
					break;
				}
			}

			const map = new Map();
			for (const c of allComments) {
				if (c.databaseId != null) {
					map.set(c.databaseId, {
						isMinimized: !!c.isMinimized,
						minimizedReason: c.minimizedReason || null,
					});
				}
			}
			return map;
		},

		async fetchPRReviewThreads(owner, repo, number, signal) {
			const query = `
        query($owner: String!, $repo: String!, $number: Int!) {
          repository(owner: $owner, name: $repo) {
            pullRequest(number: $number) {
              reviewThreads(first: 100) {
                nodes {
                  id
                  isResolved
                  isOutdated
                  path
                  line
                  startLine
                  comments(first: 100) {
                    nodes {
                      databaseId
                      id
                      isOutdated
                      minimizedReason
                    }
                  }
                }
              }
            }
          }
        }`;
			try {
				const data = await this.graphql(query, { owner, repo, number }, signal);
				const threads =
					data?.repository?.pullRequest?.reviewThreads?.nodes || [];
				return threads.map((t) => ({
					id: t.id,
					path: t.path,
					line: t.line,
					startLine: t.startLine,
					isResolved: !!t.isResolved,
					isOutdated: !!t.isOutdated,
					comments: (t.comments?.nodes || []).map((c) => ({
						databaseId: c.databaseId,
						id: c.id,
						isOutdated: !!c.isOutdated,
						minimizedReason: c.minimizedReason || null,
					})),
				}));
			} catch (e) {
				warn("PR review threads GQL failed:", e.message);
				return [];
			}
		},

		/**
		 * Fetch discussion timeline via GraphQL timelineItems.
		 * REST endpoint returns 404 for discussions, so GraphQL is required.
		 */
		async fetchDiscussionTimeline(owner, repo, number, signal) {
			const allEvents = [];
			let cursor = null;
			let hasNext = true;

			while (hasNext) {
				if (signal?.aborted) throw new Error("Cancelled");
				const query = `
          query($owner: String!, $repo: String!, $number: Int!${cursor ? ", $after: String!" : ""}) {
            repository(owner: $owner, name: $repo) {
              discussion(number: $number) {
                timelineItems(first: 100${cursor ? ", after: $after" : ""}) {
                  pageInfo { hasNextPage endCursor }
                  nodes {
                    __typename
                    ... on LabeledEvent { createdAt label { name } actor { login } }
                    ... on UnlabeledEvent { createdAt label { name } actor { login } }
                    ... on ClosedEvent { createdAt actor { login } stateReason }
                    ... on ReopenedEvent { createdAt actor { login } }
                    ... on LockedEvent { createdAt actor { login } lockReason }
                    ... on UnlockedEvent { createdAt actor { login } }
                    ... on PinnedEvent { createdAt actor { login } }
                    ... on UnpinnedEvent { createdAt actor { login } }
                    ... on TransferredEvent { createdAt actor { login } }
                    ... on CategoryChangedEvent { createdAt actor { login } }
                    ... on RenamedTitleEvent { createdAt actor { login } previousTitle currentTitle }
                    ... on MarkedAsDuplicateEvent { createdAt actor { login } }
                    ... on CrossReferencedEvent { createdAt actor { login } source { __typename ... on Issue { number title url repository { nameWithOwner } } ... on PullRequest { number title url repository { nameWithOwner } } } }
                  }
                }
              }
            }
          }`;
				try {
					const vars = { owner, repo, number };
					if (cursor) vars.after = cursor;
					const data = await this.graphql(query, vars, signal);
					const items = data?.repository?.discussion?.timelineItems;
					if (!items?.nodes) break;
					allEvents.push(...items.nodes);
					hasNext = !!items.pageInfo?.hasNextPage;
					cursor = items.pageInfo?.endCursor || null;
					if (hasNext) await Utils.delay(FETCH_DELAY_MS);
				} catch (e) {
					warn("Discussion timeline GQL failed:", e.message);
					break;
				}
			}
			return allEvents;
		},

		/**
		 * Fetch discussion enrichment via GraphQL (polls, upvotes, answer comment ID,
		 * minimized comments). Paginates comments for discussions with 100+ comments.
		 */
		async fetchDiscussionGraphQL(owner, repo, number, signal) {
			// First page: fetch discussion metadata + first batch of comments
			const firstQuery = `
        query($owner: String!, $repo: String!, $number: Int!) {
          repository(owner: $owner, name: $repo) {
            discussion(number: $number) {
              upvoteCount
              isPinned
              isAnswered
              answer { databaseId }
              poll {
                question
                totalVotes
                options(first: 10) {
                  nodes { text votes }
                }
              }
              comments(first: 100) {
                pageInfo { hasNextPage endCursor }
                nodes {
                  databaseId
                  isMinimized
                  minimizedReason
                  isAnswer
                  replies(first: 100) {
                    nodes {
                      databaseId
                      isMinimized
                      minimizedReason
                    }
                  }
                }
              }
            }
          }
        }`;
			try {
				const data = await this.graphql(
					firstQuery,
					{ owner, repo, number },
					signal,
				);
				const disc = data?.repository?.discussion;
				if (!disc) return null;

				// Paginate remaining comments if needed
				let pageInfo = disc.comments?.pageInfo;
				while (pageInfo?.hasNextPage && pageInfo.endCursor) {
					if (signal?.aborted) throw new Error("Cancelled");
					await Utils.delay(FETCH_DELAY_MS);

					const nextQuery = `
            query($owner: String!, $repo: String!, $number: Int!, $after: String!) {
              repository(owner: $owner, name: $repo) {
                discussion(number: $number) {
                  comments(first: 100, after: $after) {
                    pageInfo { hasNextPage endCursor }
                    nodes {
                      databaseId
                      isMinimized
                      minimizedReason
                      isAnswer
                      replies(first: 100) {
                        nodes {
                          databaseId
                          isMinimized
                          minimizedReason
                        }
                      }
                    }
                  }
                }
              }
            }`;
					const nextData = await this.graphql(
						nextQuery,
						{ owner, repo, number, after: pageInfo.endCursor },
						signal,
					);
					const nextComments = nextData?.repository?.discussion?.comments;
					if (!nextComments?.nodes?.length) break;

					disc.comments.nodes.push(...nextComments.nodes);
					pageInfo = nextComments.pageInfo;
				}

				return disc;
			} catch (e) {
				warn("Discussion GQL enrichment failed:", e.message);
				return null;
			}
		},

		// ──────────────────────────────────────────────────────────────────────
		// Releases endpoint (from Repo Lister)
		// ──────────────────────────────────────────────────────────────────────

		async fetchReleases(owner, repo, opts = {}) {
			return this.fetchAllPages(`/repos/${owner}/${repo}/releases`, opts);
		},
	};

	// ════════════════════════════════════════════════════════════════════════════
	// SHARED DEBUG LOGGER FACTORY
	// ════════════════════════════════════════════════════════════════════════════

	function createDebugLogger(prefix) {
		let entries = [],
			enabled = false;
		return {
			log(msg, data) {
				if (!enabled) return;
				const ts = new Date().toISOString().slice(11, 23);
				entries.push(
					data !== undefined
						? `[${ts}] ${msg} ${JSON.stringify(data)}`
						: `[${ts}] ${msg}`,
				);
				log(prefix, msg, data !== undefined ? data : "");
			},
			setEnabled(on) {
				enabled = on;
			},
			getText() {
				return entries.join("\n");
			},
			clear() {
				entries = [];
			},
			isEnabled() {
				return enabled;
			},
		};
	}

	// ════════════════════════════════════════════════════════════════════════════
	// RELEASES MODULE
	// ════════════════════════════════════════════════════════════════════════════

	const ReleasesModule = (() => {
		let html = "",
			md = "",
			items = [];
		let running = false;
		let paused = false; // true when stopped mid-fetch (cancel or rate limit)
		const dbgLog = createDebugLogger("[Releases]");
		const _dbg = (msg, data) => dbgLog.log(msg, data);

		// ── Auto-link GitHub references in release body markdown ──
		function autoLinkRefs(src, repo) {
			if (!src || !repo) return src;
			let r = src;
			r = r.replace(
				/(^|[^[(\w/])#(\d+)\b/gm,
				// biome-ignore lint/correctness/noUnusedFunctionParameters: m (full match) unused
				(m, pre, num) =>
					`${pre}[#${num}](https://github.com/${repo}/issues/${num})`,
			);
			r = r.replace(
				/(^|[^[(\w/])@([a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?)\b/gm,
				(m, pre, user) => {
					if (
						/^(param|media|import|charset|font-face|keyframes|supports|layer)$/i.test(
							user,
						)
					)
						return m;
					return `${pre}[@${user}](https://github.com/${user})`;
				},
			);
			r = r.replace(
				/(^|[^[(\w/])([0-9a-f]{40})\b/gm,
				// biome-ignore lint/correctness/noUnusedFunctionParameters: m (full match) unused
				(m, pre, sha) =>
					`${pre}[\`${sha.slice(0, 7)}\`](https://github.com/${repo}/commit/${sha})`,
			);
			r = r.replace(
				/(^|[^[(\w])([a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+)#(\d+)\b/gm,
				(m, pre, xrepo, num) => {
					if (xrepo === repo) return m;
					return `${pre}[${xrepo}#${num}](https://github.com/${xrepo}/issues/${num})`;
				},
			);
			return r;
		}

		function renderMd(src, repo) {
			if (!src) return "";
			try {
				if (typeof marked !== "undefined") {
					const renderer =
						typeof marked.parse === "function" ? marked : marked.marked;
					const processed = autoLinkRefs(src, repo);
					return renderer.parse(processed, {
						breaks: true,
						gfm: true,
					});
				}
			} catch (e) {
				log("marked parse error:", e);
			}
			return `<pre style="white-space:pre-wrap">${Utils.escapeHtml(src)}</pre>`;
		}

		function guessAssetType(name) {
			const n = name.toLowerCase();
			if (/\.(sig|asc|gpg)$/i.test(n)) return "Signature";
			if (/sha\d*sum|checksum|\.sha\d+$/i.test(n)) return "Checksum";
			if (/\.sigstore$/i.test(n)) return "Attestation";
			if (/\.(exe|msi|msix)$/i.test(n)) return "Installer";
			if (/\.(dmg|pkg)$/i.test(n)) return "macOS";
			if (/\.(deb|rpm|appimage|snap|flatpak)$/i.test(n)) return "Linux";
			if (/\.(tar\.gz|tar\.bz2|tar\.xz|tgz|zip|7z|rar)$/i.test(n))
				return "Archive";
			if (/\.(apk|aab|ipa)$/i.test(n)) return "Mobile";
			return "Asset";
		}

		function buildAssetList(rel) {
			const assets = [];
			for (const a of rel.assets || []) {
				assets.push({
					type: guessAssetType(a.name),
					name: a.name,
					size: a.size,
					download_count: a.download_count,
					uploaded: a.created_at ? a.created_at.slice(0, 10) : "",
					digest: a.digest || null,
					url: a.browser_download_url || "",
				});
			}
			if (rel.zipball_url)
				assets.push({
					type: "Source",
					name: "Source code (zip)",
					size: null,
					download_count: null,
					uploaded: "",
					digest: null,
					url: rel.zipball_url,
				});
			if (rel.tarball_url)
				assets.push({
					type: "Source",
					name: "Source code (tar.gz)",
					size: null,
					download_count: null,
					uploaded: "",
					digest: null,
					url: rel.tarball_url,
				});
			return assets;
		}

		function buildReactionsHtml(reactions) {
			if (!reactions?.total_count) return "";
			const emojis = {
				"+1": "👍",
				"-1": "👎",
				laugh: "😄",
				hooray: "🎉",
				confused: "😕",
				heart: "❤️",
				rocket: "🚀",
				eyes: "👀",
			};
			const parts = Object.entries(emojis)
				.filter(([k]) => reactions[k] > 0)
				.map(
					([k, e]) => `<span class="reaction-pill">${e} ${reactions[k]}</span>`,
				);
			return parts.length
				? `<div class="reactions-container">${parts.join("")}</div>`
				: "";
		}

		function buildContributorsHtml(rel) {
			// Extract unique non-bot contributors mentioned via @mentions in the body
			// and the release author
			const contributors = new Map();
			if (rel.author?.login && !rel.author.login.endsWith("[bot]")) {
				contributors.set(rel.author.login, {
					login: rel.author.login,
					avatar: rel.author.avatar_url,
					html_url: rel.author.html_url,
				});
			}
			// Try to extract @mentions from the body
			if (rel.body) {
				const mentionRe = /@([a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?)/g;
				let m;
				// biome-ignore lint/suspicious/noAssignInExpressions: intentional loop
				while ((m = mentionRe.exec(rel.body)) !== null) {
					const user = m[1];
					if (!contributors.has(user) && !user.endsWith("[bot]")) {
						contributors.set(user, {
							login: user,
							avatar: `https://avatars.githubusercontent.com/${user}?s=64&v=4`,
							html_url: `https://github.com/${user}`,
						});
					}
				}
			}
			if (contributors.size === 0) return "";
			const avatars = [...contributors.values()]
				.map(
					(c) =>
						`<a href="${c.html_url}" class="contributor-link"><img src="${c.avatar}" alt="@${Utils.escapeHtml(c.login)}" width="32" height="32" class="avatar-circle"></a>`,
				)
				.join("\n");
			const names = [...contributors.values()]
				.map((c) => Utils.escapeHtml(c.login))
				.join(", ");
			return `<div class="contributors-section"><h3>Contributors</h3><div class="contributor-avatars">${avatars}</div><div class="contributor-names">${names}</div></div>`;
		}

		function build() {
			const repo = Utils.repoFullName() || "?/?";
			buildHtml(repo, items);
			buildMd(repo, items);
		}

		/** Build HTML+MD from an arbitrary array of release objects. Returns { html, md }. */
		function buildFrom(releaseItems, repo) {
			const r = repo || Utils.repoFullName() || "?/?";
			let h = "",
				m = "";
			// Temporarily swap and build
			const prevHtml = html,
				prevMd = md;
			buildHtml(r, releaseItems);
			buildMd(r, releaseItems);
			h = html;
			m = md;
			html = prevHtml;
			md = prevMd;
			return { html: h, md: m };
		}

		function buildHtml(repo, releaseItems) {
			const title = `Releases — ${repo}`;
			let body = "";
			for (const rel of releaseItems) {
				const tag = rel.tag_name || "";
				const name = rel.name || tag;
				const authorLogin = rel.author?.login || "";
				const authorAvatar = rel.author?.avatar_url || "";
				const authorUrl =
					rel.author?.html_url || `https://github.com/${authorLogin}`;
				const date = rel.published_at || rel.created_at || "";
				const dateShort = date ? date.slice(0, 10) : "";
				const dateIso = date;
				const safeTitle = (name || "")
					.replace(/</g, "&lt;")
					.replace(/>/g, "&gt;");
				const bodyHtml = rel.body
					? renderMd(rel.body, repo)
					: "<em>No release notes.</em>";

				// Badges
				let badgesHtml = "";
				if (rel.prerelease) {
					badgesHtml += '<span class="label label-warning">Pre-release</span>';
				}
				if (rel.draft) {
					badgesHtml += '<span class="label label-draft">Draft</span>';
				}

				// Sidebar: date, author, tag, commit
				let sidebarHtml = '<div class="release-sidebar">';
				if (dateShort) {
					sidebarHtml += `<div class="sidebar-item"><time datetime="${Utils.escapeHtml(dateIso)}">${dateShort}</time></div>`;
				}
				if (authorLogin) {
					sidebarHtml += `<div class="sidebar-item">`;
					if (authorAvatar) {
						sidebarHtml += `<img src="${Utils.escapeHtml(authorAvatar)}&s=40" alt="@${Utils.escapeHtml(authorLogin)}" width="20" height="20" class="avatar-small"> `;
					}
					sidebarHtml += `<a href="${Utils.escapeHtml(authorUrl)}" class="muted-link">${Utils.escapeHtml(authorLogin)}</a></div>`;
				}
				sidebarHtml += `<div class="sidebar-item"><svg class="octicon" viewBox="0 0 16 16" width="16" height="16"><path d="M1 7.775V2.75C1 1.784 1.784 1 2.75 1h5.025c.464 0 .91.184 1.238.513l6.25 6.25a1.75 1.75 0 0 1 0 2.474l-5.026 5.026a1.75 1.75 0 0 1-2.474 0l-6.25-6.25A1.752 1.752 0 0 1 1 7.775Zm1.5 0c0 .066.026.13.073.177l6.25 6.25a.25.25 0 0 0 .354 0l5.025-5.025a.25.25 0 0 0 0-.354l-6.25-6.25a.25.25 0 0 0-.177-.073H2.75a.25.25 0 0 0-.25.25ZM6 5a1 1 0 1 1 0 2 1 1 0 0 1 0-2Z"></path></svg> <span class="wb-break-all">${Utils.escapeHtml(tag)}</span></div>`;
				if (rel.target_commitish) {
					const shortSha =
						rel.target_commitish.length > 7
							? rel.target_commitish.slice(0, 7)
							: rel.target_commitish;
					sidebarHtml += `<div class="sidebar-item"><svg class="octicon" viewBox="0 0 16 16" width="16" height="16"><path d="M11.93 8.5a4.002 4.002 0 0 1-7.86 0H.75a.75.75 0 0 1 0-1.5h3.32a4.002 4.002 0 0 1 7.86 0h3.32a.75.75 0 0 1 0 1.5Zm-1.43-.75a2.5 2.5 0 1 0-5 0 2.5 2.5 0 0 0 5 0Z"></path></svg> <code>${Utils.escapeHtml(shortSha)}</code></div>`;
				}
				sidebarHtml += "</div>";

				// Assets
				let assetsHtml = "";
				const allAssets = buildAssetList(rel);
				if (allAssets.length > 0) {
					const rows = allAssets
						.map((a) => {
							const sha = a.digest ? a.digest.replace(/^sha256:/i, "") : "";
							const shaShort = sha ? `${sha.slice(0, 8)}…${sha.slice(-6)}` : "";
							const shaCell = sha
								? `<code title="${Utils.escapeHtml(sha)}">${shaShort}</code>`
								: "";
							const dlCount =
								a.download_count != null
									? a.download_count.toLocaleString()
									: "";
							return `<tr><td>${a.type}</td><td><a href="${a.url}" target="_blank">${Utils.escapeHtml(a.name)}</a></td><td style="text-align:right">${Utils.formatBytes(a.size)}</td><td style="text-align:right">${dlCount}</td><td>${a.uploaded}</td><td>${shaCell}</td></tr>`;
						})
						.join("");
					assetsHtml = `<details open><summary class="assets-summary"><span class="assets-title">Assets</span> <span class="counter">${allAssets.length}</span></summary><table class="assets-table"><thead><tr><th>Type</th><th>File</th><th style="text-align:right">Size</th><th style="text-align:right">Downloads</th><th>Uploaded</th><th>SHA256</th></tr></thead><tbody>${rows}</tbody></table></details>`;
				}

				const reactionsHtml = buildReactionsHtml(rel.reactions);
				const contributorsHtml = buildContributorsHtml(rel);

				body += `<section class="release-section">
<div class="release-flex">
${sidebarHtml}
<div class="release-main">
  <div class="Box">
    <div class="Box-body">
      <div class="release-header">
        <div class="release-title-row">
          <h2 class="release-title"><a href="${rel.html_url}" target="_blank" class="Link--primary">${safeTitle}</a></h2>
          ${badgesHtml ? `<span class="release-badges">${badgesHtml}</span>` : ""}
        </div>
      </div>
      <div class="markdown-body">${bodyHtml}</div>
    </div>
    <div class="Box-footer">
      ${contributorsHtml}
      ${assetsHtml}
      ${reactionsHtml}
    </div>
  </div>
</div>
</div>
</section>`;
			}
			html = `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><title>${title}</title>
<style>
*{box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','Noto Sans',Helvetica,Arial,sans-serif;margin:0;padding:40px;background:#0d1117;color:#c9d1d9;line-height:1.5}
a{color:#58a6ff;text-decoration:none}
a:hover{text-decoration:underline}
h1{font-size:22px;margin-bottom:24px;color:#f0f6fc}
.release-section{margin-bottom:40px}
.release-flex{display:flex;flex-direction:row;gap:24px}
.release-sidebar{width:180px;flex-shrink:0;padding-top:4px;font-size:14px;color:#8b949e}
.release-sidebar .sidebar-item{margin-bottom:8px;display:flex;align-items:center;gap:6px;word-break:break-all}
.release-sidebar time{display:block}
.release-sidebar .avatar-small{border-radius:50%;vertical-align:middle}
.release-sidebar .muted-link{color:#8b949e}
.release-sidebar .muted-link:hover{color:#58a6ff}
.release-sidebar .octicon{fill:#8b949e;flex-shrink:0}
.release-sidebar code{font-size:12px;background:#21262d;padding:1px 4px;border-radius:3px}
.release-main{flex:1;min-width:0}
.Box{border:1px solid #30363d;border-radius:6px;overflow:hidden}
.Box-body{padding:16px}
.Box-footer{padding:16px;border-top:1px solid #30363d}
.release-header{margin-bottom:12px}
.release-title-row{display:flex;align-items:baseline;flex-wrap:wrap;gap:8px}
.release-title{margin:0;font-size:24px;font-weight:700}
.release-title a{color:#f0f6fc}
.release-title a:hover{color:#58a6ff}
.Link--primary{color:#f0f6fc}
.Link--primary:hover{color:#58a6ff}
.release-badges{display:inline-flex;gap:6px;align-items:center}
.label{display:inline-block;padding:2px 8px;font-size:12px;font-weight:600;border-radius:2em;line-height:1.5}
.label-warning{background:rgba(210,153,34,.2);color:#d29922;border:1px solid rgba(210,153,34,.4)}
.label-draft{background:rgba(110,119,129,.2);color:#8b949e;border:1px solid rgba(110,119,129,.4)}
.markdown-body{font-size:14px;line-height:1.6}
.markdown-body h1,.markdown-body h2,.markdown-body h3,.markdown-body h4{margin:16px 0 8px;color:#f0f6fc;border-bottom:1px solid #21262d;padding-bottom:4px}
.markdown-body h1{font-size:1.6em}.markdown-body h2{font-size:1.3em}.markdown-body h3{font-size:1.1em}
.markdown-body ul,.markdown-body ol{padding-left:24px;margin:8px 0}
.markdown-body li{margin:4px 0}
.markdown-body blockquote{border-left:3px solid #30363d;padding-left:12px;color:#8b949e;margin:8px 0}
.markdown-body p{margin:8px 0}
.markdown-body img{max-width:100%;height:auto;border-radius:6px}
.markdown-body hr{border:none;border-top:1px solid #30363d;margin:16px 0}
.markdown-body code{font-size:12px;background:#21262d;padding:1px 4px;border-radius:3px}
.markdown-body pre{background:#161b22;border:1px solid #30363d;border-radius:6px;padding:12px;overflow-x:auto;font-size:13px;line-height:1.45}
.markdown-body pre code{background:none;padding:0;font-size:inherit}
.markdown-body a.user-mention,.markdown-body a.commit-link{font-weight:600}
table{border-collapse:collapse}
.assets-summary{cursor:pointer;padding:8px 0;display:flex;align-items:center;gap:8px}
.assets-title{font-size:18px;font-weight:700}
.counter{display:inline-block;padding:0 6px;font-size:12px;font-weight:600;line-height:18px;color:#c9d1d9;background:rgba(110,118,129,.4);border-radius:2em}
.assets-table{width:100%;border-collapse:collapse;font-size:13px;margin-top:4px}
.assets-table thead tr{border-bottom:2px solid #30363d;text-align:left}
.assets-table th{padding:4px 8px;font-weight:600;color:#8b949e}
.assets-table td{padding:4px 8px;border-bottom:1px solid #21262d}
.assets-table tr:hover{background:#161b22}
.assets-table code{font-size:11px}
.contributors-section{margin-bottom:12px}
.contributors-section h3{margin:0 0 8px;font-size:14px;font-weight:600}
.contributor-avatars{display:flex;flex-wrap:wrap;gap:4px;margin-bottom:4px}
.contributor-link{display:inline-block}
.avatar-circle{border-radius:50%;vertical-align:middle}
.contributor-names{font-size:12px;color:#8b949e;margin-top:4px}
.reactions-container{display:flex;flex-wrap:wrap;gap:4px;margin-top:12px;align-items:center}
.reaction-pill{display:inline-flex;align-items:center;gap:4px;padding:2px 10px;font-size:12px;line-height:20px;border:1px solid #30363d;border-radius:2em;background:transparent;color:#c9d1d9;cursor:default}
.reaction-pill:hover{background:#21262d}
.wb-break-all{word-break:break-all}
@media (max-width:768px){
  .release-flex{flex-direction:column}
  .release-sidebar{width:100%;display:flex;flex-wrap:wrap;gap:8px 16px}
}
</style></head><body><h1>${title}</h1>${body}</body></html>`;
		}

		function buildMd(repo, releaseItems) {
			const lines = [`# Releases — ${repo}`, ""];
			for (const rel of releaseItems) {
				const tag = rel.tag_name || "";
				const name = rel.name || tag;
				const author = rel.author?.login || "";
				const date = rel.published_at ? rel.published_at.slice(0, 10) : "";
				const flags = [
					rel.draft ? "Draft" : "",
					rel.prerelease ? "Pre-release" : "",
				]
					.filter(Boolean)
					.join(", ");
				lines.push(`## ${name}${tag ? ` (\`${tag}\`)` : ""}`);
				const meta = [author, date, flags].filter(Boolean).join(" · ");
				if (meta) lines.push(`*${meta}*`);
				lines.push("");
				if (rel.body) {
					lines.push(rel.body.trim());
					lines.push("");
				}
				const allAssets = buildAssetList(rel);
				if (allAssets.length > 0) {
					lines.push(
						"### Assets",
						"",
						"| Type | File | Size | Downloads | Uploaded | SHA256 |",
						"|:-----|:-----|-----:|----------:|:---------|:-------|",
					);
					for (const a of allAssets) {
						const sha = a.digest ? a.digest.replace(/^sha256:/i, "") : "";
						const shaShort = sha ? `${sha.slice(0, 8)}…${sha.slice(-6)}` : "";
						const shaCell = sha ? `\`${shaShort}\`` : "";
						const dlCount =
							a.download_count != null ? a.download_count.toLocaleString() : "";
						const fileCell = a.url ? `[${a.name}](${a.url})` : a.name;
						lines.push(
							`| ${a.type} | ${fileCell} | ${Utils.formatBytes(a.size)} | ${dlCount} | ${a.uploaded} | ${shaCell} |`,
						);
					}
					lines.push("");
				}
				if (rel.reactions && rel.reactions.total_count > 0) {
					const emojis = {
						"+1": "👍",
						"-1": "👎",
						laugh: "😄",
						hooray: "🎉",
						confused: "😕",
						heart: "❤️",
						rocket: "🚀",
						eyes: "👀",
					};
					const parts = Object.entries(emojis)
						.filter(([k]) => rel.reactions[k] > 0)
						.map(([k, e]) => `${e} ${rel.reactions[k]}`);
					if (parts.length) lines.push(`Reactions: ${parts.join(" · ")}`, "");
				}
				lines.push("---", "");
			}
			md = lines.join("\n");
		}

		function setDebug(on) {
			dbgLog.setEnabled(on);
		}
		function getDebugLog() {
			return dbgLog.getText();
		}
		function clearDebugLog() {
			dbgLog.clear();
		}

		let releaseFilter = null;

		/**
		 * Normalize a partial date string to a full YYYY-MM-DD range.
		 *   "2009"       → { from: "2009-01-01", to: "2009-12-31" }
		 *   "2009-06"    → { from: "2009-06-01", to: "2009-06-30" }
		 *   "2009-06-15" → { from: "2009-06-15", to: "2009-06-15" }
		 */
		function expandPartialDate(dateStr) {
			const d = dateStr.replace(/\//g, "-");
			// Full date: YYYY-MM-DD
			if (/^\d{4}-\d{2}-\d{2}$/.test(d)) {
				return { from: d, to: d };
			}
			// Year-month: YYYY-MM
			if (/^\d{4}-\d{2}$/.test(d)) {
				const [y, m] = d.split("-").map(Number);
				const lastDay = new Date(y, m, 0).getDate();
				return {
					from: `${d}-01`,
					to: `${d}-${String(lastDay).padStart(2, "0")}`,
				};
			}
			// Year only: YYYY
			if (/^\d{4}$/.test(d)) {
				return { from: `${d}-01-01`, to: `${d}-12-31` };
			}
			return null;
		}

		/**
		 * Parse release filter string.
		 * Supports:
		 *   v1.*                   → tag glob
		 *   v1.0.0-v2.0.0         → tag range (- separator, needs v-prefix or dots)
		 *   v1.0.0..v2.0.0        → tag range (.. separator)
		 *   2024-01-01..2025-06-01 → date range (full dates, .. only)
		 *   2009..2010             → date range (year to year)
		 *   2009-06..2010-03       → date range (month to month)
		 *   2009                   → single year (all releases in 2009)
		 *   2009-06                → single month (all releases in June 2009)
		 *   2009-06-15             → single day
		 *   v2.35.1                → exact tag
		 *
		 * Note: date ranges ONLY use ".." as separator because "-" is ambiguous
		 * with YYYY-MM-DD formatting. Tag ranges accept both "-" and ".." since
		 * tags use dots (v1.0.0) not dashes for structure.
		 */
		function parseReleaseFilter(str) {
			if (!str?.trim()) return null;
			const s = str.trim();

			// Date range with .. separator: "2009..2010", "2024-01..2025-06", "2024-01-01..2025-06-01"
			const dateDotDot = s.match(
				/^(\d{4}(?:[-/]\d{2}(?:[-/]\d{2})?)?)\.\.(\d{4}(?:[-/]\d{2}(?:[-/]\d{2})?)?)$/,
			);
			if (dateDotDot) {
				const fromExp = expandPartialDate(dateDotDot[1]);
				const toExp = expandPartialDate(dateDotDot[2]);
				if (fromExp && toExp) {
					return { type: "date", from: fromExp.from, to: toExp.to };
				}
			}

			// Single date: "2009", "2009-06", "2009-06-15"
			// Only match if the string is purely a date pattern (no letters, no dots beyond date separators)
			const normalizedForDateCheck = s.replace(/\//g, "-");
			if (/^\d{4}(?:-\d{2}(?:-\d{2})?)?$/.test(normalizedForDateCheck)) {
				const singleDate = expandPartialDate(s);
				if (singleDate) {
					return { type: "date", from: singleDate.from, to: singleDate.to };
				}
			}

			// Tag range: v1.0.0-v2.0.0 or v1.0.0..v2.0.0
			// At least one side must have a v-prefix or contain a dot to distinguish
			// from date expressions. Pure numeric ranges are not treated as tag ranges.
			const tagRange = s.match(
				/^(v?\d+\S*?)(?:\s*[-–]\s*|\s*\.\.\s*)(v?\d+\S*)$/i,
			);
			if (tagRange) {
				const left = tagRange[1];
				const right = tagRange[2];
				const hasTagChars = /[v.]/i.test(left) || /[v.]/i.test(right);
				if (hasTagChars) {
					return { type: "tagRange", from: left, to: right };
				}
			}

			// Glob: v1.*, v2.0.*
			if (s.includes("*")) {
				// Escape regex special chars (split to avoid character class parse issues)
				const escaped = s
					.replace(/\\/g, "\\\\")
					.replace(/[.+^${}()|]/g, "\\$&")
					.replace(/$/g, "$")
					.replace(/$/g, "$")
					.replace(/\*/g, ".*");
				const regex = new RegExp("^" + escaped + "$", "i");
				return { type: "glob", regex };
			}

			// Exact tag
			return { type: "exact", tag: s };
		}

		function matchesReleaseFilter(rel, filter) {
			if (!filter) return true;
			const tag = rel.tag_name || "";
			const date = (rel.published_at || rel.created_at || "").slice(0, 10);

			switch (filter.type) {
				case "exact":
					return tag.toLowerCase() === filter.tag.toLowerCase();
				case "glob":
					return filter.regex.test(tag);
				case "date": {
					if (!date) return false;
					return date >= filter.from && date <= filter.to;
				}
				case "tagRange": {
					const normTag = (t) => t.replace(/^v/i, "");
					const normFrom = normTag(filter.from);
					const normTo = normTag(filter.to);
					const normCur = normTag(tag);
					// Pad segments for numeric comparison: 1.2.3 → 00001.00002.00003
					const padVer = (v) =>
						v
							.split(/[.-]/)
							.map((p) => (/^\d+$/.test(p) ? p.padStart(10, "0") : p))
							.join(".");
					const pFrom = padVer(normFrom);
					const pTo = padVer(normTo);
					const pCur = padVer(normCur);
					const lo = pFrom <= pTo ? pFrom : pTo;
					const hi = pFrom <= pTo ? pTo : pFrom;
					return pCur >= lo && pCur <= hi;
				}
				default:
					return true;
			}
		}

		function setReleaseFilter(str) {
			releaseFilter = parseReleaseFilter(str);
		}

		let resumePage = 1;

		async function fetchAll(cb) {
			const repo = Utils.repoFullName();
			if (!repo) return false;
			const resuming = paused && items.length > 0;
			running = true;
			paused = false;
			if (!resuming) {
				items = [];
				resumePage = 1;
			}
			const [owner, name] = repo.split("/");
			let page = resumePage;

			_dbg(
				resuming
					? "═══ RESUMING RELEASE FETCH ═══"
					: "═══ RELEASE FETCH START ═══",
			);
			if (resuming)
				_dbg(`Resuming from page ${page} with ${items.length} items`);

			while (running) {
				const url = `/repos/${owner}/${name}/releases?per_page=100&page=${page}`;
				_dbg(`→ GET ${url}`);
				cb(`Fetching page ${page}…`);

				let data;
				try {
					const resp = await API.request(url);
					data = resp.data;
					_dbg(
						`← OK | RL remaining: ${API.rateLimit.core.remaining} | reset: ${Utils.formatReset(API.rateLimit.core.reset)}`,
					);
				} catch (err) {
					_dbg(`✗ ${err.message}`);
					running = false;
					paused = true;
					resumePage = page;
					if (err.message.includes("Rate limit")) {
						cb(
							`⚠️ Rate limited. ${items.length} releases fetched so far. Click Resume when limit resets.`,
						);
					} else {
						cb(`Error: ${err.message}`);
					}
					return false;
				}

				if (!Array.isArray(data)) {
					running = false;
					return false;
				}
				_dbg(`  Received ${data.length} releases (page ${page})`);
				if (releaseFilter) {
					const matched = data.filter((r) =>
						matchesReleaseFilter(r, releaseFilter),
					);
					_dbg(`  Filter matched: ${matched.length}/${data.length}`);
					items.push(...matched);
				} else {
					items.push(...data);
				}
				cb(`Fetched ${items.length} releases…`);
				if (data.length < 100) break;
				page++;
				resumePage = page;
				await Utils.delay(280);
			}

			// If stopped by cancel (running became false mid-loop), mark as paused
			if (!running && items.length > 0 && page <= 500) {
				paused = true;
				resumePage = page;
				if (items.length > 0) {
					build();
				}
				cb(`Paused. ${items.length} releases fetched so far.`);
				return false;
			}

			running = false;
			if (items.length === 0) {
				cb("No releases found.");
				return false;
			}
			build();
			_dbg(`═══ RELEASE FETCH DONE · ${items.length} releases ═══`);
			cb(`Done · ${items.length} releases`);
			return true;
		}

		function cancel() {
			running = false;
		}

		function isPaused() {
			return paused;
		}

		function state() {
			const s = API.rateLimitSummary;
			return {
				count: items.length,
				running,
				rlRemain: s.remaining,
				rlReset: s.reset,
				hasData: html.length > 0,
				finished: !running && items.length > 0 && !paused,
				paused,
			};
		}

		return {
			fetchAll,
			cancel,
			setDebug,
			getDebugLog,
			clearDebugLog,
			state,
			build,
			buildFrom,
			setReleaseFilter,
			isPaused,
			_getItems: () => [...items],
			getHtml: () => html,
			getMd: () => md,
			hasData: () => html.length > 0,
			reset: () => {
				items = [];
				html = "";
				md = "";
				running = false;
				paused = false;
				resumePage = 1;
				releaseFilter = null;
			},
		};
	})();

	// ════════════════════════════════════════════════════════════════════════════
	// REPO INDEX MODULE (Issues / PRs / Discussions listing)
	// ════════════════════════════════════════════════════════════════════════════

	const RepoIndexModule = (() => {
		let items = [],
			lowest = Infinity,
			highest = 0,
			running = false;
		let curPage = 1,
			rangeFilter = null;
		let html = "",
			md = "";
		let paused = false,
			gqlBatchIndex = 0,
			discussionCursor = null;
		let fetchOpts = {};

		const CFG = { perPage: 100, delayMs: 280, maxPages: 500 };
		const dbgLog = createDebugLogger("[Index]");
		const _dbg = (msg, data) => dbgLog.log(msg, data);

		function setDebug(on) {
			dbgLog.setEnabled(on);
		}
		function getDebugLog() {
			return dbgLog.getText();
		}
		function clearDebugLog() {
			dbgLog.clear();
		}

		// ── Range parsing ──

		function parseRange(str) {
			if (str === null || str === undefined || !String(str).trim()) return null;
			const parts = String(str)
				.split(",")
				.map((s) => s.trim())
				.filter(Boolean);
			if (parts.length === 0) return null;
			const ranges = [];
			for (const part of parts) {
				const m = part.match(/^(\d+)\s*-\s*(\d+)$/);
				if (m) {
					const lo = parseInt(m[1], 10),
						hi = parseInt(m[2], 10);
					if (!Number.isNaN(lo) && !Number.isNaN(hi))
						ranges.push([Math.min(lo, hi), Math.max(lo, hi)]);
				} else {
					const n = parseInt(part, 10);
					if (!Number.isNaN(n) && n > 0) ranges.push([n, n]);
				}
			}
			return ranges.length > 0 ? ranges : null;
		}

		function inRange(num) {
			if (!rangeFilter) return true;
			return rangeFilter.some(([lo, hi]) => num >= lo && num <= hi);
		}

		function setRange(rangeStr) {
			rangeFilter = parseRange(rangeStr);
			if (dbgLog.isEnabled())
				_dbg("Range set:", rangeFilter ? JSON.stringify(rangeFilter) : "none");
		}

		// ── REST URL builder ──

		function apiUrl(page, type) {
			const repo = Utils.repoFullName();
			if (!repo) return null;
			const [o, n] = repo.split("/");
			const endpoint = type === "pulls" ? "pulls" : "issues";
			const u = new URL(`${API_BASE}/repos/${o}/${n}/${endpoint}`);
			u.searchParams.set("state", "all");
			u.searchParams.set("per_page", CFG.perPage);
			u.searchParams.set("page", page);
			u.searchParams.set("sort", "created");
			u.searchParams.set("direction", "asc");
			return u.toString();
		}

		// ── Build output ──

		function build(opts) {
			const repo = Utils.repoFullName() || "?/?";
			const selParts = [];
			if (!opts || opts.issues) selParts.push("Issues");
			if (!opts || opts.prs) selParts.push("Pull Requests");
			if (!opts || opts.discussions) selParts.push("Discussions");
			const selLabel =
				selParts.length === 3
					? "Issues, Pull Requests & Discussions"
					: selParts.length === 2
						? `${selParts[0]} & ${selParts[1]}`
						: selParts[0] || "Items";
			const title = `${selLabel} Index — ${repo}`;

			const typed = items
				.map((i) => {
					if (i.pull_request) return { ...i, _k: "pr", _l: "Pull Request" };
					if (i.discussion) return { ...i, _k: "discussion", _l: "DISCUSSION" };
					return { ...i, _k: "issue", _l: "ISSUE" };
				})
				.sort((a, b) => a.number - b.number);

			const c = { issue: 0, pr: 0, discussion: 0 };
			// biome-ignore lint/suspicious/useIterableCallbackReturn: increment has side effect, return unused
			typed.forEach((i) => c[i._k]++);
			const summary = `Total: ${items.length} (Issues: ${c.issue} · Pull Requests: ${c.pr} · Discussions: ${c.discussion})`;

			// ── HTML output ──
			const rows = typed
				.map((i) => {
					const isDsc = i._k === "discussion";
					const st = isDsc
						? i.isAnswered
							? { l: "ANSWERED", c: "closed" }
							: { l: "OPEN", c: "open" }
						: i.state === "open"
							? { l: "OPEN", c: "open" }
							: i.state_reason === "not_planned"
								? { l: "NOT PLANNED", c: "not-planned" }
								: { l: "CLOSED", c: "closed" };
					const labels = (i.labels || [])
						.map((l) => (typeof l === "object" ? l.name : l))
						.join(", ");
					const meta = [
						i._l,
						st.l,
						`${new Date(i.created_at).toISOString().slice(0, 10)}`,
						labels ? labels : "",
					]
						.filter(Boolean)
						.join(" · ");
					const safe = (i.title || "")
						.replace(/</g, "&lt;")
						.replace(/>/g, "&gt;");
					return `<li class="item ${st.c} ${i._k}"><a href="${i.html_url}" target="_blank">#${i.number} · ${safe}</a><div class="meta">${meta}</div></li>`;
				})
				.join("");

			html = `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><title>${title}</title>
<style>body{font-family:system-ui;margin:40px;background:#0d1117;color:#c9d1d9;line-height:1.5}
a{color:#58a6ff;text-decoration:none}h1{font-size:22px;margin-bottom:8px}
.summary{color:#8b949e;margin-bottom:20px}ul{list-style:none;padding:0}
li{padding:10px 0;border-bottom:1px solid #30363d}.meta{font-size:13px;color:#8b949e;margin-top:4px}
.open a{color:#3fb950}.pr a{color:#58a6ff}.discussion a{color:#8957e5}
.closed a,.not-planned a{color:#f85149}
.note{background:#161b22;border:1px solid #30363d;border-radius:6px;padding:10px 14px;margin-bottom:16px;font-size:12px;color:#8b949e}</style></head><body>
<h1>${title}</h1>
<div class="note">ℹ️ This is an index listing — titles, status, and links only. Full content (descriptions, comments, diffs) is not included.</div>
<div class="summary">Repository: <strong>${repo}</strong><br>${summary}</div>
<ul>${rows}</ul></body></html>`;

			// ── Markdown output ──
			const mdl = [
				`# ${title}`,
				"",
				`> ℹ️ This is an index listing — titles, status, and links only. Full content (descriptions, comments, diffs) is not included.`,
				"",
				`Repository: \`${repo}\``,
				`${summary}`,
				"",
			];
			typed.forEach((i) => {
				const isDsc = i._k === "discussion";
				const stLabel = isDsc
					? i.isAnswered
						? "ANSWERED"
						: "OPEN"
					: i.state === "open"
						? "OPEN"
						: i.state_reason === "not_planned"
							? "NOT PLANNED"
							: "CLOSED";
				mdl.push(
					`- [#${i.number} ${i.title}](${i.html_url}) — ${i._l} · **${stLabel}**`,
				);
			});
			mdl.push("");
			md = mdl.join("\n");
		}

		// ── Count total numbers in a range filter ──
		function rangeCount() {
			if (!rangeFilter) return Infinity;
			let total = 0;
			for (const [lo, hi] of rangeFilter) total += hi - lo + 1;
			return total;
		}

		// ── Direct REST lookup for small ranges (no pagination waste) ──
		async function fetchByDirectREST(cb, wantIssues, wantPRs) {
			const numbers = [];
			for (const [lo, hi] of rangeFilter) {
				for (let n = lo; n <= hi; n++) numbers.push(n);
			}
			_dbg(`Direct REST lookup: ${numbers.length} numbers`);
			const repo = Utils.repoFullName();
			if (!repo) return;
			const [owner, name] = repo.split("/");

			for (let i = 0; i < numbers.length && running; i++) {
				const num = numbers[i];
				if (items.some((e) => e.number === num)) continue;
				cb(`Fetching #${num} (${i + 1}/${numbers.length})…`);
				_dbg(`→ GET /repos/${owner}/${name}/issues/${num}`);

				try {
					const { data } = await API.request(
						`/repos/${owner}/${name}/issues/${num}`,
					);
					const isPR = !!data.pull_request;

					if (isPR && !wantPRs) {
						_dbg(`  #${num} is PR, skipped`);
						continue;
					}
					if (!isPR && !wantIssues) {
						_dbg(`  #${num} is issue, skipped`);
						continue;
					}

					items.push(data);
					if (data.number < lowest) lowest = data.number;
					if (data.number > highest) highest = data.number;
					_dbg(`  Added #${num} (${isPR ? "PR" : "issue"})`);
				} catch (e) {
					if (e.message.includes("404")) {
						_dbg(`  #${num} not found (may be discussion or deleted)`);
					} else if (e.message.includes("Rate limit")) {
						_dbg(`⚠ Rate limited at #${num}`);
						paused = true;
						running = false;
						cb();
						return;
					} else {
						_dbg(`  #${num} error: ${e.message}`);
					}
				}

				build(fetchOpts);
				cb();
				if (i < numbers.length - 1) await Utils.delay(CFG.delayMs);
			}
		}

		// ── REST fetch: Issues and/or PRs ──

		async function fetchIssuesPRs(cb, wantIssues, wantPRs) {
			// GraphQL batch when range + token
			if (rangeFilter && Token.has()) {
				_dbg("Range + token → using GraphQL batch lookup");
				await fetchByGraphQL(cb, wantIssues, wantPRs);
				return;
			}
			// Direct REST for small ranges without token (≤50 items)
			if (rangeFilter && !Token.has() && rangeCount() <= 50) {
				_dbg(`Range (${rangeCount()} items) + no token → direct REST lookup`);
				await fetchByDirectREST(cb, wantIssues, wantPRs);
				return;
			}
			if (rangeFilter && !Token.has()) {
				_dbg(
					"Range set but too large for direct REST without token → paginated fallback",
				);
			}

			const endpoints = [];
			if (wantIssues && wantPRs) {
				endpoints.push({ type: "issues", wantIssues: true, wantPRs: true });
			} else if (wantIssues) {
				endpoints.push({ type: "issues", wantIssues: true, wantPRs: false });
			} else if (wantPRs) {
				endpoints.push({ type: "pulls", wantIssues: false, wantPRs: true });
			}

			for (const ep of endpoints) {
				let page = ep.type === "pulls" ? 1 : curPage;
				let emptyStreak = 0;
				const maxEmptyPages = 3;

				_dbg(
					`Endpoint: /${ep.type} | wantIssues=${ep.wantIssues} wantPRs=${ep.wantPRs}`,
				);

				while (running && page <= CFG.maxPages) {
					const url = apiUrl(page, ep.type);
					cb();
					_dbg(`→ GET ${url}`);

					let data;
					try {
						const resp = await API.request(url);
						data = resp.data;
						_dbg(
							`← OK | RL remaining: ${API.rateLimit.core.remaining} | reset: ${Utils.formatReset(API.rateLimit.core.reset)}`,
						);
					} catch (err) {
						_dbg(`✗ ${err.message}`);
						if (err.message.includes("Rate limit")) {
							paused = true;
							curPage = page;
							running = false;
							cb();
							return;
						}
						await Utils.delay(5000);
						continue;
					}

					if (!Array.isArray(data)) {
						_dbg("✗ Not an array.");
						break;
					}
					_dbg(`  Received ${data.length} items (page ${page})`);

					let added = 0,
						skipped = { dup: 0, range: 0, filter: 0 };
					for (const i of data) {
						if (items.some((e) => e.number === i.number)) {
							skipped.dup++;
							continue;
						}
						if (!inRange(i.number)) {
							skipped.range++;
							continue;
						}
						const isPR = !!i.pull_request;
						if (ep.type === "issues") {
							if (isPR && !ep.wantPRs) {
								skipped.filter++;
								continue;
							}
							if (!isPR && !ep.wantIssues) {
								skipped.filter++;
								continue;
							}
						}
						if (ep.type === "pulls" && !i.pull_request) {
							i.pull_request = { url: i.url };
						}
						items.push(i);
						if (i.number < lowest) lowest = i.number;
						if (i.number > highest) highest = i.number;
						added++;
					}

					_dbg(
						`  Added: ${added} | Skipped — dup: ${skipped.dup}, range: ${skipped.range}, filter: ${skipped.filter}`,
					);

					if (added === 0 && data.length > 0 && skipped.dup === data.length) {
						_dbg("  All duplicates. Stopping.");
						break;
					}

					if (added === 0 && data.length > 0) {
						emptyStreak++;
						_dbg(`  0 matched (streak: ${emptyStreak}/${maxEmptyPages})`);
						if (emptyStreak >= maxEmptyPages) {
							_dbg("  Too many empty pages.");
							break;
						}
					} else {
						emptyStreak = 0;
					}

					build(fetchOpts);
					cb();
					if (data.length < CFG.perPage) break;
					page++;
					if (ep.type !== "pulls") curPage = page;
					await Utils.delay(CFG.delayMs);
				}
			}
		}

		// ── GraphQL batch fetch (for range + token) ──

		async function fetchByGraphQL(cb, wantIssues, wantPRs) {
			const numbers = [];
			for (const [lo, hi] of rangeFilter) {
				for (let n = lo; n <= hi; n++) numbers.push(n);
			}
			_dbg(
				`GraphQL batch: ${numbers.length} numbers (#${numbers[0]}–#${numbers[numbers.length - 1]}), starting at index ${gqlBatchIndex}`,
			);

			const repo = Utils.repoFullName();
			if (!repo) return;
			const [owner, name] = repo.split("/");
			const batchSize = 50;

			for (
				let i = gqlBatchIndex;
				i < numbers.length && running;
				i += batchSize
			) {
				const batch = numbers.slice(i, i + batchSize);
				const batchNum = Math.floor(i / batchSize) + 1;
				const totalBatches = Math.ceil(numbers.length / batchSize);
				cb(
					`GraphQL batch ${batchNum}/${totalBatches} (#${batch[0]}–#${batch[batch.length - 1]})…`,
				);

				const fields = batch
					.map(
						(n) =>
							`i${n}: issueOrPullRequest(number: ${n}) {
            __typename
            ... on Issue { number title state stateReason url createdAt updatedAt labels(first:20) { nodes { name } } }
            ... on PullRequest { number title state url createdAt updatedAt labels(first:20) { nodes { name } } }
          }`,
					)
					.join("\n");

				const query = `{ repository(owner: "${owner}", name: "${name}") {\n${fields}\n} }`;
				_dbg(`→ POST /graphql (batch ${batchNum}/${totalBatches})`);

				let j;
				try {
					// Use API.request for /graphql to get rate limit tracking, but handle errors/NOT_FOUND ourselves
					const resp = await API.request("/graphql", {
						method: "POST",
						headers: { "Content-Type": "application/json" },
						body: JSON.stringify({ query }),
					});
					j = resp.data;
					_dbg(`← OK | RL remaining: ${API.rateLimit.graphql.remaining}`);
				} catch (err) {
					_dbg(`✗ ${err.message}`);
					if (err.message.includes("Rate limit")) {
						gqlBatchIndex = i;
						paused = true;
						running = false;
						cb();
						return;
					}
					await Utils.delay(5000);
					continue;
				}

				if (j.errors) {
					const realErrors = j.errors.filter((e) => e.type !== "NOT_FOUND");
					if (realErrors.length) _dbg("✗ GraphQL errors:", realErrors);
					const notFound = j.errors.filter(
						(e) => e.type === "NOT_FOUND",
					).length;
					if (notFound) _dbg(`  ${notFound} numbers not found`);
				}

				const repoData = j.data?.repository || j.repository || {};
				let added = 0,
					skippedType = 0;
				for (const key of Object.keys(repoData)) {
					const item = repoData[key];
					if (!item) continue;
					const isIssue = item.__typename === "Issue";
					const isPR = item.__typename === "PullRequest";
					if (isIssue && !wantIssues) {
						skippedType++;
						continue;
					}
					if (isPR && !wantPRs) {
						skippedType++;
						continue;
					}
					if (items.some((e) => e.number === item.number)) continue;

					const normalized = {
						number: item.number,
						title: item.title,
						state: item.state === "OPEN" ? "open" : "closed",
						state_reason:
							item.stateReason === "NOT_PLANNED" ? "not_planned" : null,
						html_url: item.url,
						created_at: item.createdAt,
						updated_at: item.updatedAt,
						labels: (item.labels?.nodes || []).map((l) => ({ name: l.name })),
					};
					if (isPR) normalized.pull_request = { url: item.url };
					items.push(normalized);
					if (item.number < lowest) lowest = item.number;
					if (item.number > highest) highest = item.number;
					added++;
				}

				_dbg(`  Added: ${added} | Skipped (type): ${skippedType}`);
				gqlBatchIndex = i + batchSize;
				build(fetchOpts);
				cb();
				await Utils.delay(CFG.delayMs);
			}
		}

		// ── Discussions: handle cancel/rate-limit as pause ──

		async function fetchDiscussions(cb) {
			const repo = Utils.repoFullName();
			if (!repo) return;
			const [owner, name] = repo.split("/");

			let after = discussionCursor;
			_dbg(
				`Discussions fetch for ${owner}/${name}${after ? " (resuming)" : ""}`,
			);

			do {
				const q = `{repository(owner:"${owner}",name:"${name}"){discussions(first:${CFG.perPage}${after ? `,after:"${after}"` : ""}){pageInfo{endCursor hasNextPage}edges{node{number title url createdAt updatedAt isAnswered labels(first:100){edges{node{name}}}}}}}}`;
				_dbg(`→ POST /graphql (discussions, after=${after || "null"})`);

				let j;
				try {
					const resp = await API.request("/graphql", {
						method: "POST",
						headers: { "Content-Type": "application/json" },
						body: JSON.stringify({ query: q }),
					});
					j = resp.data;
					_dbg(`← OK | RL remaining: ${API.rateLimit.graphql.remaining}`);
				} catch (err) {
					_dbg(`✗ ${err.message}`);
					if (err.message.includes("Rate limit")) {
						discussionCursor = after;
						paused = true;
						running = false;
						cb();
						return;
					}
					await Utils.delay(5000);
					continue;
				}

				if (j.errors) {
					_dbg("✗ GraphQL errors:", j.errors);
					break;
				}

				const edges = j.data?.repository?.discussions?.edges || [];
				_dbg(`  Received ${edges.length} discussions`);

				for (const e of edges) {
					const i = e.node;
					i.html_url = i.url;
					i.discussion = true;
					i.created_at = i.createdAt;
					i.updated_at = i.updatedAt;
					i.labels = i.labels.edges.map((x) => x.node.name);
					if (items.some((x) => x.number === i.number)) continue;
					if (!inRange(i.number)) continue;
					items.push(i);
					if (i.number < lowest) lowest = i.number;
					if (i.number > highest) highest = i.number;
				}

				build(fetchOpts);
				cb();
				const pageData =
					j.data?.repository?.discussions || j.repository?.discussions;
				after = pageData?.pageInfo?.endCursor;
				discussionCursor = after;
				if (!pageData?.pageInfo?.hasNextPage) break;
				await Utils.delay(CFG.delayMs);
			} while (running && after);
		}

		// ── Main entry point ──

		let generationCounter = 0;

		async function run(cb, opts) {
			fetchOpts = opts;
			const resuming = paused && items.length > 0;
			paused = false;
			running = true;
			_dbg(resuming ? "═══ RESUMING EXPORT ═══" : "═══ EXPORT START ═══");
			_dbg("Options:", {
				issues: opts.issues,
				prs: opts.prs,
				discussions: opts.discussions,
			});
			_dbg("Range:", rangeFilter ? JSON.stringify(rangeFilter) : "none");
			_dbg("Token:", Token.has() ? "set" : "not set");
			if (resuming)
				_dbg(
					`Resuming with ${items.length} items (page ${curPage}, gqlBatch ${gqlBatchIndex})`,
				);
			cb();
			if (opts.issues || opts.prs)
				await fetchIssuesPRs(cb, opts.issues, opts.prs);
			if (running && opts.discussions) {
				// fetchDiscussions is defined above (replaced duplicate below)
				const repo = Utils.repoFullName();
				if (repo && Token.has()) {
					await fetchDiscussions(cb);
				} else if (repo && !Token.has()) {
					_dbg("Discussions require token — skipping");
				}
			}
			// If stopped mid-run by cancel, mark as paused
			if (!running && items.length > 0) {
				paused = true;
				build(fetchOpts);
				cb();
			}
			running = false;
			generationCounter++;
			build(fetchOpts);
			_dbg(`═══ EXPORT DONE · ${items.length} items ═══`);
			cb();
		}

		function cancel() {
			running = false;
		}

		function state() {
			const s = API.rateLimitSummary;
			return {
				count: items.length,
				lowest,
				highest,
				running,
				rlRemain: s.remaining,
				rlReset: s.reset,
				hasData: html.length > 0,
				finished: !running && items.length > 0 && !paused,
				paused,
			};
		}

		function fileSlug() {
			const parts = [];
			if (
				!fetchOpts ||
				(!fetchOpts.issues && !fetchOpts.prs && !fetchOpts.discussions)
			) {
				parts.push("items");
			} else {
				if (fetchOpts.issues) parts.push("issues");
				if (fetchOpts.prs) parts.push("prs");
				if (fetchOpts.discussions) parts.push("discussions");
			}
			return parts.join("-");
		}

		return {
			run,
			cancel,
			setRange,
			setDebug,
			getDebugLog,
			clearDebugLog,
			state,
			fileSlug,
			getHtml: () => html,
			getMd: () => md,
			isPaused: () => paused,
			/** Return a shallow copy of the collected items for batch export */
			_getItems: () => [...items],
			/** Generation counter — incremented each time a fetch completes */
			get _generation() {
				return generationCounter;
			},
			reset: () => {
				items = [];
				lowest = Infinity;
				highest = 0;
				html = "";
				md = "";
				running = false;
				curPage = 1;
				rangeFilter = null;
				fetchOpts = {};
				paused = false;
				gqlBatchIndex = 0;
				discussionCursor = null;
			},
		};
	})();

	// ════════════════════════════════════════════════════════════════════════════
	// MARKDOWN GENERATOR HELPERS (shared across issue/PR/discussion)
	// ════════════════════════════════════════════════════════════════════════════

	const MdGen = {
		// ── Shared helpers ──

		_repoFromUrl(htmlUrl) {
			const m = (htmlUrl || "").match(/github\.com\/([^/]+)\/([^/]+)/);
			return m ? `${m[1]}/${m[2]}` : "";
		},

		_hasLabel(obj, name) {
			return (obj?.labels || []).some(
				(l) =>
					String(l?.name || "").toLowerCase() ===
					String(name || "").toLowerCase(),
			);
		},

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

		_commitApiToHtml(apiUrl, sha) {
			try {
				const m = String(apiUrl || "").match(
					/api\.github\.com\/repos\/([^/]+)\/([^/]+)\/commits\/([a-f0-9]+)/i,
				);
				if (m) return `https://github.com/${m[1]}/${m[2]}/commit/${m[3]}`;
			} catch {}
			return sha ? `https://github.com/commit/${sha}` : String(apiUrl || "");
		},

		// ── Header (shared for issue/PR) ──

		generateHeader(issue, cfg, prData, requestedReviewers) {
			const lines = [];
			const isPR = !!issue.pull_request || !!prData;
			const repo = this._repoFromUrl(issue.html_url);
			// biome-ignore lint/correctness/noUnusedVariables: destructured for clarity, may be used later
			const [owner, repoName] = repo.split("/");

			lines.push(`> **Repository:** [${repo}](https://github.com/${repo})  `);
			const typeLabel = isPR ? "Pull Request" : "Issue";
			lines.push(`> **${typeLabel}:** [#${issue.number}](${issue.html_url})  `);

			// State
			if (isPR && prData) {
				if (prData.merged) lines.push(`> **State:** 🟣 merged  `);
				else if (prData.draft) lines.push(`> **State:** ⚪ draft  `);
				else if (issue.state === "open") lines.push(`> **State:** 🟢 open  `);
				else lines.push(`> **State:** 🔴 closed  `);
			} else {
				const emoji =
					issue.state === "open"
						? "🟢"
						: issue.state_reason === "completed"
							? "🟣"
							: "🔴";
				const label =
					issue.state === "closed"
						? issue.state_reason
							? `closed (${issue.state_reason})`
							: "closed"
						: "open";
				lines.push(`> **State:** ${emoji} ${label}  `);
			}

			// PR-specific
			if (isPR && prData) {
				if (cfg.PR_INCLUDE_BRANCH_INFO && prData.head && prData.base) {
					const headLabel =
						prData.head.label ||
						`${prData.head.repo?.full_name || ""}:${prData.head.ref}`;
					const baseLabel =
						prData.base.label ||
						`${prData.base.repo?.full_name || ""}:${prData.base.ref}`;
					lines.push(`> **Branch:** \`${headLabel}\` → \`${baseLabel}\`  `);
				}
				if (cfg.PR_INCLUDE_MERGE_STATUS && prData.merged) {
					const by = prData.merged_by
						? `[@${prData.merged_by.login}](${prData.merged_by.html_url})`
						: "unknown";
					lines.push(
						`> **Merged by:** ${by} on ${Utils.formatDate(prData.merged_at)}  `,
					);
				}
				if (cfg.PR_INCLUDE_DIFF_STATS) {
					lines.push(
						`> **Changes:** +${prData.additions || 0} / -${prData.deletions || 0} across ${prData.changed_files || 0} files  `,
					);
				}
				if (cfg.PR_INCLUDE_REQUESTED_REVIEWERS && requestedReviewers) {
					const users = (requestedReviewers.users || []).map(
						(u) => `[@${u.login}](${u.html_url})`,
					);
					const teams = (requestedReviewers.teams || []).map(
						(t) => `@${t.slug}`,
					);
					const all = [...users, ...teams];
					if (all.length > 0)
						lines.push(`> **Requested Reviewers:** ${all.join(", ")}  `);
				}
			} else {
				// Issue-specific header fields
				if (cfg.ISSUE_INCLUDE_TYPE && issue.type?.name)
					lines.push(`> **Type:** ${issue.type.name}  `);
				if (cfg.ISSUE_INCLUDE_CLOSED_BY && issue.closed_by)
					lines.push(
						`> **Closed by:** [@${issue.closed_by.login}](${issue.closed_by.html_url})  `,
					);
				if (
					cfg.ISSUE_INCLUDE_LOCK_REASON &&
					issue.locked &&
					issue.active_lock_reason
				)
					lines.push(`> **Lock reason:** ${issue.active_lock_reason}  `);
				if (issue.issue_dependencies_summary) {
					const bb = issue.issue_dependencies_summary.blocked_by || 0;
					const bl = issue.issue_dependencies_summary.blocking || 0;
					if (bb > 0 || bl > 0)
						lines.push(
							`> **Dependencies:** blocked by ${bb} · blocking ${bl}  `,
						);
				}
				if (issue.sub_issues_summary?.total > 0)
					lines.push(`> **Sub-issues:** ${issue.sub_issues_summary.total}  `);
				if (issue.pinned_comment?.id) lines.push(`> **Pinned comment:** yes  `);
			}

			// Common fields
			if (cfg.INCLUDE_LABELS && issue.labels?.length > 0) {
				lines.push(
					`> **Labels:** ${issue.labels.map((l) => `\`${l.name}\``).join(", ")}  `,
				);
			}
			if (cfg.INCLUDE_ASSIGNEES && issue.assignees?.length > 0) {
				lines.push(
					`> **Assignees:** ${issue.assignees.map((a) => `[@${a.login}](${a.html_url})`).join(", ")}  `,
				);
			}
			if (cfg.INCLUDE_MILESTONE && issue.milestone) {
				lines.push(`> **Milestone:** ${issue.milestone.title}  `);
			}
			if (cfg.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");
		},

		// ── Single comment block ──

		generateComment(comment, index, cfg, minimizedMap) {
			const parts = [];

			// Check minimized state
			const minInfo = minimizedMap?.get(comment.id);
			const isMinimized = minInfo?.isMinimized;
			if (isMinimized && !cfg.INCLUDE_MINIMIZED_COMMENTS) return "";

			let header = `### Comment #${index}`;
			if (cfg.INCLUDE_AUTHOR_INFO)
				header += ` — [@${comment.user.login}](${comment.user.html_url})`;
			if (cfg.INCLUDE_TIMESTAMPS && comment.created_at)
				header += ` · ${Utils.formatDate(comment.created_at)}`;
			if (isMinimized)
				header += ` · 🙈 *minimized${minInfo.minimizedReason ? `: ${minInfo.minimizedReason}` : ""}*`;

			const isLong =
				cfg.COLLAPSIBLE_LONG_COMMENTS &&
				comment.body &&
				comment.body.length > cfg.LONG_COMMENT_THRESHOLD;
			if (isLong && cfg.USE_HTML_DETAILS) {
				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());
				if (cfg.INCLUDE_REACTIONS && comment.reactions) {
					const r = Utils.formatReactions(comment.reactions);
					if (r) parts.push(`\n${r}`);
				}
				parts.push(`\n</details>\n`);
			} else {
				parts.push(`${header}\n`);
				if (cfg.INCLUDE_COMMENT_IDS) parts.push(`\`ID: ${comment.id}\`\n`);
				parts.push(`\n${comment.body.trim()}\n`);
				if (cfg.INCLUDE_REACTIONS && comment.reactions) {
					const r = Utils.formatReactions(comment.reactions);
					if (r) parts.push(`\n${r}\n`);
				}
			}
			return parts.join("\n");
		},

		// ── Issue-specific sections ──

		generatePinnedComment(pinned, cfg) {
			if (!pinned?.id) return "";
			const p = ["\n---\n", "## Pinned Comment\n"];
			let h = "";
			if (cfg.INCLUDE_AUTHOR_INFO && pinned.user)
				h += `*By [@${pinned.user.login}](${pinned.user.html_url})*`;
			if (cfg.INCLUDE_TIMESTAMPS && pinned.created_at)
				h += `${h ? " " : ""}*on ${Utils.formatDate(pinned.created_at)}*`;
			if (h) p.push(`${h}\n`);
			if (pinned.body?.trim()) p.push(`${pinned.body.trim()}\n`);
			if (cfg.INCLUDE_REACTIONS && pinned.reactions) {
				const r = Utils.formatReactions(pinned.reactions);
				if (r) p.push(`${r}\n`);
			}
			return p.join("\n");
		},

		// biome-ignore lint/correctness/noUnusedFunctionParameters: cfg reserved for future use
		generateDependencies(deps, cfg) {
			const bb = Array.isArray(deps?.blockedBy) ? deps.blockedBy : [];
			const bl = Array.isArray(deps?.blocking) ? deps.blocking : [];
			if (bb.length === 0 && bl.length === 0) return "";
			const p = ["\n---\n", "## Dependencies\n"];
			if (bb.length > 0) {
				p.push(`### Blocked By (${bb.length})\n`);
				// biome-ignore lint/suspicious/useIterableCallbackReturn: push return unused
				bb.forEach((i) =>
					p.push(
						`- [#${i.number}](${i.html_url})${i.title ? ` — ${i.title}` : ""}${i.state ? ` (${i.state})` : ""}`,
					),
				);
				p.push("");
			}
			if (bl.length > 0) {
				p.push(`### Blocking (${bl.length})\n`);
				// biome-ignore lint/suspicious/useIterableCallbackReturn: push return unused
				bl.forEach((i) =>
					p.push(
						`- [#${i.number}](${i.html_url})${i.title ? ` — ${i.title}` : ""}${i.state ? ` (${i.state})` : ""}`,
					),
				);
				p.push("");
			}
			return p.join("\n");
		},

		// biome-ignore lint/correctness/noUnusedFunctionParameters: cfg reserved for future use
		generateSubIssues(subs, cfg) {
			if (!Array.isArray(subs) || subs.length === 0) return "";
			const p = ["\n---\n", `## Sub-Issues (${subs.length})\n`];
			// biome-ignore lint/suspicious/useIterableCallbackReturn: push return unused
			subs.forEach((i) =>
				p.push(
					`- [#${i.number}](${i.html_url})${i.title ? ` — ${i.title}` : ""}${i.state ? ` (${i.state})` : ""}`,
				),
			);
			p.push("");
			return p.join("\n");
		},

		// ── PR sections ──

		// biome-ignore lint/correctness/noUnusedFunctionParameters: cfg reserved for future use
		generatePRRequirements(prData, branchRules, cfg) {
			const normalize = (input) => {
				if (!input) return [];
				if (Array.isArray(input)) return input;
				if (Array.isArray(input.rules)) return input.rules;
				if (Array.isArray(input.rule_suites)) return input.rule_suites;
				if (input.type) return [input];
				return [];
			};
			const rules = normalize(branchRules);
			const prRule = rules.find((r) => r?.type === "pull_request");
			const statusRule = rules.find(
				(r) => r?.type === "required_status_checks",
			);
			const mqRule = rules.find((r) => r?.type === "merge_queue");
			const depRule = rules.find((r) => r?.type === "required_deployments");
			if (!prRule && !statusRule && !mqRule && !depRule && !prData?.auto_merge)
				return "";

			const p = ["\n---\n", "## Merge Requirements\n"];
			if (prRule?.parameters) {
				const pp = prRule.parameters;
				if (typeof pp.required_approving_review_count === "number")
					p.push(
						`- Required approvals: **${pp.required_approving_review_count}**`,
					);
				if (typeof pp.require_code_owner_review === "boolean")
					p.push(
						`- Code owner review: **${pp.require_code_owner_review ? "yes" : "no"}**`,
					);
				if (typeof pp.required_review_thread_resolution === "boolean")
					p.push(
						`- Conversation resolution: **${pp.required_review_thread_resolution ? "yes" : "no"}**`,
					);
				if (typeof pp.dismiss_stale_reviews_on_push === "boolean")
					p.push(
						`- Dismiss stale reviews: **${pp.dismiss_stale_reviews_on_push ? "yes" : "no"}**`,
					);
				if (typeof pp.require_last_push_approval === "boolean")
					p.push(
						`- Last push approval: **${pp.require_last_push_approval ? "yes" : "no"}**`,
					);
				if (
					Array.isArray(pp.allowed_merge_methods) &&
					pp.allowed_merge_methods.length > 0
				)
					p.push(`- Merge methods: **${pp.allowed_merge_methods.join(", ")}**`);
			}
			if (statusRule?.parameters) {
				const checks =
					statusRule.parameters.required_status_checks ||
					statusRule.parameters.required_checks ||
					[];
				if (checks.length > 0) {
					p.push(`- Required status checks (${checks.length}):`);
					// biome-ignore lint/suspicious/useIterableCallbackReturn: push return unused
					checks.forEach((c) =>
						p.push(`  - \`${c?.context || c?.name || "unknown"}\``),
					);
				}
			}
			if (mqRule) p.push(`- Merge queue: **yes**`);
			if (depRule?.parameters?.required_deployment_environments?.length > 0) {
				p.push(
					`- Required deployments: **${depRule.parameters.required_deployment_environments.join(", ")}**`,
				);
			}
			if (prData?.auto_merge) {
				const method = prData.auto_merge.merge_method || "unknown";
				const by = prData.auto_merge.enabled_by?.login
					? ` by [@${prData.auto_merge.enabled_by.login}](${prData.auto_merge.enabled_by.html_url})`
					: "";
				p.push(`- Auto-merge: **${method}**${by}`);
			}
			p.push("");
			return p.join("\n");
		},

		// biome-ignore lint/correctness/noUnusedFunctionParameters: cfg reserved for future use
		generateChecks(combinedStatus, checkRuns, cfg) {
			const checks = Array.isArray(checkRuns?.check_runs)
				? checkRuns.check_runs
				: [];
			const statuses = Array.isArray(combinedStatus?.statuses)
				? combinedStatus.statuses
				: [];
			const overall = combinedStatus?.state || null;
			if (!overall && checks.length === 0 && statuses.length === 0) return "";

			const p = ["\n---\n", "## Checks & Status\n"];
			if (overall) {
				const em = { success: "✅", pending: "🟡", failure: "🔴", error: "🔴" };
				p.push(`- Overall: ${em[overall] || "📋"} **${overall}**`);
			}
			if (checks.length > 0) {
				const total =
					typeof checkRuns?.total_count === "number"
						? checkRuns.total_count
						: checks.length;
				p.push(`\n### Check Runs (${total})\n`);
				checks
					.sort((a, b) => {
						const af = [
							"failure",
							"timed_out",
							"cancelled",
							"action_required",
						].includes(a?.conclusion);
						const bf = [
							"failure",
							"timed_out",
							"cancelled",
							"action_required",
						].includes(b?.conclusion);
						return Number(bf) - Number(af);
					})
					.forEach((c) => {
						const conc = c.conclusion || c.status || "unknown";
						const em = {
							success: "✅",
							neutral: "⚪",
							skipped: "⚪",
							pending: "🟡",
							queued: "🟡",
							in_progress: "🟡",
							failure: "🔴",
							timed_out: "🔴",
							cancelled: "⚫",
							action_required: "🟠",
						};
						const app = c.app?.name ? ` — *${c.app.name}*` : "";
						const link =
							c.details_url || c.html_url
								? `[${c.name || "check"}](${c.details_url || c.html_url})`
								: c.name || "check";
						p.push(`- ${em[conc] || "📋"} ${link} — **${conc}**${app}`);
					});
			}
			if (statuses.length > 0) {
				p.push(`\n### Commit Statuses (${statuses.length})\n`);
				statuses.forEach((s) => {
					const em = {
						success: "✅",
						pending: "🟡",
						failure: "🔴",
						error: "🔴",
					};
					const target = s.target_url
						? `[${s.context || "default"}](${s.target_url})`
						: `\`${s.context || "default"}\``;
					const desc = s.description ? ` — ${s.description}` : "";
					p.push(`- ${em[s.state] || "📋"} ${target} — **${s.state}**${desc}`);
				});
			}
			p.push("");
			return p.join("\n");
		},

		generateReviews(reviews, cfg) {
			if (!reviews || reviews.length === 0) return "";
			const p = ["\n---\n", `## Reviews (${reviews.length})\n`];
			const em = {
				APPROVED: "✅",
				CHANGES_REQUESTED: "🔴",
				COMMENTED: "💬",
				DISMISSED: "⚪",
				PENDING: "⏳",
			};
			reviews.forEach((r, i) => {
				const user = r.user
					? `[@${r.user.login}](${r.user.html_url})`
					: "Unknown";
				const date = r.submitted_at
					? ` · ${Utils.formatDate(r.submitted_at)}`
					: "";
				p.push(
					`### ${em[r.state] || "📋"} ${r.state || "UNKNOWN"} — ${user}${date}\n`,
				);
				if (r.body?.trim()) p.push(`${r.body.trim()}\n`);
				if (i !== reviews.length - 1) p.push(`${cfg.COMMENT_SEPARATOR}\n`);
			});
			return p.join("\n");
		},

		generateReviewComments(reviewComments, cfg, reviews, threadData) {
			if (!reviewComments || reviewComments.length === 0) return "";
			const p = [
				"\n---\n",
				`## Review Comments (${reviewComments.length})\n`,
				"*Inline comments on code changes*\n",
			];

			const reviewById = new Map();
			(reviews || []).forEach((r) => {
				if (r?.id != null) reviewById.set(r.id, r);
			});

			const threadInfoById = new Map();
			const threadInfoByKey = new Map();
			(threadData || []).forEach((t) => {
				const norm = {
					id: t.id,
					isResolved: !!t.isResolved,
					isOutdated: !!t.isOutdated,
					path: t.path,
					line: t.line,
					startLine: t.startLine,
				};
				(t.comments || []).forEach((c) => {
					if (c?.databaseId != null)
						threadInfoById.set(c.databaseId, {
							...norm,
							commentIsOutdated: !!c.isOutdated,
							minimizedReason: c.minimizedReason,
						});
				});
				const key = `${t.path || ""}::${t.startLine || ""}::${t.line || ""}`;
				if (!threadInfoByKey.has(key)) threadInfoByKey.set(key, norm);
			});

			const getLine = (rc) => {
				if (rc.subject_type === "file") return " (file-level)";
				if (rc.start_line && rc.line && rc.start_line !== rc.line)
					return ` (lines ${rc.start_line}-${rc.line})`;
				if (rc.line) return ` (line ${rc.line})`;
				if (rc.original_line) return ` (line ${rc.original_line})`;
				return "";
			};

			const getThreadInfo = (rc, root) => {
				if (threadInfoById.has(rc.id)) return threadInfoById.get(rc.id);
				const target = root || rc;
				return (
					threadInfoByKey.get(
						`${target.path || ""}::${target.start_line || ""}::${target.line || ""}`,
					) || null
				);
			};

			const renderBadges = (ti, indent) => {
				if (!cfg.PR_INCLUDE_REVIEW_THREAD_STATE || !ti) return;
				const badges = [ti.isResolved ? "✅ Resolved" : "🟡 Unresolved"];
				if (ti.isOutdated) badges.push("🕰️ Outdated");
				p.push(`${indent}*${badges.join(" · ")}*\n`);
			};

			const renderComment = (rc, indent, isReply, root) => {
				const ind = "  ".repeat(indent);
				const user = rc.user
					? `[@${rc.user.login}](${rc.user.html_url})`
					: "Unknown";
				const date = rc.created_at
					? ` · ${Utils.formatDate(rc.created_at)}`
					: "";
				const line = getLine(rc);
				const reply = isReply ? " *(reply)*" : "";
				const rev =
					rc.pull_request_review_id != null
						? reviewById.get(rc.pull_request_review_id)
						: null;
				const revState = rev?.state ? ` · review: ${rev.state}` : "";
				const ti = getThreadInfo(rc, root);
				const cBadges = [];
				if (cfg.PR_INCLUDE_REVIEW_THREAD_STATE && ti?.commentIsOutdated)
					cBadges.push("🕰️ outdated");
				if (cfg.PR_INCLUDE_REVIEW_THREAD_STATE && ti?.minimizedReason)
					cBadges.push(`🙈 minimized: ${ti.minimizedReason}`);

				p.push(`${ind}**${user}**${date}${line}${reply}${revState}\n`);
				if (!isReply) renderBadges(ti, ind);
				else if (cBadges.length > 0) p.push(`${ind}*${cBadges.join(" · ")}*\n`);

				if (rc.diff_hunk && !isReply) {
					p.push(`${ind}<details><summary>Diff context</summary>\n`);
					p.push(`${ind}\`\`\`diff`);
					p.push(rc.diff_hunk);
					p.push(`${ind}\`\`\`\n`);
					p.push(`${ind}</details>\n`);
				}
				if (rc.body?.trim()) p.push(`${ind}${rc.body.trim()}\n`);
				if (cfg.INCLUDE_REACTIONS && rc.reactions) {
					const r = Utils.formatReactions(rc.reactions);
					if (r) p.push(`${ind}${r}\n`);
				}
			};

			// Group by file
			const byFile = {};
			reviewComments.forEach((rc) => {
				const path = rc.path || "unknown";
				if (!byFile[path]) byFile[path] = [];
				byFile[path].push(rc);
			});

			for (const [filePath, comments] of Object.entries(byFile)) {
				p.push(`### \`${filePath}\`\n`);
				if (cfg.PR_REVIEW_COMMENTS_GROUP_BY_THREAD) {
					const byId = new Map();
					const roots = [];
					// biome-ignore lint/suspicious/useIterableCallbackReturn: Map.set return unused
					comments.forEach((rc) => byId.set(rc.id, { ...rc, _children: [] }));
					comments.forEach((rc) => {
						const node = byId.get(rc.id);
						if (rc.in_reply_to_id && byId.has(rc.in_reply_to_id))
							byId.get(rc.in_reply_to_id)._children.push(node);
						else roots.push(node);
					});
					roots.forEach((root, idx) => {
						renderComment(root, 0, false, root);
						if (root._children?.length > 0)
							// biome-ignore lint/suspicious/useIterableCallbackReturn: renderComment return unused
							root._children.forEach((child) =>
								renderComment(child, 1, true, root),
							);
						if (idx !== roots.length - 1) p.push("");
					});
				} else {
					comments.forEach((rc, idx) => {
						renderComment(rc, 0, !!rc.in_reply_to_id, rc);
						if (idx !== comments.length - 1) p.push("");
					});
				}
				p.push("");
			}
			return p.join("\n");
		},

		// biome-ignore lint/correctness/noUnusedFunctionParameters: cfg reserved for future use
		generatePRCommits(commits, cfg) {
			if (!commits || commits.length === 0) return "";
			const p = ["\n---\n", `## PR Commits (${commits.length})\n`];
			commits.forEach((c) => {
				const sha = String(c.sha || "").substring(0, 7);
				const msg = c.commit?.message
					? String(c.commit.message).split("\n")[0].trim()
					: "No message";
				const author = c.commit?.author?.name || c.author?.login || "Unknown";
				const date = c.commit?.author?.date
					? ` · ${Utils.formatDate(c.commit.author.date)}`
					: "";
				p.push(
					`- [\`${sha}\`](${c.html_url || ""}) ${msg} — *${author}*${date}`,
				);
			});
			p.push("");
			return p.join("\n");
		},

		generatePRFiles(files, cfg) {
			if (!files || files.length === 0) return "";
			const p = ["\n---\n", `## Changed Files (${files.length})\n`];
			const em = {
				added: "🟢",
				removed: "🔴",
				modified: "🟡",
				renamed: "🔄",
				copied: "📋",
				changed: "🟡",
				unchanged: "⚪",
			};
			files.forEach((f) => {
				const rename = f.previous_filename
					? ` ← \`${f.previous_filename}\``
					: "";
				p.push(
					`- ${em[f.status] || "📄"} \`${f.filename}\` (${f.status})${rename} — +${f.additions || 0} / -${f.deletions || 0}`,
				);
				if (cfg.PR_INCLUDE_CHANGED_FILES_PATCHES && f.patch) {
					p.push(`  <details><summary>Diff</summary>\n`);
					p.push("  ```diff");
					p.push(`  ${f.patch}`);
					p.push("  ```\n");
					p.push("  </details>");
				}
			});
			p.push("");
			return p.join("\n");
		},

		// ── References section ──

		generateReferences(issue, timeline, commitDetails, cfg) {
			if (!cfg.REF_INCLUDE_CROSS_REFERENCED && !cfg.REF_INCLUDE_COMMITS)
				return "";
			const currentRepo = this._repoFromUrl(issue.html_url);

			const crossRefs = (timeline || [])
				.filter((e) => e?.event === "cross-referenced" && e.source?.issue)
				.map((e) => {
					const src = e.source.issue;
					const repoFull = src?.repository?.full_name || "";
					return {
						repoFull,
						isSameRepo:
							currentRepo &&
							repoFull.toLowerCase() === currentRepo.toLowerCase(),
						isPR: !!src?.pull_request,
						isDup: this._hasLabel(src, "duplicate"),
						number: src.number,
						title: src.title || "",
						url: src.html_url || "",
						state: src.state || "",
						merged_at: src.pull_request?.merged_at || null,
					};
				});

			const filtered = [];
			if (cfg.REF_INCLUDE_CROSS_REFERENCED) {
				for (const r of crossRefs) {
					if (!r.url) continue;
					if (r.isSameRepo && !cfg.REF_INCLUDE_SAME_REPO) continue;
					if (!r.isSameRepo && !cfg.REF_INCLUDE_CROSS_REPO) continue;
					if (r.isPR && !cfg.REF_INCLUDE_PRS) continue;
					if (!r.isPR && !cfg.REF_INCLUDE_ISSUES) continue;
					filtered.push(r);
				}
			}

			const dedup = (arr) => {
				const seen = new Set();
				return arr.filter((i) => {
					if (seen.has(i.url)) return false;
					seen.add(i.url);
					return true;
				});
			};

			const relatedPRs = dedup(
				filtered.filter(
					(r) => r.isPR && (!r.isDup || !cfg.REF_INCLUDE_DUPLICATES),
				),
			);
			const relatedIssues = dedup(
				filtered.filter(
					(r) => !r.isPR && (!r.isDup || !cfg.REF_INCLUDE_DUPLICATES),
				),
			);
			const duplicates = cfg.REF_INCLUDE_DUPLICATES
				? dedup(filtered.filter((r) => r.isDup))
				: [];

			const commits = [];
			if (cfg.REF_INCLUDE_COMMITS) {
				const seen = new Set();
				(timeline || [])
					.filter((e) => e?.event === "referenced" && e.commit_id)
					.forEach((e) => {
						const sha = String(e.commit_id);
						if (seen.has(sha)) return;
						seen.add(sha);
						const info = commitDetails?.[sha];
						commits.push({
							sha,
							htmlUrl:
								info?.html_url || this._commitApiToHtml(e.commit_url, sha),
							subject: info?.message
								? String(info.message).split("\n")[0].trim()
								: "",
						});
					});
			}

			if (
				!relatedPRs.length &&
				!relatedIssues.length &&
				!duplicates.length &&
				!commits.length
			)
				return "";

			const p = ["\n---\n", "## References\n"];
			if (relatedPRs.length > 0) {
				p.push(`### Related Pull Requests (${relatedPRs.length})\n`);
				relatedPRs.forEach((r) => {
					const prefix = r.repoFull
						? `${r.repoFull}#${r.number}`
						: `#${r.number}`;
					const title = r.title ? ` — ${r.title}` : "";
					const status = r.merged_at
						? ` (merged ${Utils.formatDate(r.merged_at)})`
						: r.state
							? ` (${r.state})`
							: "";
					p.push(`- [${prefix}](${r.url})${title}${status}`);
				});
				p.push("");
			}
			if (relatedIssues.length > 0) {
				p.push(`### Related Issues (${relatedIssues.length})\n`);
				relatedIssues.forEach((r) => {
					const prefix = r.repoFull
						? `${r.repoFull}#${r.number}`
						: `#${r.number}`;
					p.push(
						`- [${prefix}](${r.url})${r.title ? ` — ${r.title}` : ""}${r.state ? ` (${r.state})` : ""}`,
					);
				});
				p.push("");
			}
			if (duplicates.length > 0) {
				p.push(`### Duplicates (${duplicates.length})\n`);
				duplicates.forEach((r) => {
					const prefix = r.repoFull
						? `${r.repoFull}#${r.number}`
						: `#${r.number}`;
					p.push(
						`- [${prefix}](${r.url})${r.title ? ` — ${r.title}` : ""}${r.state ? ` (${r.state})` : ""}`,
					);
				});
				p.push("");
			}
			if (commits.length > 0) {
				p.push(`### Commits (${commits.length})\n`);
				// biome-ignore lint/suspicious/useIterableCallbackReturn: push return unused
				commits.forEach((c) =>
					p.push(
						`- [${this._shortSha(c.sha)}](${c.htmlUrl})${c.subject ? ` — ${c.subject}` : ""}`,
					),
				);
				p.push("");
			}
			return p.join("\n");
		},

		// ── Timeline section ──

		// biome-ignore lint/correctness/noUnusedFunctionParameters: cfg reserved for future use
		generateTimeline(issue, timeline, commitDetails, cfg) {
			const events = (timeline || []).filter(
				(e) => e && e.event !== "commented",
			);
			const p = ["\n---\n", "## Timeline (Verbose)\n"];

			const include = (ev) => {
				switch (ev.event) {
					case "cross-referenced":
						return !!cfg.TL_INCLUDE_CROSS_REFERENCED;
					case "referenced":
						return !!cfg.TL_INCLUDE_REFERENCED;
					case "renamed":
						return !!cfg.TL_INCLUDE_RENAMED;
					case "labeled":
					case "unlabeled":
						return !!cfg.TL_INCLUDE_LABEL_CHANGES;
					case "added_to_project_v2":
					case "project_v2_item_status_changed":
						return !!cfg.TL_INCLUDE_PROJECT_V2;
					case "subscribed":
					case "unsubscribed":
						return !!cfg.TL_INCLUDE_SUBSCRIBE_EVENTS;
					case "mentioned":
						return !!cfg.TL_INCLUDE_MENTIONED_EVENTS;
					case "marked_as_duplicate":
						return !!cfg.TL_INCLUDE_MARKED_DUPLICATE_EVENTS;
					case "closed":
					case "reopened":
						return !!cfg.TL_INCLUDE_CLOSED_EVENTS;
					case "reviewed":
						return !!cfg.TL_INCLUDE_REVIEWED;
					case "committed":
						return !!cfg.TL_INCLUDE_COMMITTED;
					case "merged":
						return !!cfg.TL_INCLUDE_MERGED;
					case "head_ref_force_pushed":
					case "head_ref_deleted":
					case "head_ref_restored":
						return !!cfg.TL_INCLUDE_HEAD_REF_EVENTS;
					case "review_requested":
					case "review_request_removed":
						return !!cfg.TL_INCLUDE_REVIEW_REQUESTED;
					case "ready_for_review":
					case "convert_to_draft":
						return !!cfg.TL_INCLUDE_REVIEWED;
					case "assigned":
					case "unassigned":
					case "milestoned":
					case "demilestoned":
					case "locked":
					case "unlocked":
					case "pinned":
					case "unpinned":
					case "transferred":
					case "connected":
					case "disconnected":
						return true;
					default:
						return false;
				}
			};

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

			const fmt = (ev) => {
				const a = actor(ev);
				switch (ev.event) {
					case "renamed":
						return `✏️ ${a} renamed: "${ev.rename?.from}" → "${ev.rename?.to}"`;
					case "labeled":
						return `🏷️ ${a} added label \`${ev.label?.name}\``;
					case "unlabeled":
						return `🏷️ ${a} removed label \`${ev.label?.name}\``;
					case "closed":
						return `🔴 Closed by ${a}`;
					case "reopened":
						return `🟢 Reopened by ${a}`;
					case "assigned":
						return `👤 ${a} assigned ${ev.assignee?.login ? "@" + ev.assignee.login : "someone"}`;
					case "unassigned":
						return `👤 ${a} unassigned ${ev.assignee?.login ? "@" + ev.assignee.login : "someone"}`;
					case "milestoned":
						return `🎯 ${a} added milestone "${ev.milestone?.title}"`;
					case "demilestoned":
						return `🎯 ${a} removed milestone "${ev.milestone?.title}"`;
					case "locked":
						return `🔒 ${a} locked${ev.lock_reason ? ` (${ev.lock_reason})` : ""}`;
					case "unlocked":
						return `🔓 ${a} unlocked`;
					case "pinned":
						return `📌 ${a} pinned`;
					case "unpinned":
						return `📌 ${a} unpinned`;
					case "transferred":
						return `📦 ${a} transferred`;
					case "connected":
						return `🔗 ${a} connected`;
					case "disconnected":
						return `🔗 ${a} disconnected`;
					case "marked_as_duplicate":
						return `🔁 ${a} marked duplicate`;
					case "reviewed": {
						const em = {
							approved: "✅",
							changes_requested: "🔴",
							commented: "💬",
							dismissed: "⚪",
						};
						return `${em[ev.state] || "📋"} ${a} reviewed: ${ev.state || "commented"}`;
					}
					case "committed":
						return `📝 Commit ${ev.sha ? ev.sha.substring(0, 7) : ""}${ev.message ? ": " + String(ev.message).split("\n")[0].trim() : ""}`;
					case "merged":
						return `🟣 ${a} merged`;
					case "head_ref_force_pushed":
						return `⬆️ ${a} force-pushed`;
					case "head_ref_deleted":
						return `🗑️ ${a} deleted head branch`;
					case "head_ref_restored":
						return `♻️ ${a} restored head branch`;
					case "review_requested":
						return `👀 ${a} requested review from ${ev.requested_reviewer?.login ? "@" + ev.requested_reviewer.login : "a team"}`;
					case "review_request_removed":
						return `👀 ${a} removed review request for ${ev.requested_reviewer?.login ? "@" + ev.requested_reviewer.login : "a team"}`;
					case "ready_for_review":
						return `🟢 ${a} marked ready for review`;
					case "convert_to_draft":
						return `⚪ ${a} converted to draft`;
					case "cross-referenced": {
						const src = ev.source?.issue;
						if (!src) return "🔗 Cross-referenced";
						const kind = src.pull_request ? "PR" : "Issue";
						return `🔗 ${kind}: ${src.repository?.full_name || ""}#${src.number}${src.title ? ` — ${src.title}` : ""}`;
					}
					case "referenced": {
						const sha = String(ev.commit_id || "");
						const short = sha.length > 7 ? sha.slice(0, 7) : sha;
						const info = commitDetails?.[sha];
						const subject = info?.message
							? String(info.message).split("\n")[0].trim()
							: "";
						return `🔗 Commit ${short}${subject ? ` — ${subject}` : ""}`;
					}
					default:
						return `${ev.event} by ${a}`;
				}
			};

			const relevant = events.filter(include);
			if (relevant.length === 0) {
				p.push("*No timeline events.*\n");
				return p.join("\n");
			}
			// biome-ignore lint/suspicious/useIterableCallbackReturn: push return unused
			relevant.forEach((ev) => p.push(`- ${fmtDate(ev)}: ${fmt(ev)}`));
			p.push("");
			return p.join("\n");
		},
	};

	// ── Discussion-specific markdown generation (added to MdGen) ──

	MdGen.generateDiscussionHeader = function (disc, cfg, gqlData) {
		const lines = [];
		const repo = this._repoFromUrl(disc.html_url);

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

		// State
		const stateEmoji = disc.state === "open" ? "🟢" : "🔴";
		const stateLabel =
			disc.state === "open"
				? "open"
				: disc.state_reason
					? `closed (${disc.state_reason})`
					: "closed";
		lines.push(`> **State:** ${stateEmoji} ${stateLabel}  `);

		// Category
		if (cfg.DISC_INCLUDE_CATEGORY && disc.category) {
			const emoji = disc.category.emoji ? `${disc.category.emoji} ` : "";
			const answerable = disc.category.is_answerable ? " (Q&A)" : "";
			lines.push(
				`> **Category:** ${emoji}${disc.category.name}${answerable}  `,
			);
		}

		// Author
		if (cfg.INCLUDE_AUTHOR_INFO && disc.user) {
			lines.push(
				`> **Author:** [@${disc.user.login}](${disc.user.html_url})  `,
			);
		}

		// Answer info
		if (cfg.DISC_INCLUDE_ANSWER && disc.answer_chosen_at) {
			const by = disc.answer_chosen_by
				? `[@${disc.answer_chosen_by.login}](${disc.answer_chosen_by.html_url})`
				: "unknown";
			lines.push(
				`> **Answer chosen by:** ${by} on ${Utils.formatDate(disc.answer_chosen_at)}  `,
			);
		}

		// Upvotes (from GraphQL)
		if (cfg.DISC_INCLUDE_UPVOTES && gqlData?.upvoteCount > 0) {
			lines.push(`> **Upvotes:** ${gqlData.upvoteCount}  `);
		}

		// Pinned (from GraphQL)
		if (gqlData?.isPinned) {
			lines.push(`> **Pinned:** yes  `);
		}

		// Locked
		if (disc.locked) {
			const reason = disc.active_lock_reason
				? ` (${disc.active_lock_reason})`
				: "";
			lines.push(`> **Locked:** yes${reason}  `);
		}

		// Labels
		if (cfg.INCLUDE_LABELS && disc.labels?.length > 0) {
			const names = disc.labels
				.map((l) => `\`${typeof l === "object" ? l.name : l}\``)
				.join(", ");
			lines.push(`> **Labels:** ${names}  `);
		}

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

		// Reactions
		if (cfg.INCLUDE_REACTIONS && disc.reactions) {
			const r = Utils.formatReactions(disc.reactions);
			if (r) lines.push(`> **Reactions:** ${r}  `);
		}

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

	MdGen.generateDiscussionPoll = (poll, cfg) => {
		if (!poll || !cfg.DISC_INCLUDE_POLL) return "";
		const p = ["\n---\n", "## Poll\n"];
		p.push(`**${poll.question}**\n`);
		p.push(`Total votes: **${poll.totalVotes || 0}**\n`);

		const options = poll.options?.nodes || [];
		if (options.length > 0) {
			p.push("| Option | Votes |");
			p.push("|:-------|------:|");
			options.forEach((o) => {
				const pct =
					poll.totalVotes > 0
						? ` (${((o.votes / poll.totalVotes) * 100).toFixed(1)}%)`
						: "";
				p.push(`| ${o.text} | ${o.votes}${pct} |`);
			});
			p.push("");
		}
		return p.join("\n");
	};

	MdGen.generateDiscussionComments = (comments, cfg, gqlData) => {
		if (!comments || comments.length === 0) return "";

		// Build GQL enrichment maps
		const gqlCommentMap = new Map();
		const gqlReplyMap = new Map();
		if (gqlData?.comments?.nodes) {
			for (const gc of gqlData.comments.nodes) {
				if (gc.databaseId != null) {
					gqlCommentMap.set(gc.databaseId, gc);
				}
				if (gc.replies?.nodes) {
					for (const gr of gc.replies.nodes) {
						if (gr.databaseId != null) gqlReplyMap.set(gr.databaseId, gr);
					}
				}
			}
		}

		// Find the answer comment ID from GQL
		let answerCommentId = null;
		if (gqlData?.answer?.databaseId) {
			answerCommentId = gqlData.answer.databaseId;
		}

		// Separate top-level comments and replies using parent_id
		const topLevel = [];
		const repliesByParent = new Map();

		for (const c of comments) {
			if (c.parent_id) {
				if (!repliesByParent.has(c.parent_id))
					repliesByParent.set(c.parent_id, []);
				repliesByParent.get(c.parent_id).push(c);
			} else {
				topLevel.push(c);
			}
		}

		const parts = [];
		parts.push("\n---\n");
		parts.push(`## Comments (${topLevel.length})\n`);

		topLevel.forEach((comment, index) => {
			const user = comment.user
				? `[@${comment.user.login}](${comment.user.html_url})`
				: "Unknown";
			const date = comment.created_at
				? ` · ${Utils.formatDate(comment.created_at)}`
				: "";
			const assoc =
				comment.author_association && comment.author_association !== "NONE"
					? ` · *${comment.author_association}*`
					: "";

			// Check if this is the answer
			const isAnswer = comment.id === answerCommentId;
			const answerBadge = isAnswer ? " ✅ **Answer**" : "";

			// Check GQL minimized state
			const gqlInfo = gqlCommentMap.get(comment.id);
			const minimized = gqlInfo?.isMinimized;
			const minimizedReason = gqlInfo?.minimizedReason;

			parts.push(
				`### Comment #${index + 1} — ${user}${date}${assoc}${answerBadge}\n`,
			);

			if (minimized) {
				parts.push(
					`*🙈 This comment was minimized${minimizedReason ? `: ${minimizedReason}` : ""}*\n`,
				);
			}

			if (comment.body?.trim()) {
				parts.push(`${comment.body.trim()}\n`);
			} else {
				parts.push("*No content.*\n");
			}

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

			// Replies
			if (cfg.DISC_INCLUDE_COMMENT_REPLIES) {
				const replies = repliesByParent.get(comment.id) || [];
				if (replies.length > 0) {
					parts.push(`\n#### Replies (${replies.length})\n`);
					replies.forEach((reply) => {
						const rUser = reply.user
							? `[@${reply.user.login}](${reply.user.html_url})`
							: "Unknown";
						const rDate = reply.created_at
							? ` · ${Utils.formatDate(reply.created_at)}`
							: "";
						const rAssoc =
							reply.author_association && reply.author_association !== "NONE"
								? ` · *${reply.author_association}*`
								: "";

						// Check GQL minimized for reply
						const rGql = gqlReplyMap.get(reply.id);
						const rMin = rGql?.isMinimized;
						const rMinReason = rGql?.minimizedReason;

						parts.push(`> **${rUser}**${rDate}${rAssoc}\n>`);

						if (rMin) {
							parts.push(
								`> *🙈 Minimized${rMinReason ? `: ${rMinReason}` : ""}*\n>`,
							);
						}

						if (reply.body?.trim()) {
							// Indent reply body inside blockquote
							const indented = reply.body
								.trim()
								.split("\n")
								.map((l) => `> ${l}`)
								.join("\n");
							parts.push(`${indented}\n`);
						}

						if (cfg.INCLUDE_REACTIONS && reply.reactions) {
							const rr = Utils.formatReactions(reply.reactions);
							if (rr) parts.push(`> ${rr}\n`);
						}

						parts.push("");
					});
				}
			}

			if (index !== topLevel.length - 1) {
				parts.push(`${cfg.COMMENT_SEPARATOR}\n`);
			}
		});

		return parts.join("\n");
	};

	// ── Master generate function ──

	MdGen.generate = function (data) {
		const cfg = data.cfg || ThreadConfig.load();
		const parts = [];

		if (data.type === "discussion") {
			return this._generateDiscussion(data, cfg);
		}

		// Issue or PR
		const issue = data.issue;
		const isPR = data.type === "pr";

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

		// Header
		if (cfg.INCLUDE_HEADER) {
			parts.push(
				this.generateHeader(issue, cfg, data.prData, data.requestedReviewers),
			);
		}

		// Issue-specific sections
		if (!isPR) {
			if (cfg.ISSUE_INCLUDE_PINNED_COMMENT && data.pinnedComment) {
				const s = this.generatePinnedComment(data.pinnedComment, cfg);
				if (s) parts.push(s);
			}
			if (cfg.ISSUE_INCLUDE_DEPENDENCIES && data.issueDependencies) {
				const s = this.generateDependencies(data.issueDependencies, cfg);
				if (s) parts.push(s);
			}
			if (cfg.ISSUE_INCLUDE_SUB_ISSUES && data.issueSubIssues?.length > 0) {
				const s = this.generateSubIssues(data.issueSubIssues, cfg);
				if (s) parts.push(s);
			}
		}

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

		// Description
		parts.push("## Description\n");
		if (cfg.INCLUDE_AUTHOR_INFO) {
			let line = `*Opened by [@${issue.user.login}](${issue.user.html_url})*`;
			if (cfg.INCLUDE_TIMESTAMPS && issue.created_at)
				line += ` *on ${Utils.formatDate(issue.created_at)}*`;
			parts.push(`${line}\n`);
		}
		if (issue.body?.trim()) parts.push(`\n${issue.body.trim()}\n`);
		else parts.push("\n*No description provided.*\n");

		if (cfg.INCLUDE_REACTIONS && issue.reactions) {
			const r = Utils.formatReactions(issue.reactions);
			if (r) parts.push(`\n${r}\n`);
		}

		// PR sections
		if (isPR) {
			if (
				cfg.PR_INCLUDE_MERGE_REQUIREMENTS &&
				(data.branchRules || data.prData)
			) {
				const s = this.generatePRRequirements(
					data.prData,
					data.branchRules,
					cfg,
				);
				if (s) parts.push(s);
			}
			if (
				cfg.PR_INCLUDE_CHECKS_SECTION &&
				(data.combinedStatus || data.checkRuns)
			) {
				const s = this.generateChecks(data.combinedStatus, data.checkRuns, cfg);
				if (s) parts.push(s);
			}
			if (cfg.PR_INCLUDE_REVIEWS && data.reviews?.length > 0) {
				parts.push(this.generateReviews(data.reviews, cfg));
			}
			if (cfg.PR_INCLUDE_REVIEW_COMMENTS && data.reviewComments?.length > 0) {
				parts.push(
					this.generateReviewComments(
						data.reviewComments,
						cfg,
						data.reviews,
						data.reviewThreadData,
					),
				);
			}
			if (cfg.PR_INCLUDE_PR_COMMITS && data.prCommits?.length > 0) {
				parts.push(this.generatePRCommits(data.prCommits, cfg));
			}
			if (cfg.PR_INCLUDE_CHANGED_FILES && data.prFiles?.length > 0) {
				parts.push(this.generatePRFiles(data.prFiles, cfg));
			}
		}

		// References
		if (cfg.INCLUDE_REFERENCES_SECTION && data.timeline?.length > 0) {
			const s = this.generateReferences(
				issue,
				data.timeline,
				data.commitDetails,
				cfg,
			);
			if (s) parts.push(s);
		}

		// Timeline
		if (cfg.INCLUDE_TIMELINE_SECTION && data.timeline?.length > 0) {
			const s = this.generateTimeline(
				issue,
				data.timeline,
				data.commitDetails,
				cfg,
			);
			if (s) parts.push(s);
		}

		// Comments
		if (data.comments?.length > 0) {
			parts.push("\n---\n");
			parts.push(`## Comments (${data.comments.length})\n`);
			let commentIdx = 0;
			data.comments.forEach((c) => {
				const rendered = this.generateComment(
					c,
					commentIdx + 1,
					cfg,
					data.commentMinimizedMap,
				);
				if (rendered) {
					if (commentIdx > 0) parts.push(`\n${cfg.COMMENT_SEPARATOR}\n`);
					parts.push(rendered);
					commentIdx++;
				}
			});
		}

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

	MdGen._generateDiscussion = function (data, cfg) {
		const disc = data.discussion;
		const gql = data.gqlData;
		const parts = [];

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

		// Header
		if (cfg.INCLUDE_HEADER) {
			parts.push(this.generateDiscussionHeader(disc, cfg, gql));
		}

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

		// Body
		parts.push("## Description\n");
		if (cfg.INCLUDE_AUTHOR_INFO && disc.user) {
			let line = `*Started by [@${disc.user.login}](${disc.user.html_url})*`;
			if (cfg.INCLUDE_TIMESTAMPS && disc.created_at)
				line += ` *on ${Utils.formatDate(disc.created_at)}*`;
			parts.push(`${line}\n`);
		}
		if (disc.body?.trim()) parts.push(`\n${disc.body.trim()}\n`);
		else parts.push("\n*No description provided.*\n");

		if (cfg.INCLUDE_REACTIONS && disc.reactions) {
			const r = Utils.formatReactions(disc.reactions);
			if (r) parts.push(`\n${r}\n`);
		}

		// Poll (from GraphQL)
		if (gql?.poll) {
			const s = this.generateDiscussionPoll(gql.poll, cfg);
			if (s) parts.push(s);
		}

		// Comments
		if (data.comments?.length > 0) {
			const s = this.generateDiscussionComments(data.comments, cfg, gql);
			if (s) parts.push(s);
		}

		// Discussion timeline (from GraphQL)
		if (cfg.DISC_INCLUDE_TIMELINE && data.discussionTimeline?.length > 0) {
			const s = this._generateDiscussionTimeline(data.discussionTimeline, cfg);
			if (s) parts.push(s);
		}

		// Footer
		parts.push("\n---\n");
		parts.push(
			`*Exported on ${new Date().toLocaleString()} from [${disc.html_url}](${disc.html_url})*\n`,
		);
		return parts.join("\n");
	};

	// biome-ignore lint/correctness/noUnusedFunctionParameters: cfg reserved for future use
	MdGen._generateDiscussionTimeline = (events, cfg) => {
		if (!events || events.length === 0) return "";
		const p = ["\n---\n", "## Timeline\n"];

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

		const fmt = (ev) => {
			const a = actor(ev);
			switch (ev.__typename) {
				case "LabeledEvent":
					return `🏷️ ${a} added label \`${ev.label?.name}\``;
				case "UnlabeledEvent":
					return `🏷️ ${a} removed label \`${ev.label?.name}\``;
				case "ClosedEvent":
					return `🔴 Closed by ${a}${ev.stateReason ? ` (${ev.stateReason})` : ""}`;
				case "ReopenedEvent":
					return `🟢 Reopened by ${a}`;
				case "LockedEvent":
					return `🔒 ${a} locked${ev.lockReason ? ` (${ev.lockReason})` : ""}`;
				case "UnlockedEvent":
					return `🔓 ${a} unlocked`;
				case "PinnedEvent":
					return `📌 ${a} pinned`;
				case "UnpinnedEvent":
					return `📌 ${a} unpinned`;
				case "TransferredEvent":
					return `📦 ${a} transferred`;
				case "CategoryChangedEvent":
					return `📂 ${a} changed category`;
				case "RenamedTitleEvent":
					return `✏️ ${a} renamed: "${ev.previousTitle}" → "${ev.currentTitle}"`;
				case "MarkedAsDuplicateEvent":
					return `🔁 ${a} marked as duplicate`;
				case "CrossReferencedEvent": {
					const src = ev.source;
					if (!src) return `🔗 Cross-referenced by ${a}`;
					const kind = src.__typename === "PullRequest" ? "PR" : "Issue";
					const repo = src.repository?.nameWithOwner || "";
					return `🔗 ${kind}: ${repo}#${src.number}${src.title ? ` — ${src.title}` : ""}`;
				}
				default:
					return `${ev.__typename} by ${a}`;
			}
		};

		// Filter out DiscussionComment nodes (they're in the comments section)
		const relevant = events.filter(
			(ev) => ev.__typename && ev.__typename !== "DiscussionComment",
		);

		if (relevant.length === 0) {
			p.push("*No timeline events.*\n");
			return p.join("\n");
		}
		// biome-ignore lint/suspicious/useIterableCallbackReturn: push return unused
		relevant.forEach((ev) => p.push(`- ${fmtDate(ev)}: ${fmt(ev)}`));
		p.push("");
		return p.join("\n");
	};

	// ════════════════════════════════════════════════════════════════════════════
	// THREAD EXPORT MODULE (orchestrator)
	// ════════════════════════════════════════════════════════════════════════════

	const ThreadExportModule = {
		/**
		 * Export a single thread (issue, PR, or discussion) to Markdown.
		 * This is the core function that fetches all data and generates output.
		 * Designed to be callable both from the FAB and from batch export.
		 *
		 * @param {string} owner
		 * @param {string} repo
		 * @param {number} number
		 * @param {string} type  'issue' | 'pr' | 'discussion'
		 * @param {AbortSignal} signal
		 * @returns {{ markdown: string, title: string, commentCount: number, summary: string }}
		 */
		async exportThread(owner, repo, number, type, signal, onProgress) {
			const cfg = ThreadConfig.load();
			const check = () => {
				if (signal?.aborted) throw new Error("Cancelled");
			};
			const progress = (msg) => {
				if (onProgress) onProgress(msg);
			};

			if (type === "discussion") {
				return this._exportDiscussion(
					owner,
					repo,
					number,
					cfg,
					signal,
					onProgress,
				);
			}

			// ── Issue or PR ──
			progress("Fetching issue data…");
			const issue = await API.fetchIssue(owner, repo, number, signal);
			check();

			// Detect actual type (issue URL might be a PR)
			const isPR = type === "pr" || !!issue.pull_request;
			const actualType = isPR ? "pr" : "issue";

			// Comments (conversation tab — same for issues and PRs)
			let comments = [];
			if (issue.comments > 0) {
				progress(`Fetching ${issue.comments} comments…`);
				comments = await API.fetchIssueComments(owner, repo, number, signal);
				check();
			}

			// Fetch minimized state for comments via GraphQL
			let commentMinimizedMap = null;
			if (Token.has() && comments.length > 0) {
				progress("Fetching comment minimized state…");
				commentMinimizedMap = await API.fetchCommentMinimizedState(
					owner,
					repo,
					number,
					signal,
				);
				check();
			}

			// Collect all data into a single object
			const exportData = {
				type: actualType,
				issue,
				comments,
				commentMinimizedMap,
				cfg,
				// PR-specific (filled below if PR)
				prData: null,
				reviews: [],
				reviewComments: [],
				prCommits: [],
				prFiles: [],
				requestedReviewers: null,
				branchRules: null,
				combinedStatus: null,
				checkRuns: null,
				reviewThreadData: [],
				// Issue-specific (filled below if issue)
				issueDependencies: null,
				issueSubIssues: [],
				pinnedComment: null,
				// Shared
				timeline: [],
				commitDetails: {},
			};

			if (isPR) {
				progress("Fetching PR details…");
				exportData.prData = await API.fetchPullRequest(
					owner,
					repo,
					number,
					signal,
				);
				check();

				if (cfg.PR_INCLUDE_REQUESTED_REVIEWERS) {
					progress("Fetching requested reviewers…");
					exportData.requestedReviewers = await API.fetchRequestedReviewers(
						owner,
						repo,
						number,
						signal,
					);
					check();
				}
				if (cfg.PR_INCLUDE_MERGE_REQUIREMENTS && exportData.prData?.base?.ref) {
					progress("Fetching branch rules…");
					exportData.branchRules = await API.fetchBranchRules(
						owner,
						repo,
						exportData.prData.base.ref,
						signal,
					);
					check();
				}
				if (cfg.PR_INCLUDE_CHECKS_SECTION && exportData.prData?.head?.sha) {
					progress("Fetching checks & status…");
					exportData.combinedStatus = await API.fetchCombinedStatus(
						owner,
						repo,
						exportData.prData.head.sha,
						signal,
					);
					check();
					exportData.checkRuns = await API.fetchCheckRuns(
						owner,
						repo,
						exportData.prData.head.sha,
						signal,
					);
					check();
				}
				if (cfg.PR_INCLUDE_REVIEWS) {
					progress("Fetching reviews…");
					exportData.reviews = await API.fetchReviews(
						owner,
						repo,
						number,
						signal,
					);
					check();
				}
				if (cfg.PR_INCLUDE_REVIEW_COMMENTS) {
					progress("Fetching review comments…");
					exportData.reviewComments = await API.fetchReviewComments(
						owner,
						repo,
						number,
						signal,
					);
					check();
					if (
						cfg.PR_INCLUDE_REVIEW_THREAD_STATE &&
						exportData.reviewComments.length > 0
					) {
						progress("Fetching review thread state (GraphQL)…");
						exportData.reviewThreadData = await API.fetchPRReviewThreads(
							owner,
							repo,
							number,
							signal,
						);
						check();
					}
				}
				if (cfg.PR_INCLUDE_PR_COMMITS) {
					progress("Fetching PR commits…");
					exportData.prCommits = await API.fetchPRCommits(
						owner,
						repo,
						number,
						signal,
					);
					check();
				}
				if (cfg.PR_INCLUDE_CHANGED_FILES) {
					progress("Fetching changed files…");
					exportData.prFiles = await API.fetchPRFiles(
						owner,
						repo,
						number,
						signal,
					);
					check();
				}
			} else {
				// Issue-specific fetches
				if (cfg.ISSUE_INCLUDE_DEPENDENCIES) {
					progress("Fetching dependencies…");
					const bb = await API.fetchIssueDependenciesBlockedBy(
						owner,
						repo,
						number,
						signal,
					);
					check();
					const bl = await API.fetchIssueDependenciesBlocking(
						owner,
						repo,
						number,
						signal,
					);
					check();
					exportData.issueDependencies = { blockedBy: bb, blocking: bl };
				}
				if (cfg.ISSUE_INCLUDE_SUB_ISSUES) {
					progress("Fetching sub-issues…");
					exportData.issueSubIssues = await API.fetchIssueSubIssues(
						owner,
						repo,
						number,
						signal,
					);
					check();
				}
				if (cfg.ISSUE_INCLUDE_PINNED_COMMENT && issue.pinned_comment) {
					exportData.pinnedComment = issue.pinned_comment;
				}
			}

			// Timeline (shared)
			if (cfg.INCLUDE_REFERENCES_SECTION || cfg.INCLUDE_TIMELINE_SECTION) {
				progress("Fetching timeline…");
				exportData.timeline = await API.fetchTimeline(
					owner,
					repo,
					number,
					signal,
				);
				check();
			}

			// Optional commit details
			if (
				cfg.INCLUDE_REFERENCES_SECTION &&
				cfg.REF_INCLUDE_COMMITS &&
				cfg.REF_FETCH_COMMIT_DETAILS
			) {
				progress("Fetching commit details…");
				const refs = (exportData.timeline || []).filter(
					(e) => e?.event === "referenced" && e.commit_id && e.commit_url,
				);
				const unique = new Map();
				refs.forEach((e) => {
					if (!unique.has(e.commit_id)) unique.set(e.commit_id, e.commit_url);
				});
				for (const [sha, url] of unique.entries()) {
					check();
					try {
						const c = await API.fetchCommit(url, signal);
						exportData.commitDetails[sha] = {
							html_url: c?.html_url,
							message: c?.commit?.message,
						};
					} catch (e) {
						warn("Commit fetch failed:", sha, e?.message);
					}
				}
			}

			check();

			// Generate markdown
			progress("Generating Markdown…");
			const markdown = MdGen.generate(exportData);

			// Build summary
			const summaryParts = [`${comments.length + 1} messages`];
			if (isPR) {
				if (exportData.reviews.length > 0)
					summaryParts.push(`${exportData.reviews.length} reviews`);
				if (exportData.reviewComments.length > 0)
					summaryParts.push(
						`${exportData.reviewComments.length} review comments`,
					);
			}

			return {
				markdown,
				title: issue.title,
				commentCount: comments.length,
				summary: summaryParts.join(", "),
				type: actualType,
			};
		},

		/**
		 * Export a discussion to Markdown.
		 */
		async _exportDiscussion(owner, repo, number, cfg, signal, onProgress) {
			const check = () => {
				if (signal?.aborted) throw new Error("Cancelled");
			};
			const progress = (msg) => {
				if (onProgress) onProgress(msg);
			};

			// Fetch discussion via REST
			progress("Fetching discussion…");
			const disc = await API.fetchDiscussion(owner, repo, number, signal);
			check();

			// Fetch all comments via REST (flat list with parent_id for threading)
			progress("Fetching comments…");
			const comments = await API.fetchDiscussionComments(
				owner,
				repo,
				number,
				signal,
			);
			check();

			// GraphQL enrichment (polls, upvotes, minimized, answer ID)
			let gqlData = null;
			if (Token.has()) {
				progress("Fetching GraphQL enrichment…");
				gqlData = await API.fetchDiscussionGraphQL(owner, repo, number, signal);
				check();
			}

			// Discussion timeline (GraphQL only — REST 404s)
			let discussionTimeline = [];
			if (cfg.DISC_INCLUDE_TIMELINE && Token.has()) {
				progress("Fetching discussion timeline…");
				discussionTimeline = await API.fetchDiscussionTimeline(
					owner,
					repo,
					number,
					signal,
				);
				check();
			}

			const exportData = {
				type: "discussion",
				discussion: disc,
				comments,
				gqlData,
				discussionTimeline,
				cfg,
			};

			const markdown = MdGen.generate(exportData);

			// Count top-level comments
			const topLevel = comments.filter((c) => !c.parent_id);
			const replyCount = comments.length - topLevel.length;

			const summaryParts = [`${topLevel.length} comments`];
			if (replyCount > 0) summaryParts.push(`${replyCount} replies`);
			if (gqlData?.poll) summaryParts.push("poll");

			return {
				markdown,
				title: disc.title,
				commentCount: topLevel.length,
				summary: summaryParts.join(", "),
				type: "discussion",
			};
		},
	};

	// ════════════════════════════════════════════════════════════════════════════
	// TOAST NOTIFICATIONS
	// ════════════════════════════════════════════════════════════════════════════

	function showToast(message, type = "info") {
		document
			.querySelectorAll(".gh-exporter-toast")
			// biome-ignore lint/suspicious/useIterableCallbackReturn: remove() return unused
			.forEach((el) => el.remove());
		const toast = document.createElement("div");
		toast.className = "gh-exporter-toast";
		toast.textContent = message;
		const bg = { info: "#1f6feb", success: "#238636", error: "#da3633" };
		Object.assign(toast.style, {
			position: "fixed",
			bottom: "80px",
			left: "50%",
			transform: "translateX(-50%)",
			background: bg[type] || bg.info,
			color: "#fff",
			padding: "12px 24px",
			borderRadius: "8px",
			fontSize: "14px",
			fontFamily: '-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif',
			fontWeight: "500",
			zIndex: "2147483647",
			opacity: "0",
			transition: "opacity 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);
	}

	// ════════════════════════════════════════════════════════════════════════════
	// UNIFIED PANEL — CSS
	// ════════════════════════════════════════════════════════════════════════════

	function injectPanelCSS() {
		if (document.getElementById("ghe-css")) return;
		const s = document.createElement("style");
		s.id = "ghe-css";
		s.textContent = `
#ghe-root{position:fixed;bottom:20px;right:20px;z-index:999999;font-size:13px}
#ghe-toggle{background:#238636;color:#fff;padding:8px 12px;border-radius:8px;cursor:pointer;box-shadow:0 4px 12px rgba(0,0,0,.4);user-select:none;font-weight:600;font-size:13px}
#ghe-panel{background:#0d1117;border:1px solid #30363d;border-radius:8px;padding:8px;min-width:400px;max-width:90vw;box-shadow:0 8px 32px rgba(0,0,0,.6);color:#c9d1d9;display:none;max-height:calc(100vh - 40px);overflow-y:auto;box-sizing:border-box;color-scheme:dark;position:relative}
#ghe-sash{position:absolute;left:-3px;top:0;bottom:0;width:6px;cursor:ew-resize;z-index:1;background:transparent}
#ghe-sash:hover,#ghe-sash.active{background:rgba(88,166,255,.3);border-radius:3px}
.ghe-batch-disabled{opacity:.5;pointer-events:none}
.ghe-batch-hint{text-align:center;padding:12px 8px;color:#8b949e;font-size:11px;font-style:italic}
#ghe-panel input[type="checkbox"]{accent-color:#238636}
.ghe-hdr{display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;font-size:13px}
.ghe-close{cursor:pointer;color:#8b949e;font-size:16px;padding:2px 6px}
.ghe-sec{margin-bottom:6px;padding:10px 12px;border:1px solid #30363d;border-radius:6px}
.ghe-sec h3{margin:0 0 6px;font-size:13px;color:#f0f6fc;cursor:default}
.ghe-info{font-size:11px;color:#8b949e;font-weight:400;cursor:help;margin-left:4px;vertical-align:middle;display:inline-block;width:16px;height:16px;line-height:16px;text-align:center;border:1px solid #30363d;border-radius:50%;background:#161b22;position:relative}
.ghe-info:hover{color:#c9d1d9;border-color:#8b949e}
.ghe-tip{display:none;position:absolute;left:50%;transform:translateX(-50%);background:#1c2128;color:#c9d1d9;border:1px solid #444c56;border-radius:6px;padding:8px 10px;font-size:11px;font-weight:400;line-height:1.45;white-space:normal;width:max-content;max-width:280px;z-index:10;box-shadow:0 4px 12px rgba(0,0,0,.4);pointer-events:none;text-align:left}
.ghe-tip.above{bottom:calc(100% + 8px)}
.ghe-tip.below{top:calc(100% + 8px)}
.ghe-tip::after{content:'';position:absolute;left:var(--arrow-left,50%);transform:translateX(-50%);border:5px solid transparent}
.ghe-tip.above::after{top:100%;border-top-color:#444c56}
.ghe-tip.below::after{bottom:100%;border-bottom-color:#444c56}
.ghe-info:hover .ghe-tip{display:block}
.ghe-btn{padding:5px 8px;border:none;border-radius:5px;cursor:pointer;color:#fff;font-size:12px;white-space:nowrap}
.ghe-btn:disabled{opacity:.4;cursor:not-allowed}
.ghe-g{background:#238636}.ghe-b{background:#1f6feb}.ghe-p{background:#8957e5}.ghe-gr{background:#6e7781}.ghe-r{background:#da3633}
.ghe-row{display:flex;gap:3px;margin-top:6px;flex-wrap:wrap}
.ghe-row .ghe-btn{flex:1;text-align:center;min-width:0}
.ghe-st{color:#8b949e;font-size:11px;margin-top:4px;min-height:1.4em}
.ghe-warn{color:#f85149}
.ghe-rl{display:flex;align-items:center;gap:6px;padding:4px 8px;border-radius:4px;font-size:11px;color:#8b949e;background:#161b22;border:1px solid #21262d;margin-bottom:6px}
.ghe-rl-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}
.ghe-rl-dot.ok{background:#3fb950}.ghe-rl-dot.low{background:#d29922}.ghe-rl-dot.out{background:#f85149}
.ghe-rl-mini{display:inline-flex;align-items:center;gap:4px;font-size:10px;color:#8b949e}
.ghe-rl-mini .ghe-rl-dot{width:6px;height:6px}
.ghe-inp{width:60px;padding:3px;background:#21262d;border:1px solid #30363d;border-radius:4px;color:#fff;font-size:12px}
.ghe-fw{width:100%}
.ghe-chk{display:flex;flex-wrap:wrap;gap:4px 10px;margin-bottom:6px}
.ghe-chk label{display:flex;align-items:center;gap:4px;font-size:12px;color:#c9d1d9;cursor:pointer;white-space:nowrap}
.ghe-chk input{margin:0;cursor:pointer}
.ghe-exp-grid{display:grid;grid-template-columns:1fr 1fr;gap:2px 10px;font-size:12px;margin-bottom:6px}
.ghe-exp-grid label{display:flex;align-items:center;gap:4px;color:#c9d1d9;cursor:pointer;white-space:nowrap;padding:2px 0}
.ghe-exp-grid label.indent{padding-left:16px;color:#8b949e;font-size:11px}
.ghe-exp-grid label.disabled{opacity:.4;pointer-events:none}
.ghe-exp-grid input{margin:0;cursor:pointer;width:14px;height:14px}
.ghe-batch-list{height:180px;min-height:60px;overflow-y:auto;border:1px solid #30363d;border-radius:6px;background:#161b22;margin:6px 0;padding:0;font-size:12px;resize:vertical}
.ghe-batch-list::-webkit-scrollbar{width:6px}
.ghe-batch-list::-webkit-scrollbar-thumb{background:#30363d;border-radius:3px}
.ghe-batch-list::-webkit-scrollbar-track{background:transparent}
.ghe-batch-table{width:100%;border-collapse:collapse;font-size:12px}
.ghe-batch-table tr{cursor:pointer;user-select:none}
.ghe-batch-table tr:hover{background:#21262d}
.ghe-batch-table td{padding:2px 4px;vertical-align:middle;white-space:nowrap}
.ghe-batch-table td:first-child{width:20px;text-align:center}
.ghe-batch-table td:nth-child(2){width:32px}
.ghe-batch-table td:nth-child(3){width:20px;text-align:center;font-size:11px}
.ghe-batch-table td:last-child{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:0;width:100%}
.ghe-batch-table input{margin:0;cursor:pointer}
.ghe-batch-table .badge{font-size:9px;font-weight:600;padding:1px 4px;border-radius:3px;text-transform:uppercase;letter-spacing:.3px;display:inline-block}
.ghe-batch-table .badge.issue{background:#238636;color:#fff}
.ghe-batch-table .badge.pr{background:#1f6feb;color:#fff}
.ghe-batch-table .badge.discussion{background:#8957e5;color:#fff}
.ghe-log-wrap{position:relative}
.ghe-log-actions{position:absolute;top:3px;right:3px;display:flex;gap:2px;z-index:1;opacity:.2;transition:opacity .2s}
.ghe-log-wrap:hover .ghe-log-actions{opacity:1}
.ghe-log-act{background:rgba(33,38,45,.8);border:1px solid #30363d;border-radius:4px;color:#8b949e;font-size:13px;width:24px;height:24px;line-height:24px;text-align:center;padding:0;cursor:pointer;transition:color .15s,background .15s;display:inline-flex;align-items:center;justify-content:center}
.ghe-log-act:hover{color:#c9d1d9;background:rgba(33,38,45,.95)}
.ghe-batch-controls{display:flex;gap:3px;align-items:center;flex-wrap:wrap;margin-bottom:4px}
.ghe-batch-controls button{font-size:10px;padding:2px 6px;border:1px solid #30363d;background:#21262d;color:#c9d1d9;border-radius:4px;cursor:pointer}
.ghe-batch-controls button:hover{background:#30363d}
.ghe-batch-controls button.active{background:#1f6feb;color:#fff;border-color:#1f6feb}
.ghe-batch-title{color:#f0f6fc}
.ghe-batch-filter{flex:1;min-width:80px;padding:3px 6px;background:#21262d;border:1px solid #30363d;border-radius:4px;color:#c9d1d9;font-size:11px}
#ghe-panel.ghe-light{background:#ffffff;border-color:#d0d7de;color:#1f2328;color-scheme:light}
#ghe-panel.ghe-light .ghe-sec{border-color:#d0d7de;background:#ffffff}
#ghe-panel.ghe-light .ghe-sec h3{color:#1f2328}
#ghe-panel.ghe-light .ghe-info{background:#f6f8fa;border-color:#d0d7de;color:#656d76}
#ghe-panel.ghe-light .ghe-info:hover{color:#1f2328;border-color:#656d76}
#ghe-panel.ghe-light .ghe-tip{background:#ffffff;color:#1f2328;border-color:#d0d7de;box-shadow:0 4px 12px rgba(0,0,0,.12)}
#ghe-panel.ghe-light .ghe-tip.above::after{border-top-color:#d0d7de}
#ghe-panel.ghe-light .ghe-tip.below::after{border-bottom-color:#d0d7de}
#ghe-panel.ghe-light .ghe-st{color:#656d76}
#ghe-panel.ghe-light .ghe-close{color:#656d76}
#ghe-panel.ghe-light .ghe-close:hover{color:#1f2328}
#ghe-panel.ghe-light .ghe-hdr strong{color:#1f2328}
#ghe-panel.ghe-light a{color:#0969da}
#ghe-panel.ghe-light .ghe-inp{background:#f6f8fa;border-color:#d0d7de;color:#1f2328}
#ghe-panel.ghe-light .ghe-chk label{color:#1f2328}
#ghe-panel.ghe-light .ghe-exp-grid label{color:#1f2328}
#ghe-panel.ghe-light .ghe-exp-grid label.indent{color:#656d76}
#ghe-panel.ghe-light .ghe-exp-grid label.disabled{opacity:.4}
#ghe-panel.ghe-light #ghe-token-det summary{color:#656d76}
#ghe-panel.ghe-light #ghe-token-inp{background:#f6f8fa!important;border-color:#d0d7de!important;color:#1f2328!important}
#ghe-panel.ghe-light textarea{background:#f6f8fa!important;color:#1f2328!important;border-color:#d0d7de!important}
#ghe-panel.ghe-light code{background:#eaeef2!important;color:#1f2328!important}
#ghe-panel.ghe-light .ghe-rl{background:#f6f8fa;border-color:#d0d7de;color:#656d76}
#ghe-panel.ghe-light .ghe-rl-mini{color:#656d76}
#ghe-panel.ghe-light .ghe-btn.ghe-gr{background:#d0d7de!important;color:#1f2328!important}
#ghe-panel.ghe-light .ghe-btn.ghe-g{background:#1a7f37}
#ghe-panel.ghe-light .ghe-btn.ghe-b{background:#0969da}
#ghe-panel.ghe-light .ghe-btn.ghe-p{background:#7c3aed}
#ghe-panel.ghe-light .ghe-btn.ghe-r{background:#cf222e}
#ghe-panel.ghe-light .ghe-batch-list::-webkit-scrollbar-thumb{background:#d0d7de}
#ghe-panel.ghe-light .ghe-batch-list{background:#f6f8fa;border-color:#d0d7de}
#ghe-panel.ghe-light .ghe-batch-table tr:hover{background:#eaeef2}
#ghe-panel.ghe-light .ghe-batch-table td:last-child{color:#1f2328}
#ghe-panel.ghe-light .ghe-log-act{background:rgba(246,248,250,.9);border-color:#d0d7de;color:#656d76}
#ghe-panel.ghe-light .ghe-log-act:hover{color:#1f2328}
#ghe-panel.ghe-light .ghe-batch-controls button{background:#f6f8fa;border-color:#d0d7de;color:#1f2328}
#ghe-panel.ghe-light .ghe-batch-controls button:hover{background:#eaeef2}
#ghe-panel.ghe-light .ghe-batch-controls button.active{background:#0969da;color:#fff;border-color:#0969da}
#ghe-panel.ghe-light .ghe-batch-filter{background:#f6f8fa;border-color:#d0d7de;color:#1f2328}
#ghe-panel.ghe-light .ghe-batch-hint{color:#656d76}
#ghe-panel.ghe-light .ghe-warn{color:#cf222e}
#ghe-panel.ghe-light details summary{color:#656d76}
#ghe-panel.ghe-light #ghe-batch-wrap{border-color:#d0d7de}
#ghe-panel.ghe-light #ghe-sash:hover,#ghe-panel.ghe-light #ghe-sash.active{background:rgba(9,105,218,.2)}
#ghe-panel.ghe-light .ghe-batch-title{color:#1f2328}
#ghe-panel.ghe-light #ghe-batch-progress div[style*="background:#21262d"]{background:#e1e4e8!important}
#ghe-panel.ghe-light #ghe-batch-bar{background:#1a7f37}
`;
		document.head.appendChild(s);
	}

	// ════════════════════════════════════════════════════════════════════════════
	// THREAD EXPORT SETTINGS — full flag metadata with groups + indent + deps
	// ════════════════════════════════════════════════════════════════════════════

	/** Parent→children dependency map for export settings UI */
	const EXP_DEPS = {
		INCLUDE_HEADER: [
			"INCLUDE_LABELS",
			"INCLUDE_ASSIGNEES",
			"INCLUDE_MILESTONE",
		],
		PR_INCLUDE_REVIEW_COMMENTS: [
			"PR_INCLUDE_REVIEW_THREAD_STATE",
			"PR_REVIEW_COMMENTS_GROUP_BY_THREAD",
		],
		PR_INCLUDE_CHANGED_FILES: ["PR_INCLUDE_CHANGED_FILES_PATCHES"],
		INCLUDE_REFERENCES_SECTION: [
			"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",
		],
		INCLUDE_TIMELINE_SECTION: [
			"TL_INCLUDE_CROSS_REFERENCED",
			"TL_INCLUDE_REFERENCED",
			"TL_INCLUDE_RENAMED",
			"TL_INCLUDE_LABEL_CHANGES",
			"TL_INCLUDE_CLOSED_EVENTS",
			"TL_INCLUDE_REVIEWED",
			"TL_INCLUDE_COMMITTED",
			"TL_INCLUDE_MERGED",
			"TL_INCLUDE_HEAD_REF_EVENTS",
			"TL_INCLUDE_REVIEW_REQUESTED",
			"TL_INCLUDE_MARKED_DUPLICATE_EVENTS",
			"TL_INCLUDE_PROJECT_V2",
			"TL_INCLUDE_SUBSCRIBE_EVENTS",
			"TL_INCLUDE_MENTIONED_EVENTS",
		],
	};

	/** Reverse lookup: child key → parent key */
	const EXP_CHILD_TO_PARENT = {};
	for (const [parent, children] of Object.entries(EXP_DEPS)) {
		for (const child of children) EXP_CHILD_TO_PARENT[child] = parent;
	}

	const EXP_FLAGS = {
		// Content
		INCLUDE_HEADER: {
			l: "Include Header",
			g: "Content",
			t: "Include the metadata header block (state, labels, assignees, etc.)",
		},
		INCLUDE_LABELS: {
			l: "Labels",
			g: "Content",
			t: "Show labels in header",
			indent: 1,
		},
		INCLUDE_ASSIGNEES: {
			l: "Assignees",
			g: "Content",
			t: "Show assignees in header",
			indent: 1,
		},
		INCLUDE_MILESTONE: {
			l: "Milestone",
			g: "Content",
			t: "Show milestone in header",
			indent: 1,
		},
		INCLUDE_AUTHOR_INFO: {
			l: "Show Authors",
			g: "Content",
			t: "Show author names and profile links on comments",
		},
		INCLUDE_REACTIONS: {
			l: "Show Reactions",
			g: "Content",
			t: "Show emoji reactions on the body and comments",
		},
		INCLUDE_TIMESTAMPS: {
			l: "Show Timestamps",
			g: "Content",
			t: "Show created/updated/closed dates",
		},
		INCLUDE_COMMENT_IDS: {
			l: "Comment IDs",
			g: "Content",
			t: "Show internal comment IDs (useful for API references)",
		},
		// Issue
		ISSUE_INCLUDE_TYPE: {
			l: "Issue Type",
			g: "Issue",
			t: "Show issue type (bug, feature, task) if set",
		},
		ISSUE_INCLUDE_CLOSED_BY: {
			l: "Closed By",
			g: "Issue",
			t: "Show who closed the issue",
		},
		ISSUE_INCLUDE_LOCK_REASON: {
			l: "Lock Reason",
			g: "Issue",
			t: "Show lock reason if the issue is locked",
		},
		ISSUE_INCLUDE_DEPENDENCIES: {
			l: "Dependencies",
			g: "Issue",
			t: "Fetch and show blocked-by / blocking issue dependencies",
		},
		ISSUE_INCLUDE_SUB_ISSUES: {
			l: "Sub-Issues",
			g: "Issue",
			t: "Fetch and list sub-issues",
		},
		ISSUE_INCLUDE_PINNED_COMMENT: {
			l: "Pinned Comment",
			g: "Issue",
			t: "Show the pinned comment if one exists",
		},
		// PR
		PR_INCLUDE_MERGE_STATUS: {
			l: "Merge Status",
			g: "Pull Request",
			t: "Show merged/draft/open state and who merged",
		},
		PR_INCLUDE_BRANCH_INFO: {
			l: "Branch Info",
			g: "Pull Request",
			t: "Show head → base branch names",
		},
		PR_INCLUDE_DIFF_STATS: {
			l: "Diff Stats",
			g: "Pull Request",
			t: "Show additions/deletions/changed files count",
		},
		PR_INCLUDE_REQUESTED_REVIEWERS: {
			l: "Requested Reviewers",
			g: "Pull Request",
			t: "Show users and teams requested for review",
		},
		PR_INCLUDE_MERGE_REQUIREMENTS: {
			l: "Merge Requirements",
			g: "Pull Request",
			t: "Show branch protection rules and auto-merge status",
		},
		PR_INCLUDE_CHECKS_SECTION: {
			l: "Checks & Status",
			g: "Pull Request",
			t: "Show CI check runs and commit statuses",
		},
		PR_INCLUDE_REVIEWS: {
			l: "Reviews",
			g: "Pull Request",
			t: "Show review verdicts (approved, changes requested, etc.)",
		},
		PR_INCLUDE_REVIEW_COMMENTS: {
			l: "Review Comments",
			g: "Pull Request",
			t: "Show inline code review comments with diff context",
		},
		PR_INCLUDE_REVIEW_THREAD_STATE: {
			l: "Thread State (GQL)",
			g: "Pull Request",
			t: "Show resolved/outdated state on review threads (requires token)",
			indent: 1,
		},
		PR_REVIEW_COMMENTS_GROUP_BY_THREAD: {
			l: "Group by Thread",
			g: "Pull Request",
			t: "Group reply chains together instead of flat list",
			indent: 1,
		},
		PR_INCLUDE_PR_COMMITS: {
			l: "PR Commits",
			g: "Pull Request",
			t: "List all commits in the pull request",
		},
		PR_INCLUDE_CHANGED_FILES: {
			l: "Changed Files",
			g: "Pull Request",
			t: "List all changed files with status and line counts",
		},
		PR_INCLUDE_CHANGED_FILES_PATCHES: {
			l: "File Diffs",
			g: "Pull Request",
			t: "Include the actual diff patches for each file",
			indent: 1,
		},
		// Discussion
		DISC_INCLUDE_CATEGORY: {
			l: "Category",
			g: "Discussion",
			t: "Show discussion category (General, Q&A, etc.)",
		},
		DISC_INCLUDE_ANSWER: {
			l: "Answer",
			g: "Discussion",
			t: "Highlight the accepted answer comment in Q&A discussions",
		},
		DISC_INCLUDE_POLL: {
			l: "Poll",
			g: "Discussion",
			t: "Show poll question, options, and vote counts (requires token)",
		},
		DISC_INCLUDE_UPVOTES: {
			l: "Upvotes",
			g: "Discussion",
			t: "Show upvote count (requires token)",
		},
		DISC_INCLUDE_COMMENT_REPLIES: {
			l: "Comment Replies",
			g: "Discussion",
			t: "Show nested replies under each top-level comment",
		},
		DISC_INCLUDE_TIMELINE: {
			l: "Timeline (GQL)",
			g: "Discussion",
			t: "Include timeline events for discussions via GraphQL (labeled, closed, pinned, etc.)",
		},
		// References
		INCLUDE_REFERENCES_SECTION: {
			l: "References Section",
			g: "References",
			t: "Include a deduplicated references section from timeline events",
		},
		REF_INCLUDE_CROSS_REFERENCED: {
			l: "Cross References",
			g: "References",
			t: "Include issues/PRs that mention this thread",
			indent: 1,
		},
		REF_INCLUDE_SAME_REPO: {
			l: "Same-Repo Refs",
			g: "References",
			t: "Include references from the same repository",
			indent: 1,
		},
		REF_INCLUDE_CROSS_REPO: {
			l: "Cross-Repo Refs",
			g: "References",
			t: "Include references from other repositories",
			indent: 1,
		},
		REF_INCLUDE_ISSUES: {
			l: "Referenced Issues",
			g: "References",
			t: "Include referenced issues",
			indent: 1,
		},
		REF_INCLUDE_PRS: {
			l: "Referenced PRs",
			g: "References",
			t: "Include referenced pull requests",
			indent: 1,
		},
		REF_INCLUDE_DUPLICATES: {
			l: "Duplicates",
			g: "References",
			t: "Include items marked as duplicates",
			indent: 1,
		},
		REF_INCLUDE_COMMITS: {
			l: "Commit Refs",
			g: "References",
			t: "Include commits that reference this thread",
			indent: 1,
		},
		REF_FETCH_COMMIT_DETAILS: {
			l: "Fetch Commit Details",
			g: "References",
			t: "Fetch full commit message for each referenced commit (extra API calls)",
			indent: 1,
		},
		// Timeline
		INCLUDE_TIMELINE_SECTION: {
			l: "Timeline (Verbose)",
			g: "Timeline",
			t: "Include a verbose audit log of all timeline events",
		},
		TL_INCLUDE_CROSS_REFERENCED: {
			l: "Cross References",
			g: "Timeline",
			t: "Show cross-reference events in timeline",
			indent: 1,
		},
		TL_INCLUDE_REFERENCED: {
			l: "Commit Refs",
			g: "Timeline",
			t: "Show commit reference events in timeline",
			indent: 1,
		},
		TL_INCLUDE_RENAMED: {
			l: "Title Changes",
			g: "Timeline",
			t: "Show title rename events",
			indent: 1,
		},
		TL_INCLUDE_LABEL_CHANGES: {
			l: "Label Changes",
			g: "Timeline",
			t: "Show label added/removed events",
			indent: 1,
		},
		TL_INCLUDE_CLOSED_EVENTS: {
			l: "Closed/Reopened",
			g: "Timeline",
			t: "Show closed and reopened events",
			indent: 1,
		},
		TL_INCLUDE_REVIEWED: {
			l: "Reviews (PR)",
			g: "Timeline",
			t: "Show review events in timeline",
			indent: 1,
		},
		TL_INCLUDE_COMMITTED: {
			l: "Commits (PR)",
			g: "Timeline",
			t: "Show commit events in timeline",
			indent: 1,
		},
		TL_INCLUDE_MERGED: {
			l: "Merged (PR)",
			g: "Timeline",
			t: "Show merge events in timeline",
			indent: 1,
		},
		TL_INCLUDE_HEAD_REF_EVENTS: {
			l: "Branch Events (PR)",
			g: "Timeline",
			t: "Show force-push, delete, restore branch events",
			indent: 1,
		},
		TL_INCLUDE_REVIEW_REQUESTED: {
			l: "Review Requested (PR)",
			g: "Timeline",
			t: "Show review request/removal events",
			indent: 1,
		},
		TL_INCLUDE_MARKED_DUPLICATE_EVENTS: {
			l: "Marked Duplicate",
			g: "Timeline",
			t: "Show marked-as-duplicate events",
			indent: 1,
		},
		TL_INCLUDE_PROJECT_V2: {
			l: "Project v2 Events",
			g: "Timeline",
			t: "Show GitHub Projects v2 events (noisy)",
			indent: 1,
		},
		TL_INCLUDE_SUBSCRIBE_EVENTS: {
			l: "Subscribe Events",
			g: "Timeline",
			t: "Show subscribe/unsubscribe events (noisy)",
			indent: 1,
		},
		TL_INCLUDE_MENTIONED_EVENTS: {
			l: "Mentioned Events",
			g: "Timeline",
			t: "Show mentioned events (noisy)",
			indent: 1,
		},
		// Minimized
		INCLUDE_MINIMIZED_COMMENTS: {
			l: "Show Minimized Comments",
			g: "Content",
			t: "Include comments that were minimized (hidden) by moderators, with a note",
		},
		// Formatting
		COLLAPSIBLE_LONG_COMMENTS: {
			l: "Collapse Long Comments",
			g: "Formatting",
			t: "Wrap long comments in collapsible <details> blocks",
		},
	};

	const EXP_GROUP_ORDER = [
		"Content",
		"Issue",
		"Pull Request",
		"Discussion",
		"References",
		"Timeline",
		"Formatting",
	];

	// ════════════════════════════════════════════════════════════════════════════
	// UNIFIED PANEL
	// ════════════════════════════════════════════════════════════════════════════

	const Panel = (() => {
		const ID = "ghe-root";
		let threadExportAbort = null;
		let threadExporting = false;

		function create() {
			remove();
			injectPanelCSS();
			const pageInfo = Utils.parsePage();
			const isThread =
				pageInfo &&
				(pageInfo.type === "issue" ||
					pageInfo.type === "pr" ||
					pageInfo.type === "discussion");
			const isRelease = pageInfo && pageInfo.type === "release";

			const root = document.createElement("div");
			root.id = ID;
			const toggle = document.createElement("div");
			toggle.id = "ghe-toggle";
			toggle.textContent = isThread
				? "📥 Export"
				: isRelease
					? "📥 Release Export"
					: "📋 Repo Exporter";

			const panel = document.createElement("div");
			panel.id = "ghe-panel";

			// Build thread export settings HTML
			let expSettingsHtml = "";
			if (isThread) {
				const grouped = {};
				Object.entries(EXP_FLAGS).forEach(([k, m]) => {
					const g = m.g || "Other";
					if (!grouped[g]) grouped[g] = [];
					grouped[g].push({ key: k, ...m });
				});
				let settingsInner = "";
				EXP_GROUP_ORDER.forEach((gn) => {
					const flags = grouped[gn];
					if (!flags) return;
					settingsInner += `<div style="margin-bottom:6px"><div style="font-size:10px;font-weight:600;color:#8b949e;text-transform:uppercase;letter-spacing:.5px;margin-bottom:3px;border-bottom:1px solid #21262d;padding-bottom:2px">${gn}</div><div class="ghe-exp-grid">`;
					flags.forEach((f) => {
						const cls = f.indent ? ' class="indent"' : "";
						const checked = ThreadConfig.get(f.key) ? " checked" : "";
						const tip = f.t ? ` title="${Utils.escapeHtml(f.t)}"` : "";
						settingsInner += `<label${cls}${tip}><input type="checkbox" data-exp-key="${f.key}"${checked}> ${f.l}</label>`;
					});
					settingsInner += "</div></div>";
				});

				const typeLabel =
					pageInfo.type === "pr"
						? "Pull Request"
						: pageInfo.type === "discussion"
							? "Discussion"
							: "Issue";
				expSettingsHtml = `
<div class="ghe-sec" id="ghe-exp-sec">
  <h3>📥 Export This ${typeLabel} #${pageInfo.number} <span class="ghe-info">?<span class="ghe-tip">Export this ${typeLabel.toLowerCase()} as a Markdown file with full content.<br><br><b>What's included:</b> body, all comments, metadata, reactions, labels, and any sections enabled in Export Settings below.<br><br><b>How to use:</b><br>1. Adjust settings if needed (⚙️ below)<br>2. Click Export or Preview<br>3. Download the .md file or view rendered HTML</span></span></h3>
  <div id="ghe-exp-st" class="ghe-st">Ready to export.</div>
  <button id="ghe-exp-go" class="ghe-btn ghe-g ghe-fw" style="margin-top:6px" title="Download this ${typeLabel.toLowerCase()} as a .md Markdown file">📥 Export to Markdown</button>
  <button id="ghe-exp-preview" class="ghe-btn ghe-b ghe-fw" style="margin-top:4px" title="Render the Markdown and open it as styled HTML in a new tab — includes table of contents, copy button, and raw/rendered toggle">👁️ Preview in New Tab</button>
  <details style="margin-top:8px">
    <summary style="cursor:pointer;font-size:12px;color:#8b949e;user-select:none" title="Configure which sections to include in the export (reviews, timeline, references, etc.)">⚙️ Export Settings</summary>
    <div style="margin-top:6px">
      ${settingsInner}
      <div class="ghe-row"><button id="ghe-exp-reset" class="ghe-btn ghe-gr" style="font-size:11px" title="Reset all export settings to their default values">Reset to Defaults</button></div>
    </div>
  </details>
</div>`;
			}

			safeSetInnerHTML(
				panel,
				`
<div class="ghe-hdr"><strong style="font-size:12px">${SCRIPT_NAME}</strong><div style="display:flex;align-items:center;gap:4px"><button id="ghe-help" class="ghe-btn ghe-gr" style="font-size:11px;padding:2px 6px" title="Open full help & documentation in a new tab">📖</button><button id="ghe-theme" class="ghe-btn ghe-gr" style="font-size:11px;padding:2px 6px" title="Toggle between light and dark panel theme">🌙</button><a id="ghe-kofi" href="https://ko-fi.com/piknockyou" target="_blank" rel="noopener noreferrer" class="ghe-btn ghe-gr" style="font-size:11px;padding:2px 6px;text-decoration:none;color:#fff" title="Buy me a coffee — support this script's development">☕</a><span class="ghe-close" id="ghe-x" title="Close panel (click the toggle button to reopen)">✕</span></div></div>
<div style="font-size:11px;color:#8b949e;margin-bottom:4px">${Utils.repoFullName() || ""}</div>
<div class="ghe-rl" id="ghe-rl-bar" title="GitHub API rate limit — how many requests you have left this hour.&#10;Green = plenty, Yellow = running low, Red = exhausted.&#10;Resets every hour. Add a token for 5,000/hr (vs 60 without).">
  <span class="ghe-rl-dot" id="ghe-rl-dot"></span>
  <span id="ghe-rl-text">API quota: no requests yet</span>
</div>

<div class="ghe-sec" style="padding:8px 12px">
  <details id="ghe-token-det">
    <summary style="cursor:pointer;font-size:12px;color:#8b949e;user-select:none">🔑 GitHub Token <span id="ghe-token-badge" style="font-size:11px" title="Token status — a token increases API rate limits from 60 to 5,000 requests/hour and enables Discussion exports"></span></summary>
    <div style="margin-top:6px">
      <div style="font-size:11px;color:#8b949e;margin-bottom:4px">Required for Discussions export & higher rate limits (60→5k/hr).<br>
      <a href="https://github.com/settings/tokens" target="_blank" style="color:#58a6ff">Settings → Tokens</a> · scope: <code style="background:#21262d;padding:1px 4px;border-radius:3px;font-size:11px">public_repo</code></div>
      <div style="display:flex;gap:4px;align-items:center">
        <input id="ghe-token-inp" type="password" placeholder="ghp_…" title="Paste your GitHub personal access token here" style="flex:1;padding:4px 6px;background:#21262d;border:1px solid #30363d;border-radius:4px;color:#c9d1d9;font-family:monospace;font-size:12px" value="${Token.has() ? "••••••••" : ""}">
        <button id="ghe-token-save" class="ghe-btn ghe-g" style="font-size:11px;padding:4px 8px" title="Save token to local storage (encrypted by your userscript manager)">Save</button>
        <button id="ghe-token-clear" class="ghe-btn ghe-gr" style="font-size:11px;padding:4px 8px" title="Remove saved token">Clear</button>
      </div>
      <div id="ghe-token-st" class="ghe-st" style="margin-top:4px"></div>
    </div>
  </details>
</div>

${expSettingsHtml}

${
	isRelease
		? `
<div class="ghe-sec" id="ghe-single-rel-sec">
  <h3>📦 Export This Release <span class="ghe-info">?<span class="ghe-tip">Export this specific release's notes, changelog, and asset list.<br><br><b>What's included:</b> release title, description/changelog, download links, file sizes, reactions.<br><br><b>Not included:</b> actual binary files — only metadata and links.</span></span></h3>
  <div style="font-size:12px;color:#8b949e;margin-bottom:6px">Tag: <code style="background:#21262d;padding:1px 4px;border-radius:3px">${Utils.escapeHtml(pageInfo.tag || "")}</code></div>
  <div id="ghe-single-rel-st" class="ghe-st">Ready to export.</div>
  <div class="ghe-row" style="margin-top:6px">
    <button id="ghe-single-rel-html" class="ghe-btn ghe-b" title="Open release notes as styled HTML in a new tab">🔗 HTML</button>
    <button id="ghe-single-rel-save-h" class="ghe-btn ghe-b" title="Download release notes as an HTML file">💾 HTML</button>
    <button id="ghe-single-rel-save-m" class="ghe-btn ghe-p" title="Download release notes as a Markdown file">💾 MD</button>
  </div>
</div>
`
		: ""
}

<div class="ghe-sec" id="ghe-rel-sec">
  <h3>📦 Release Notes &amp; Changelogs <span class="ghe-info">?<span class="ghe-tip">Fetch and export release notes for all releases in this repository.<br><br><b>What's included:</b> version tags, changelogs, asset download links, file sizes, reactions.<br><br><b>How to use:</b><br>1. (Optional) Enter a filter to narrow results<br>2. Click Start to fetch all releases<br>3. Pause anytime, resume later<br>4. Download as HTML, Markdown, or ZIP (one file per release)<br><br><b>Note:</b> This exports release <em>notes</em>, not the actual binary files.</span></span></h3>
  <div style="display:flex;align-items:center;gap:4px;font-size:12px;margin-bottom:4px">
    <span style="flex-shrink:0">Filter:</span>
    <input id="ghe-rel-filter" type="text" class="ghe-inp" style="width:140px;flex-shrink:0" placeholder="2009, v1.*, v1..v2" title="Filter by date, tag glob, tag range, or exact tag — hover the ? for details">
    <span class="ghe-info">?<span class="ghe-tip above"><b>Release Filter</b><br><br><b>By date (use <code>..</code> for ranges):</b><br><code>2009</code> — all of 2009<br><code>2009-06</code> — June 2009 only<br><code>2009-06-15</code> — exact day<br><code>2009..2010</code> — 2009 through 2010<br><code>2009-01..2010-06</code> — Jan 2009 to Jun 2010<br><code>2024-01-01..2025-06-30</code> — exact range<br><br><b>By tag name:</b><br><code>v1.*</code> — wildcard / glob<br><code>v2.0.*</code> — any v2.0.x tag<br><code>v1.0.0..v2.0.0</code> — tag range<br><code>v1.0.0-v2.0.0</code> — tag range (<code>-</code> also works for tags)<br><code>v2.35.1</code> — exact tag<br><br><b>Date format:</b> <code>YYYY</code>, <code>YYYY-MM</code>, or <code>YYYY-MM-DD</code><br><b>Range separator:</b> always <code>..</code> for dates<br>(<code>-</code> is only for tag ranges since dates already use dashes)<br><br>Leave empty to fetch all.</span></span>
  </div>
  <div id="ghe-rel-st" class="ghe-st" style="display:flex;justify-content:space-between;align-items:center"><span id="ghe-rel-st-txt">No data yet.</span><label style="display:flex;align-items:center;gap:3px;font-size:11px;color:#8b949e;cursor:pointer;white-space:nowrap;flex-shrink:0" title="Show detailed API request log for debugging"><input type="checkbox" id="ghe-rel-dbg" ${Prefs.get("relDebug", false) ? "checked" : ""} style="margin:0;cursor:pointer"> Log</label></div>
  <div id="ghe-rel-dbg-wrap" style="display:none;margin-top:4px">
    <div class="ghe-log-wrap"><textarea id="ghe-rel-dbg-log" readonly style="width:100%;height:80px;background:#161b22;color:#8b949e;border:1px solid #30363d;border-radius:4px;font-family:monospace;font-size:10px;padding:4px;resize:vertical;box-sizing:border-box"></textarea><div class="ghe-log-actions"><button class="ghe-log-act" data-log-action="clear" data-log-target="ghe-rel-dbg-log" data-log-module="releases" title="Clear the debug log">🗑</button><button class="ghe-log-act" data-log-action="copy" data-log-target="ghe-rel-dbg-log" title="Copy debug log to clipboard">📋</button></div></div>
  </div>
  <div class="ghe-row" style="margin-top:6px">
    <button id="ghe-rel-go" class="ghe-btn ghe-g" style="flex:2" title="Fetch all releases from this repository">Start</button>
    <button id="ghe-rel-cancel" class="ghe-btn ghe-r" style="display:none" title="Pause fetching — you can resume later">⏸ Pause</button>
    <button id="ghe-rel-reset" class="ghe-btn ghe-gr" style="display:none" title="Discard all fetched data and start fresh">✕ Reset</button>
  </div>
  <div class="ghe-row" style="margin-top:6px">
    <button id="ghe-rel-open" class="ghe-btn ghe-b" disabled title="Open all release notes as styled HTML in a new tab">🔗 HTML</button>
    <button id="ghe-rel-save-h" class="ghe-btn ghe-b" disabled title="Download all release notes as a single HTML file">💾 HTML</button>
    <button id="ghe-rel-save-m" class="ghe-btn ghe-p" disabled title="Download all release notes as a single Markdown file">💾 MD</button>
    <button id="ghe-rel-save-zip" class="ghe-btn ghe-b" disabled title="Download as ZIP with one Markdown file per release, plus combined files">📦 ZIP</button>
  </div>
</div>

<div class="ghe-sec" id="ghe-iss-sec">
  <h3>📋 Issues · PRs · Discussions — Index <span class="ghe-info">?<span class="ghe-tip">Generate a lightweight listing of all issues, PRs, and/or discussions.<br><br><b>What's included:</b> number, title, state, labels, and link for each item.<br><b>Not included:</b> body text, comments, reviews, diffs.<br><br><b>How to use:</b><br>1. Check which types to include<br>2. (Optional) Set a number range<br>3. Click Start — pause/resume anytime<br>4. Download as HTML or Markdown<br><br><b>Tip:</b> This index is also used by Batch Full Export below to know which items exist.</span></span></h3>
  <div class="ghe-chk">
    <label title="Include issues in the index"><input type="checkbox" id="ghe-chk-iss" ${Prefs.get("issues", true) ? "checked" : ""}> Issues</label>
    <label title="Include pull requests in the index"><input type="checkbox" id="ghe-chk-pr" ${Prefs.get("prs", true) ? "checked" : ""}> Pull Requests</label>
    <label title="Include discussions in the index (requires a GitHub token)"><input type="checkbox" id="ghe-chk-dsc" ${Prefs.get("discussions", true) ? "checked" : ""}> Discussions 🔑</label>
  </div>
  <div style="display:flex;align-items:center;gap:4px;font-size:12px;margin-bottom:4px">
    <span style="flex-shrink:0" title="Limit which item numbers to fetch.&#10;Examples: 1-100, 50-200, 1-50,100-150">Range: #</span>
    <input id="ghe-iss-from" type="text" class="ghe-inp" style="width:90px" placeholder="e.g. 1-500" title="Enter a number range to fetch specific items.&#10;Examples:&#10;  1-500     — items #1 through #500&#10;  1-50,100-150 — two ranges&#10;  42        — single item&#10;Leave empty to fetch all.">
  </div>
  <div id="ghe-iss-pr" class="ghe-st" style="display:flex;justify-content:space-between;align-items:center"><span id="ghe-iss-pr-txt">No data yet.</span><label style="display:flex;align-items:center;gap:3px;font-size:11px;color:#8b949e;cursor:pointer;white-space:nowrap;flex-shrink:0" title="Show detailed API request log for debugging"><input type="checkbox" id="ghe-chk-dbg" ${Prefs.get("debug", false) ? "checked" : ""} style="margin:0;cursor:pointer"> Log</label></div>
  <div id="ghe-dbg-wrap" style="display:none;margin-top:4px">
    <div class="ghe-log-wrap"><textarea id="ghe-dbg-log" readonly style="width:100%;height:80px;background:#161b22;color:#8b949e;border:1px solid #30363d;border-radius:4px;font-family:monospace;font-size:10px;padding:4px;resize:vertical;box-sizing:border-box"></textarea><div class="ghe-log-actions"><button class="ghe-log-act" data-log-action="clear" data-log-target="ghe-dbg-log" data-log-module="index" title="Clear the debug log">🗑</button><button class="ghe-log-act" data-log-action="copy" data-log-target="ghe-dbg-log" title="Copy debug log to clipboard">📋</button></div></div>
  </div>
  <div class="ghe-row" style="margin-top:6px">
    <button id="ghe-iss-go" class="ghe-btn ghe-g" style="flex:2" title="Fetch the index of issues, PRs, and discussions">Start</button>
    <button id="ghe-iss-cancel" class="ghe-btn ghe-r" style="display:none" title="Pause fetching — you can resume later">⏸ Pause</button>
    <button id="ghe-iss-reset" class="ghe-btn ghe-gr" style="display:none" title="Discard all fetched data and start fresh">✕ Reset</button>
  </div>
  <div class="ghe-row" style="margin-top:6px">
    <button id="ghe-iss-open" class="ghe-btn ghe-b" disabled title="Open the index as styled HTML in a new tab">🔗 HTML</button>
    <button id="ghe-iss-dl-h" class="ghe-btn ghe-b" disabled title="Download the index as an HTML file">💾 HTML</button>
    <button id="ghe-iss-dl-m" class="ghe-btn ghe-p" disabled title="Download the index as a Markdown file">💾 MD</button>
  </div>
  <div id="ghe-batch-wrap" style="margin-top:8px;padding-top:8px;border-top:1px solid #30363d">
    <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:4px">
      <span class="ghe-batch-title" style="font-size:12px;font-weight:600">📥 Batch Full Export <span class="ghe-info">?<span class="ghe-tip">Export the FULL content of selected issues, PRs, and discussions as Markdown.<br>Unlike the index above (titles only), this downloads everything:<br>• Issue/PR body and all comments<br>• PR reviews, review comments, commits, changed files<br>• Discussion replies, polls, answer highlighting<br>• Metadata, reactions, references, timeline<br><br>⚠️ Uses 5–15 API calls per item. Use a token for higher rate limits.</span></span></span>
      <span id="ghe-batch-count" style="font-size:11px;color:#8b949e"></span> <span id="ghe-batch-status" style="font-size:11px"></span>
    </div>
    <div id="ghe-batch-token-warn" style="display:none;font-size:11px;color:#f85149;margin-bottom:6px">⚠️ No token set. Batch export uses 5–15 API calls per item. You will likely hit the 60 req/hr unauthenticated limit. <a href="https://github.com/settings/tokens" target="_blank" style="color:#58a6ff">Add a token</a> for 5,000 req/hr.</div>
    <details style="margin-bottom:6px">
      <summary style="cursor:pointer;font-size:12px;color:#8b949e;user-select:none" title="Configure which sections to include in each exported item (same settings as single-page export)">⚙️ Export Settings (applies to batch)</summary>
      <div id="ghe-batch-settings" style="margin-top:6px"></div>
    </details>
    <div id="ghe-batch-picker" style="display:none">
      <div class="ghe-batch-controls">
        <button id="ghe-batch-sel-all" title="Select all visible items">All</button>
        <button id="ghe-batch-sel-none" title="Deselect all">None</button>
        <button id="ghe-batch-sel-invert" title="Invert selection">Invert</button>
        <span style="width:1px;height:14px;background:#30363d;flex-shrink:0"></span>
        <button id="ghe-batch-sel-issues" title="Select only issues">Issues</button>
        <button id="ghe-batch-sel-prs" title="Select only PRs">PRs</button>
        <button id="ghe-batch-sel-disc" title="Select only discussions">Disc</button>
        <span style="width:1px;height:14px;background:#30363d;flex-shrink:0"></span>
        <button id="ghe-batch-st-all" class="active" title="Select all items">All</button>
        <button id="ghe-batch-st-open" title="Select only open items">Open</button>
        <button id="ghe-batch-st-closed" title="Select only closed items">Closed</button>
        <button id="ghe-batch-st-merged" title="Select only merged PRs">Merged</button>
        <button id="ghe-batch-st-answered" title="Select only answered discussions">Answered</button>
      </div>
      <div style="margin-bottom:3px"><input type="text" id="ghe-batch-search" class="ghe-batch-filter" style="width:100%" placeholder="Filter by title or #number…"></div>
      <div id="ghe-batch-list" class="ghe-batch-list"><table class="ghe-batch-table"><tbody id="ghe-batch-tbody"></tbody></table></div>
      <div style="font-size:11px;color:#8b949e;margin-top:2px"><span id="ghe-batch-sel-count">0</span> of <span id="ghe-batch-total-count">0</span> selected</div>
    </div>
    <div id="ghe-batch-st-el" class="ghe-st">Ready.</div>
    <div id="ghe-batch-progress" style="display:none;margin-top:4px">
      <div style="display:flex;align-items:center;gap:6px">
        <div style="flex:1;background:#21262d;border-radius:4px;height:6px;overflow:hidden">
          <div id="ghe-batch-bar" style="background:#238636;height:100%;width:0%;transition:width 0.3s"></div>
        </div>
        <span id="ghe-batch-rl-mini" class="ghe-rl-mini"><span class="ghe-rl-dot"></span><span></span></span>
      </div>
      <div id="ghe-batch-detail" class="ghe-st" style="margin-top:2px"></div>
    </div>
    <div id="ghe-batch-dbg-st" class="ghe-st" style="display:flex;justify-content:flex-end;margin-top:2px"><label style="display:flex;align-items:center;gap:3px;font-size:11px;color:#8b949e;cursor:pointer;white-space:nowrap" title="Show debug log for batch export"><input type="checkbox" id="ghe-batch-dbg-chk" ${Prefs.get("batchDebug", false) ? "checked" : ""} style="margin:0;cursor:pointer"> Log</label></div>
    <div id="ghe-batch-dbg-wrap" style="display:none;margin-top:4px">
      <div class="ghe-log-wrap"><textarea id="ghe-batch-dbg-log" readonly style="width:100%;height:80px;background:#161b22;color:#8b949e;border:1px solid #30363d;border-radius:4px;font-family:monospace;font-size:10px;padding:4px;resize:vertical;box-sizing:border-box"></textarea><div class="ghe-log-actions"><button class="ghe-log-act" data-log-action="clear" data-log-target="ghe-batch-dbg-log" data-log-module="batch" title="Clear log">🗑</button><button class="ghe-log-act" data-log-action="copy" data-log-target="ghe-batch-dbg-log" title="Copy log">📋</button></div></div>
    </div>
    <div class="ghe-row" style="margin-top:6px">
      <button id="ghe-batch-go" class="ghe-btn ghe-g" title="Export full content for all selected items">📥 Export Selected</button>
      <button id="ghe-batch-cancel" class="ghe-btn ghe-r" style="display:none" title="Pause the batch export — you can resume later">⏸ Pause</button>
      <button id="ghe-batch-reset" class="ghe-btn ghe-gr" style="display:none" title="Discard all exported content and start fresh">✕ Reset</button>
    </div>
    <div class="ghe-row" style="margin-top:4px">
      <button id="ghe-batch-dl" class="ghe-btn ghe-p" disabled title="Download all exported items as one combined Markdown file">💾 Single MD</button>
      <button id="ghe-batch-dl-zip" class="ghe-btn ghe-b" disabled title="Download as ZIP with one Markdown file per item, plus a combined file and index">📦 ZIP</button>
    </div>
  </div>
</div>
`,
			);

			// Resize sash
			const sash = document.createElement("div");
			sash.id = "ghe-sash";
			sash.title = "Drag to resize";
			panel.appendChild(sash);

			root.appendChild(toggle);
			root.appendChild(panel);
			document.body.appendChild(root);

			// ── Sash resize logic ──
			let sashDragging = false;
			let sashStartX = 0;
			let sashStartW = 0;

			sash.addEventListener("mousedown", (e) => {
				e.preventDefault();
				sashDragging = true;
				sashStartX = e.clientX;
				sashStartW = panel.offsetWidth;
				sash.classList.add("active");
				document.body.style.cursor = "ew-resize";
				document.body.style.userSelect = "none";
			});

			document.addEventListener("mousemove", (e) => {
				if (!sashDragging) return;
				const delta = sashStartX - e.clientX;
				const newW = Math.max(
					400,
					Math.min(window.innerWidth * 0.9, sashStartW + delta),
				);
				panel.style.width = newW + "px";
			});

			document.addEventListener("mouseup", () => {
				if (!sashDragging) return;
				sashDragging = false;
				sash.classList.remove("active");
				document.body.style.cursor = "";
				document.body.style.userSelect = "";
				// Persist width
				Prefs.set("panelWidth", panel.offsetWidth);
			});

			// Toggle
			const flip = () => {
				const open = panel.style.display === "block";
				panel.style.display = open ? "none" : "block";
				toggle.style.display = open ? "block" : "none";
				if (!open) {
					const rr = root.getBoundingClientRect();
					panel.style.maxHeight =
						window.innerHeight - (window.innerHeight - rr.bottom) - 20 + "px";
					// Restore persisted width or auto-size
					const savedW = Prefs.get("panelWidth", null);
					if (savedW && savedW >= 400) {
						panel.style.width =
							Math.min(savedW, window.innerWidth * 0.9) + "px";
					} else {
						requestAnimationFrame(() => {
							panel.style.width = panel.offsetWidth + "px";
						});
					}
				} else {
					panel.style.width = "";
				}
			};
			toggle.onclick = flip;
			panel.querySelector("#ghe-x").onclick = flip;

			// Theme
			const themeBtn = panel.querySelector("#ghe-theme");
			const applyTheme = (light) => {
				panel.classList.toggle("ghe-light", light);
				themeBtn.textContent = light ? "☀️" : "🌙";
			};
			applyTheme(Prefs.get("lightMode", false));
			themeBtn.onclick = () => {
				const n = !Prefs.get("lightMode", false);
				Prefs.set("lightMode", n);
				applyTheme(n);
			};
			panel
				.querySelector("#ghe-kofi")
				.addEventListener("click", (e) => e.stopPropagation());

			// Help button
			panel.querySelector("#ghe-help").onclick = () => openHelpWindow();

			wireToken(panel);
			wireRateLimit(panel);
			if (isRelease) wireSingleRelease(panel, pageInfo);
			wireReleases(panel);
			wireIndex(panel);
			if (isThread) wireThreadExport(panel, pageInfo);
			// Wire all log action buttons after all sections are wired (clear handlers registered)
			wireLogActions(panel);
			// Reposition tooltips that would clip outside the panel
			wireTooltipPositioning(panel);
		}

		/** Map of module name → clear callback, populated by each section's wiring */
		const logClearHandlers = {};

		/**
		 * Wire all log action buttons (copy + clear) in one pass.
		 * Each button has data-log-action="copy"|"clear" and data-log-target="textareaId".
		 * Clear buttons also have data-log-module="releases"|"index"|"batch".
		 */
		function wireLogActions(panel) {
			panel.querySelectorAll(".ghe-log-act").forEach((btn) => {
				btn.addEventListener("click", () => {
					const targetId = btn.dataset.logTarget;
					const textarea = panel.querySelector(`#${targetId}`);
					const action = btn.dataset.logAction;

					if (action === "copy") {
						if (!textarea?.value) return;
						navigator.clipboard
							.writeText(textarea.value)
							.then(() => {
								const prev = btn.textContent;
								btn.textContent = "✅";
								setTimeout(() => {
									btn.textContent = prev;
								}, 1500);
							})
							.catch(() => {
								showToast("Copy failed", "error");
							});
					} else if (action === "clear") {
						const mod = btn.dataset.logModule;
						if (logClearHandlers[mod]) logClearHandlers[mod]();
						if (textarea) textarea.value = "";
					}
				});
			});
		}

		// ── Unified Rate Limit Display ──
		function wireRateLimit(panel) {
			const dot = panel.querySelector("#ghe-rl-dot");
			const text = panel.querySelector("#ghe-rl-text");
			if (!dot || !text) return;

			let lastCoreR = null,
				lastGqlR = null;

			function updateDisplay() {
				const c = API.rateLimit.core;
				const g = API.rateLimit.graphql;

				if (c.remaining === lastCoreR && g.remaining === lastGqlR) return;
				lastCoreR = c.remaining;
				lastGqlR = g.remaining;

				if (c.remaining === null && g.remaining === null) {
					dot.className = "ghe-rl-dot";
					text.textContent = "API quota: no requests yet";
					text.style.color = "";
					return;
				}

				const parts = [];
				if (c.remaining !== null) parts.push(`REST: ${c.remaining}/hr`);
				if (g.remaining !== null) parts.push(`GQL: ${g.remaining}/hr`);
				const lowest = API.rateLimitSummary;
				const resetStr = lowest.reset ? Utils.formatReset(lowest.reset) : "?";

				if (lowest.remaining <= 0) {
					dot.className = "ghe-rl-dot out";
					text.textContent = `⚠ ${parts.join(" · ")} · resets ${resetStr}`;
					text.style.color = "#f85149";
				} else if (lowest.remaining <= 100) {
					dot.className = "ghe-rl-dot low";
					text.textContent = `${parts.join(" · ")} · resets ${resetStr}`;
					text.style.color = "#d29922";
				} else {
					dot.className = "ghe-rl-dot ok";
					text.textContent = `${parts.join(" · ")}`;
					text.style.color = "";
				}
			}

			setInterval(updateDisplay, 1000);
			updateDisplay();
		}

		// ── Single Release ──
		function wireSingleRelease(panel, pageInfo) {
			const sec = panel.querySelector("#ghe-single-rel-sec");
			if (!sec) return;
			const stEl = sec.querySelector("#ghe-single-rel-st");
			const btnHtml = sec.querySelector("#ghe-single-rel-html");
			const btnSaveH = sec.querySelector("#ghe-single-rel-save-h");
			const btnSaveM = sec.querySelector("#ghe-single-rel-save-m");
			let cached = null; // { release, html, md }

			async function ensureFetched() {
				if (cached) return cached;
				stEl.textContent = "Fetching release…";
				// biome-ignore lint/suspicious/useIterableCallbackReturn: assignment return unused
				[btnHtml, btnSaveH, btnSaveM].forEach((b) => (b.disabled = true));
				try {
					const { data } = await API.request(
						`/repos/${pageInfo.owner}/${pageInfo.repo}/releases/tags/${encodeURIComponent(pageInfo.tag)}`,
					);
					const result = ReleasesModule.buildFrom([data], pageInfo.fullName);
					cached = { release: data, html: result.html, md: result.md };
					stEl.textContent = `✅ ${data.name || data.tag_name || pageInfo.tag}`;
					// biome-ignore lint/suspicious/useIterableCallbackReturn: assignment return unused
					[btnHtml, btnSaveH, btnSaveM].forEach((b) => (b.disabled = false));
					return cached;
				} catch (e) {
					stEl.textContent = `❌ ${e.message}`;
					// biome-ignore lint/suspicious/useIterableCallbackReturn: assignment return unused
					[btnHtml, btnSaveH, btnSaveM].forEach((b) => (b.disabled = false));
					return null;
				}
			}

			const slug = () =>
				Utils.sanitizeFilename(cached?.release?.name || pageInfo.tag);
			btnHtml.onclick = async () => {
				const c = await ensureFetched();
				if (c) Utils.openBlob(c.html, "text/html");
			};
			btnSaveH.onclick = async () => {
				const c = await ensureFetched();
				if (c)
					Utils.downloadBlob(c.html, `${slug()}-release.html`, "text/html");
			};
			btnSaveM.onclick = async () => {
				const c = await ensureFetched();
				if (c)
					Utils.downloadBlob(c.md, `${slug()}-release.md`, "text/markdown");
			};
		}

		// ── Token ──
		function wireToken(panel) {
			const inp = panel.querySelector("#ghe-token-inp");
			const badge = panel.querySelector("#ghe-token-badge");
			const st = panel.querySelector("#ghe-token-st");
			const updateBadge = () => {
				badge.textContent = Token.has() ? "✅" : "(not set)";
				badge.style.color = Token.has() ? "#3fb950" : "#f85149";
			};
			updateBadge();
			panel.querySelector("#ghe-token-save").onclick = () => {
				const v = inp.value.trim();
				if (!v || v === "••••••••") {
					st.textContent = "Enter a token first.";
					return;
				}
				Token.set(v);
				inp.value = "••••••••";
				inp.type = "password";
				st.textContent = "Token saved.";
				updateBadge();
				setTimeout(() => {
					st.textContent = "";
				}, 3000);
			};
			panel.querySelector("#ghe-token-clear").onclick = () => {
				Token.clear();
				inp.value = "";
				st.textContent = "Token cleared.";
				updateBadge();
				setTimeout(() => {
					st.textContent = "";
				}, 3000);
			};
			inp.onfocus = () => {
				if (inp.value === "••••••••") {
					inp.value = "";
					inp.type = "text";
				}
			};
			inp.onblur = () => {
				if (!inp.value && Token.has()) {
					inp.value = "••••••••";
					inp.type = "password";
				}
			};
		}

		// ── Releases ──
		function wireReleases(panel) {
			const sec = panel.querySelector("#ghe-rel-sec");
			const btnGo = sec.querySelector("#ghe-rel-go");
			const btnCancel = sec.querySelector("#ghe-rel-cancel");
			const btnReset = sec.querySelector("#ghe-rel-reset");
			const btnOpen = sec.querySelector("#ghe-rel-open");
			const btnSaveH = sec.querySelector("#ghe-rel-save-h");
			const btnSaveM = sec.querySelector("#ghe-rel-save-m");
			const stTxt = sec.querySelector("#ghe-rel-st-txt");
			const dbgChk = sec.querySelector("#ghe-rel-dbg");
			const dbgWrap = sec.querySelector("#ghe-rel-dbg-wrap");
			const dbgLog = sec.querySelector("#ghe-rel-dbg-log");
			const dlBtns = [btnOpen, btnSaveH, btnSaveM];

			dbgChk.onchange = function () {
				Prefs.set("relDebug", this.checked);
				ReleasesModule.setDebug(this.checked);
				dbgWrap.style.display = this.checked ? "block" : "none";
			};
			ReleasesModule.setDebug(dbgChk.checked);
			dbgWrap.style.display = dbgChk.checked ? "block" : "none";
			logClearHandlers.releases = () => ReleasesModule.clearDebugLog();

			// Release filter input
			const relFilterInp = sec.querySelector("#ghe-rel-filter");
			relFilterInp.addEventListener("input", () => {
				ReleasesModule.setReleaseFilter(relFilterInp.value.trim());
			});

			const refreshDbg = () => {
				if (dbgChk.checked) {
					dbgLog.value = ReleasesModule.getDebugLog();
					dbgLog.scrollTop = dbgLog.scrollHeight;
				}
			};
			// biome-ignore lint/suspicious/useIterableCallbackReturn: assignment return unused
			const enableDl = (on) => dlBtns.forEach((b) => (b.disabled = !on));

			const updateUI = () => {
				const s = ReleasesModule.state();
				if (s.running) {
					btnGo.style.display = "none";
					btnCancel.style.display = "inline-block";
					btnReset.style.display = "none";
				} else if (s.paused) {
					btnGo.style.display = "inline-block";
					btnGo.textContent = `▶ Resume (${s.count} fetched)`;
					btnGo.classList.remove("ghe-g");
					btnGo.classList.add("ghe-b");
					btnGo.disabled = false;
					btnCancel.style.display = "none";
					btnReset.style.display = "inline-block";
				} else if (s.finished) {
					btnGo.style.display = "inline-block";
					btnGo.textContent = "↺ New Export";
					btnGo.classList.remove("ghe-b");
					btnGo.classList.add("ghe-g");
					btnGo.disabled = false;
					btnCancel.style.display = "none";
					btnReset.style.display = "none";
				} else {
					btnGo.style.display = "inline-block";
					btnGo.textContent = "Start";
					btnGo.classList.remove("ghe-b");
					btnGo.classList.add("ghe-g");
					btnGo.disabled = false;
					btnCancel.style.display = "none";
					btnReset.style.display = "none";
				}
				enableDl(s.hasData);
				refreshDbg();
			};

			btnCancel.onclick = () => {
				ReleasesModule.cancel();
				updateUI();
			};

			btnReset.onclick = () => {
				ReleasesModule.reset();
				ReleasesModule.setReleaseFilter(relFilterInp.value.trim());
				stTxt.textContent = "No data yet.";
				enableDl(false);
				updateUI();
			};

			btnGo.onclick = async () => {
				const s = ReleasesModule.state();
				// If finished a full run, reset for new export
				if (s.finished && !ReleasesModule.isPaused()) {
					ReleasesModule.reset();
				}
				ReleasesModule.setReleaseFilter(relFilterInp.value.trim());
				enableDl(false);
				updateUI();
				const ok = await ReleasesModule.fetchAll((msg) => {
					stTxt.textContent = msg;
					updateUI();
				});
				if (ok) {
					stTxt.textContent = `Done · ${ReleasesModule.state().count} releases`;
					enableDl(true);
				}
				updateUI();
			};

			const repo = Utils.repoFullName() || "";
			btnOpen.onclick = () => {
				if (ReleasesModule.hasData())
					Utils.openBlob(ReleasesModule.getHtml(), "text/html");
			};
			btnSaveH.onclick = () => {
				if (ReleasesModule.hasData())
					Utils.downloadBlob(
						ReleasesModule.getHtml(),
						`${Utils.sanitizeFilename(repo)}-releases.html`,
						"text/html",
					);
			};
			btnSaveM.onclick = () => {
				if (ReleasesModule.hasData())
					Utils.downloadBlob(
						ReleasesModule.getMd(),
						`${Utils.sanitizeFilename(repo)}-releases.md`,
						"text/markdown",
					);
			};

			const btnSaveZip = sec.querySelector("#ghe-rel-save-zip");
			dlBtns.push(btnSaveZip);
			btnSaveZip.onclick = async () => {
				if (!ReleasesModule.hasData()) return;
				if (typeof JSZip === "undefined") {
					showToast("JSZip not loaded", "error");
					return;
				}
				btnSaveZip.disabled = true;
				btnSaveZip.textContent = "⏳…";
				try {
					const zip = new JSZip();
					const repoSlug = Utils.sanitizeFilename(repo);
					const folder = zip.folder(repoSlug + "-releases");

					// Build per-release individual .md files
					const items = ReleasesModule._getItems
						? ReleasesModule._getItems()
						: [];
					if (items.length > 0) {
						for (const rel of items) {
							const tag = rel.tag_name || "untagged";
							const result = ReleasesModule.buildFrom([rel], repo);
							const safeTag = Utils.sanitizeFilename(tag, 60);
							folder.file(`${safeTag}.md`, result.md);
						}
					}

					// Also include combined files
					folder.file("_all-releases.md", ReleasesModule.getMd());
					folder.file("_all-releases.html", ReleasesModule.getHtml());

					const blob = await zip.generateAsync({
						type: "blob",
						compression: "DEFLATE",
						compressionOptions: { level: 6 },
					});
					const url = URL.createObjectURL(blob);
					const a = Object.assign(document.createElement("a"), {
						href: url,
						download: `${repoSlug}-releases.zip`,
					});
					document.body.appendChild(a);
					a.click();
					document.body.removeChild(a);
					URL.revokeObjectURL(url);
					showToast(`ZIP: ${items.length} release files`, "success");
				} catch (err) {
					showToast(`ZIP failed: ${err.message}`, "error");
				} finally {
					btnSaveZip.disabled = !ReleasesModule.hasData();
					btnSaveZip.textContent = "📦 ZIP";
				}
			};

			updateUI();
		}

		// ── Index ──
		function wireIndex(panel) {
			const sec = panel.querySelector("#ghe-iss-sec");
			const chkIss = sec.querySelector("#ghe-chk-iss");
			const chkPr = sec.querySelector("#ghe-chk-pr");
			const chkDsc = sec.querySelector("#ghe-chk-dsc");
			const chkDbg = sec.querySelector("#ghe-chk-dbg");
			const dbgWrap = sec.querySelector("#ghe-dbg-wrap");
			const dbgLog = sec.querySelector("#ghe-dbg-log");
			const goBtn = sec.querySelector("#ghe-iss-go");
			const cancelBtn = sec.querySelector("#ghe-iss-cancel");
			const resetBtn = sec.querySelector("#ghe-iss-reset");
			const rangeInput = sec.querySelector("#ghe-iss-from");
			const btnOpen = sec.querySelector("#ghe-iss-open");
			const btnDlH = sec.querySelector("#ghe-iss-dl-h");
			const btnDlM = sec.querySelector("#ghe-iss-dl-m");

			const updateGoEnabled = () => {
				const any = chkIss.checked || chkPr.checked || chkDsc.checked;
				goBtn.disabled = !any && !RepoIndexModule.state().running;
			};

			chkIss.onchange = function () {
				Prefs.set("issues", this.checked);
				updateGoEnabled();
			};
			chkPr.onchange = function () {
				Prefs.set("prs", this.checked);
				updateGoEnabled();
			};
			chkDsc.onchange = function () {
				Prefs.set("discussions", this.checked);
				updateGoEnabled();
			};
			chkDbg.onchange = function () {
				Prefs.set("debug", this.checked);
				RepoIndexModule.setDebug(this.checked);
				dbgWrap.style.display = this.checked ? "block" : "none";
			};
			RepoIndexModule.setDebug(chkDbg.checked);
			dbgWrap.style.display = chkDbg.checked ? "block" : "none";
			logClearHandlers.index = () => RepoIndexModule.clearDebugLog();
			rangeInput.addEventListener("input", () =>
				RepoIndexModule.setRange(rangeInput.value.trim()),
			);

			const refreshDbg = () => {
				if (chkDbg.checked) {
					dbgLog.value = RepoIndexModule.getDebugLog();
					dbgLog.scrollTop = dbgLog.scrollHeight;
				}
			};
			const updateDlBtns = () => {
				const h = RepoIndexModule.state().hasData;
				btnOpen.disabled = !h;
				btnDlH.disabled = !h;
				btnDlM.disabled = !h;
			};

			const updateUI = () => {
				const s = RepoIndexModule.state();
				const pr = sec.querySelector("#ghe-iss-pr-txt");
				if (s.count > 0) {
					const range =
						s.lowest === s.highest
							? `#${s.lowest}`
							: `#${s.lowest} – #${s.highest}`;
					pr.textContent = s.running
						? `Fetching… ${s.count} items (${range})`
						: s.paused
							? `Paused · ${s.count} items (${range})`
							: `Done · ${s.count} items (${range})`;
				} else {
					pr.textContent = s.running ? "Fetching…" : "No data yet";
				}
				if (s.running) {
					goBtn.style.display = "none";
					cancelBtn.style.display = "inline-block";
					resetBtn.style.display = "none";
				} else if (s.paused) {
					goBtn.style.display = "inline-block";
					goBtn.textContent = `▶ Resume (${s.count} items)`;
					goBtn.classList.remove("ghe-g");
					goBtn.classList.add("ghe-b");
					goBtn.disabled = false;
					cancelBtn.style.display = "none";
					resetBtn.style.display = "inline-block";
				} else if (s.finished) {
					goBtn.style.display = "inline-block";
					goBtn.textContent = "↺ New Export";
					goBtn.classList.remove("ghe-b");
					goBtn.classList.add("ghe-g");
					cancelBtn.style.display = "none";
					resetBtn.style.display = "none";
					updateGoEnabled();
				} else {
					goBtn.style.display = "inline-block";
					goBtn.textContent = "Start";
					goBtn.classList.remove("ghe-b");
					goBtn.classList.add("ghe-g");
					cancelBtn.style.display = "none";
					resetBtn.style.display = "none";
					updateGoEnabled();
				}
				refreshDbg();
				updateDlBtns();
			};

			cancelBtn.onclick = () => {
				RepoIndexModule.cancel();
				updateUI();
			};

			resetBtn.onclick = () => {
				RepoIndexModule.reset();
				RepoIndexModule.setRange(rangeInput.value.trim());
				sec.querySelector("#ghe-iss-pr-txt").textContent = "No data yet.";
				updateDlBtns();
				updateUI();
			};

			goBtn.onclick = () => {
				const s = RepoIndexModule.state();
				if (s.finished && !RepoIndexModule.isPaused()) RepoIndexModule.reset();
				RepoIndexModule.setRange(rangeInput.value.trim());
				RepoIndexModule.run(updateUI, {
					issues: chkIss.checked,
					prs: chkPr.checked,
					discussions: chkDsc.checked,
				});
			};

			const repo = Utils.repoFullName() || "";
			btnOpen.onclick = () => {
				if (RepoIndexModule.state().hasData)
					Utils.openBlob(RepoIndexModule.getHtml(), "text/html");
			};
			btnDlH.onclick = () => {
				if (RepoIndexModule.state().hasData)
					Utils.downloadBlob(
						RepoIndexModule.getHtml(),
						`${Utils.sanitizeFilename(repo)}-${RepoIndexModule.fileSlug()}.html`,
						"text/html",
					);
			};
			btnDlM.onclick = () => {
				if (RepoIndexModule.state().hasData)
					Utils.downloadBlob(
						RepoIndexModule.getMd(),
						`${Utils.sanitizeFilename(repo)}-${RepoIndexModule.fileSlug()}.md`,
						"text/markdown",
					);
			};
			updateGoEnabled();
			updateUI();

			// ── Batch Full-Content Export ──
			wireBatchExport(sec);
		}

		// ── Batch Export ──
		function wireBatchExport(sec) {
			const wrap = sec.querySelector("#ghe-batch-wrap");
			const countEl = sec.querySelector("#ghe-batch-count");
			const tokenWarn = sec.querySelector("#ghe-batch-token-warn");
			const stEl = sec.querySelector("#ghe-batch-st-el");
			const progressWrap = sec.querySelector("#ghe-batch-progress");
			const bar = sec.querySelector("#ghe-batch-bar");
			const detailEl = sec.querySelector("#ghe-batch-detail");
			const goBtn = sec.querySelector("#ghe-batch-go");
			const cancelBtn = sec.querySelector("#ghe-batch-cancel");
			const dlBtn = sec.querySelector("#ghe-batch-dl");

			// Picker elements
			const pickerWrap = sec.querySelector("#ghe-batch-picker");
			const listEl = sec.querySelector("#ghe-batch-list");
			const tbodyEl = sec.querySelector("#ghe-batch-tbody");
			const searchInp = sec.querySelector("#ghe-batch-search");
			const selCountEl = sec.querySelector("#ghe-batch-sel-count");
			const totalCountEl = sec.querySelector("#ghe-batch-total-count");

			const dlZipBtn = sec.querySelector("#ghe-batch-dl-zip");
			const resetBatchBtn = sec.querySelector("#ghe-batch-reset");

			// Inline rate limit mini display
			const rlMiniEl = sec.querySelector("#ghe-batch-rl-mini");

			// Debug log for batch
			const batchDbgChk = sec.querySelector("#ghe-batch-dbg-chk");
			const batchDbgWrap = sec.querySelector("#ghe-batch-dbg-wrap");
			const batchDbgLog = sec.querySelector("#ghe-batch-dbg-log");
			const batchLogger = createDebugLogger("[Batch]");

			batchDbgChk.addEventListener("change", () => {
				Prefs.set("batchDebug", batchDbgChk.checked);
				batchLogger.setEnabled(batchDbgChk.checked);
				batchDbgWrap.style.display = batchDbgChk.checked ? "block" : "none";
			});
			// Restore persisted state on create
			batchLogger.setEnabled(batchDbgChk.checked);
			batchDbgWrap.style.display = batchDbgChk.checked ? "block" : "none";
			logClearHandlers.batch = () => batchLogger.clear();
			function refreshBatchDbg() {
				if (batchDbgChk.checked) {
					batchDbgLog.value = batchLogger.getText();
					batchDbgLog.scrollTop = batchDbgLog.scrollHeight;
				}
			}

			// Inline rate limit updater
			function updateBatchRL() {
				if (!rlMiniEl) return;
				const s = API.rateLimitSummary;
				const dot = rlMiniEl.querySelector(".ghe-rl-dot");
				const txt = rlMiniEl.querySelector("span:last-child");
				if (s.remaining === null) {
					txt.textContent = "";
					dot.className = "ghe-rl-dot";
					return;
				}
				const c = API.rateLimit.core;
				const g = API.rateLimit.graphql;
				const parts = [];
				if (c.remaining !== null) parts.push(`REST: ${c.remaining}`);
				if (g.remaining !== null) parts.push(`GraphQL: ${g.remaining}`);
				txt.textContent = parts.join(" ");
				if (s.remaining <= 0) dot.className = "ghe-rl-dot out";
				else if (s.remaining <= 100) dot.className = "ghe-rl-dot low";
				else dot.className = "ghe-rl-dot ok";
			}

			let batchAbort = null;
			let batchRunning = false;
			let batchParts = [];
			let batchCompleted = 0;
			let batchFailed = 0;
			let batchItems = [];
			let batchRateLimited = false;
			let batchGeneration = -1;
			let batchSelected = new Set();
			/** Parallel array to batchParts: metadata for each exported item */
			let batchMeta = [];

			// ── Picker helpers ──

			function getItemType(item) {
				if (item.discussion) return "discussion";
				if (item.pull_request) return "pr";
				return "issue";
			}

			function getItemState(item) {
				const type = getItemType(item);
				if (type === "discussion") {
					if (item.isAnswered)
						return { icon: "✅", label: "answered", filter: "answered" };
					if (item.state === "closed" || item.state === "CLOSED")
						return { icon: "🔴", label: "closed", filter: "closed" };
					return { icon: "🟢", label: "open", filter: "open" };
				}
				if (item.state === "open")
					return { icon: "🟢", label: "open", filter: "open" };
				if (type === "pr" && item.pull_request?.merged_at)
					return { icon: "🟣", label: "merged", filter: "merged" };
				if (item.state_reason === "not_planned")
					return { icon: "⚫", label: "not planned", filter: "closed" };
				return { icon: "🔴", label: "closed", filter: "closed" };
			}

			function buildPicker(items) {
				if (!items || items.length === 0) {
					pickerWrap.style.display = "none";
					return;
				}
				pickerWrap.style.display = "block";
				totalCountEl.textContent = String(items.length);
				batchSelected = new Set(items.map((i) => i.number));
				renderPickerList(items);
				updateSelCount();
			}

			function renderPickerList(items, filterText) {
				const filter = (filterText || "").toLowerCase().trim();
				let html = "";
				const sorted = [...items].sort((a, b) => a.number - b.number);
				for (const item of sorted) {
					const type = getItemType(item);
					const st = getItemState(item);
					const title = item.title || "(no title)";
					const searchText =
						`#${item.number} ${title} ${st.label}`.toLowerCase();
					if (filter && !searchText.includes(filter)) continue;
					const checked = batchSelected.has(item.number) ? " checked" : "";
					const badgeLabel =
						type === "pr" ? "PR" : type === "discussion" ? "DSC" : "ISS";
					html +=
						`<tr data-num="${item.number}" data-type="${type}" data-state="${st.filter}">` +
						`<td><input type="checkbox"${checked} data-batch-num="${item.number}"></td>` +
						`<td><span class="badge ${type}">${badgeLabel}</span></td>` +
						`<td title="${st.label}">${st.icon}</td>` +
						`<td title="${Utils.escapeHtml(title)}">#${item.number} ${Utils.escapeHtml(title)}</td>` +
						`</tr>`;
				}
				if (!html)
					html =
						'<tr><td colspan="4" style="padding:8px;color:#8b949e;text-align:center;font-size:11px">No items match filter</td></tr>';
				safeSetInnerHTML(tbodyEl, html);
				// Wire checkbox changes
				tbodyEl.querySelectorAll("input[data-batch-num]").forEach((chk) => {
					chk.addEventListener("change", () => {
						const num = parseInt(chk.dataset.batchNum, 10);
						if (chk.checked) batchSelected.add(num);
						else batchSelected.delete(num);
						updateSelCount();
						updateStateButtonHighlights();
					});
				});
				// Click row to toggle checkbox
				tbodyEl.querySelectorAll("tr[data-num]").forEach((row) => {
					row.addEventListener("click", (e) => {
						if (e.target.tagName === "INPUT") return;
						const chk = row.querySelector("input[data-batch-num]");
						if (chk) {
							chk.checked = !chk.checked;
							chk.dispatchEvent(new Event("change"));
						}
					});
				});
			}

			/** Update state filter button highlights based on actual selection */
			function updateStateButtonHighlights() {
				const items = RepoIndexModule._getItems
					? RepoIndexModule._getItems()
					: [];
				if (items.length === 0 || batchSelected.size === 0) {
					for (const btn of Object.values(stFilterBtns)) {
						if (btn) btn.classList.remove("active");
					}
					return;
				}
				// Check if selection matches a state category
				const selectedStates = new Set();
				const selectedTypes = new Set();
				for (const item of items) {
					if (batchSelected.has(item.number)) {
						selectedStates.add(getItemState(item).filter);
						selectedTypes.add(getItemType(item));
					}
				}
				// Count items per state
				const stateCounts = { open: 0, closed: 0, merged: 0, answered: 0 };
				for (const item of items) {
					const sf = getItemState(item).filter;
					if (stateCounts[sf] !== undefined) stateCounts[sf]++;
				}
				const isAll = batchSelected.size === items.length;
				const isExactState = (state) => {
					if (selectedStates.size !== 1 || !selectedStates.has(state))
						return false;
					return batchSelected.size === stateCounts[state];
				};
				stFilterBtns.all?.classList.toggle("active", isAll);
				stFilterBtns.open?.classList.toggle("active", isExactState("open"));
				stFilterBtns.closed?.classList.toggle("active", isExactState("closed"));
				stFilterBtns.merged?.classList.toggle("active", isExactState("merged"));
				stFilterBtns.answered?.classList.toggle(
					"active",
					isExactState("answered"),
				);
			}

			function reRenderPicker() {
				const items = RepoIndexModule._getItems
					? RepoIndexModule._getItems()
					: [];
				renderPickerList(items, searchInp.value);
				updateStateButtonHighlights();
			}

			// State filter buttons — these SELECT items by state
			const stFilterBtns = {
				all: sec.querySelector("#ghe-batch-st-all"),
				open: sec.querySelector("#ghe-batch-st-open"),
				closed: sec.querySelector("#ghe-batch-st-closed"),
				merged: sec.querySelector("#ghe-batch-st-merged"),
				answered: sec.querySelector("#ghe-batch-st-answered"),
			};
			function selectByState(val) {
				const items = RepoIndexModule._getItems
					? RepoIndexModule._getItems()
					: [];
				if (val === "all") {
					batchSelected = new Set(items.map((i) => i.number));
				} else {
					batchSelected.clear();
					for (const item of items) {
						const st = getItemState(item);
						if (st.filter === val) batchSelected.add(item.number);
					}
				}
				tbodyEl.querySelectorAll("input[data-batch-num]").forEach((chk) => {
					chk.checked = batchSelected.has(parseInt(chk.dataset.batchNum, 10));
				});
				updateSelCount();
				updateStateButtonHighlights();
			}
			for (const [k, btn] of Object.entries(stFilterBtns)) {
				if (btn) btn.onclick = () => selectByState(k);
			}

			function updateSelCount() {
				selCountEl.textContent = String(batchSelected.size);
				if (!batchRunning && batchCompleted === 0) {
					goBtn.textContent =
						batchSelected.size > 0
							? `📥 Export ${batchSelected.size} Item${batchSelected.size === 1 ? "" : "s"}`
							: "📥 Export Selected";
					goBtn.disabled = batchSelected.size === 0;
				}
			}

			function selectByType(type) {
				const items = RepoIndexModule._getItems
					? RepoIndexModule._getItems()
					: [];
				batchSelected.clear();
				for (const item of items) {
					if (getItemType(item) === type) batchSelected.add(item.number);
				}
				listEl.querySelectorAll("input[data-batch-num]").forEach((chk) => {
					chk.checked = batchSelected.has(parseInt(chk.dataset.batchNum, 10));
				});
				updateSelCount();
			}

			// Wire picker controls
			sec.querySelector("#ghe-batch-sel-all").onclick = () => {
				const items = RepoIndexModule._getItems
					? RepoIndexModule._getItems()
					: [];
				batchSelected = new Set(items.map((i) => i.number));
				tbodyEl.querySelectorAll("input[data-batch-num]").forEach((chk) => {
					chk.checked = true;
				});
				updateSelCount();
				updateStateButtonHighlights();
			};
			sec.querySelector("#ghe-batch-sel-none").onclick = () => {
				batchSelected.clear();
				tbodyEl.querySelectorAll("input[data-batch-num]").forEach((chk) => {
					chk.checked = false;
				});
				updateSelCount();
				updateStateButtonHighlights();
			};
			sec.querySelector("#ghe-batch-sel-invert").onclick = () => {
				tbodyEl.querySelectorAll("input[data-batch-num]").forEach((chk) => {
					const num = parseInt(chk.dataset.batchNum, 10);
					if (batchSelected.has(num)) batchSelected.delete(num);
					else batchSelected.add(num);
					chk.checked = batchSelected.has(num);
				});
				updateSelCount();
				updateStateButtonHighlights();
			};
			sec.querySelector("#ghe-batch-sel-issues").onclick = () => {
				selectByType("issue");
				updateStateButtonHighlights();
			};
			sec.querySelector("#ghe-batch-sel-prs").onclick = () => {
				selectByType("pr");
				updateStateButtonHighlights();
			};
			sec.querySelector("#ghe-batch-sel-disc").onclick = () => {
				selectByType("discussion");
				updateStateButtonHighlights();
			};

			// ── Batch export settings (mirror of thread export settings) ──
			const batchSettingsEl = sec.querySelector("#ghe-batch-settings");
			if (batchSettingsEl) {
				const grouped = {};
				Object.entries(EXP_FLAGS).forEach(([k, m]) => {
					const g = m.g || "Other";
					if (!grouped[g]) grouped[g] = [];
					grouped[g].push({ key: k, ...m });
				});
				let settingsHtml = "";
				EXP_GROUP_ORDER.forEach((gn) => {
					const flags = grouped[gn];
					if (!flags) return;
					settingsHtml += `<div style="margin-bottom:6px"><div style="font-size:10px;font-weight:600;color:#8b949e;text-transform:uppercase;letter-spacing:.5px;margin-bottom:3px;border-bottom:1px solid #21262d;padding-bottom:2px">${gn}</div><div class="ghe-exp-grid">`;
					flags.forEach((f) => {
						const cls = f.indent ? ' class="indent"' : "";
						const checked = ThreadConfig.get(f.key) ? " checked" : "";
						const tip = f.t ? ` title="${Utils.escapeHtml(f.t)}"` : "";
						settingsHtml += `<label${cls}${tip}><input type="checkbox" data-bexp-key="${f.key}"${checked}> ${f.l}</label>`;
					});
					settingsHtml += "</div></div>";
				});
				safeSetInnerHTML(batchSettingsEl, settingsHtml);

				// Wire batch settings checkboxes
				batchSettingsEl
					.querySelectorAll("input[data-bexp-key]")
					.forEach((chk) => {
						chk.addEventListener("change", () => {
							ThreadConfig.set(chk.dataset.bexpKey, chk.checked);
							updateBatchExpDeps();
						});
					});

				function updateBatchExpDeps() {
					for (const [parentKey, childKeys] of Object.entries(EXP_DEPS)) {
						const parentChk = batchSettingsEl.querySelector(
							`input[data-bexp-key="${parentKey}"]`,
						);
						if (!parentChk) continue;
						const parentOn = parentChk.checked;
						for (const childKey of childKeys) {
							const childChk = batchSettingsEl.querySelector(
								`input[data-bexp-key="${childKey}"]`,
							);
							if (!childChk) continue;
							childChk.disabled = !parentOn;
							const label = childChk.closest("label");
							if (label) label.classList.toggle("disabled", !parentOn);
						}
					}
				}
				updateBatchExpDeps();
			}

			let searchTimeout = null;
			searchInp.addEventListener("input", () => {
				clearTimeout(searchTimeout);
				searchTimeout = setTimeout(() => reRenderPicker(), 200);
			});

			// ── Batch state management ──

			const statusEl = sec.querySelector("#ghe-batch-status");

			function updateBatchVisibility() {
				const s = RepoIndexModule.state();
				wrap.style.display = "block";
				if (batchRunning) {
					// During batch export, don't change status text
					return;
				}
				if (batchCompleted > 0 && batchCompleted >= batchItems.length) {
					// Batch finished — show done state
					statusEl.innerHTML = "";
					return;
				}
				if (batchCompleted > 0 && batchCompleted < batchItems.length) {
					// Batch paused mid-way
					statusEl.innerHTML = `<span style="color:#d29922">· paused at ${batchCompleted}/${batchItems.length}</span>`;
					return;
				}
				if (s.finished && s.count > 0) {
					countEl.textContent = `${s.count} items`;
					safeSetInnerHTML(
						statusEl,
						'<span style="color:#3fb950">· ready</span> — <span style="font-size:10px;color:#8b949e">select items below, then export</span>',
					);
					tokenWarn.style.display = Token.has() ? "none" : "block";
					const currentGen = RepoIndexModule._generation || 0;
					if (batchGeneration !== currentGen) {
						const items = RepoIndexModule._getItems
							? RepoIndexModule._getItems()
							: [];
						buildPicker(items);
						batchGeneration = currentGen;
					}
				} else if (s.running) {
					countEl.textContent = "";
					safeSetInnerHTML(
						statusEl,
						'<span style="color:#d29922">· indexing…</span>',
					);
				} else {
					countEl.textContent = "";
					safeSetInnerHTML(
						statusEl,
						'<span style="color:#8b949e">· <a href="#" id="ghe-batch-hint-link" style="color:#58a6ff;text-decoration:none;font-size:11px">run index above first ↑</a></span>',
					);
					// Wire click to scroll to and flash the index Start button
					const hintLink = statusEl.querySelector("#ghe-batch-hint-link");
					if (hintLink) {
						hintLink.onclick = (e) => {
							e.preventDefault();
							const goBtn = sec.querySelector("#ghe-iss-go");
							if (goBtn) {
								goBtn.scrollIntoView({ behavior: "smooth", block: "center" });
								goBtn.style.transition = "box-shadow .3s";
								goBtn.style.boxShadow = "0 0 0 3px rgba(88,166,255,.5)";
								setTimeout(() => {
									goBtn.style.boxShadow = "";
								}, 2000);
							}
						};
					}
				}
			}

			function updateBatchButtons() {
				if (batchRunning) {
					goBtn.style.display = "none";
					cancelBtn.style.display = "inline-block";
					resetBatchBtn.style.display = "none";
					dlBtn.disabled = true;
					dlZipBtn.disabled = true;
				} else {
					cancelBtn.style.display = "none";
					goBtn.style.display = "inline-block";
					if (
						batchRateLimited ||
						(batchCompleted > 0 && batchCompleted < batchItems.length)
					) {
						goBtn.textContent = `▶ Resume (${batchCompleted}/${batchItems.length})`;
						goBtn.classList.remove("ghe-g");
						goBtn.classList.add("ghe-b");
						goBtn.disabled = false;
						resetBatchBtn.style.display = "inline-block";
					} else if (
						batchCompleted > 0 &&
						batchCompleted >= batchItems.length
					) {
						goBtn.textContent = "↺ New Export";
						goBtn.classList.remove("ghe-b");
						goBtn.classList.add("ghe-g");
						goBtn.disabled = false;
						resetBatchBtn.style.display = "none";
					} else {
						goBtn.classList.remove("ghe-b");
						goBtn.classList.add("ghe-g");
						resetBatchBtn.style.display = "none";
						updateSelCount();
					}
					dlBtn.disabled = batchParts.length === 0;
					dlZipBtn.disabled = batchMeta.length === 0;
				}
				refreshBatchDbg();
			}

			function buildBatchResult() {
				if (batchParts.length === 0) return null;
				const pageInfo = Utils.parsePage();
				const name = pageInfo ? pageInfo.fullName : "unknown";
				const header =
					`# Full Export — ${name}\n\n` +
					`> Exported ${batchParts.length} items on ${new Date().toLocaleString()}\n\n---\n\n`;
				return header + batchParts.join("\n\n---\n\n---\n\n");
			}

			function resetBatch() {
				batchParts = [];
				batchMeta = [];
				batchCompleted = 0;
				batchFailed = 0;
				batchItems = [];
				batchRateLimited = false;
				bar.style.width = "0%";
				progressWrap.style.display = "none";
				stEl.textContent = "Ready.";
				stEl.className = "ghe-st";
				detailEl.textContent = "";
				batchLogger.clear();
				refreshBatchDbg();
			}

			// Observe index state changes
			const indexSt = sec.querySelector("#ghe-iss-pr-txt");
			if (indexSt) {
				const obs = new MutationObserver(updateBatchVisibility);
				obs.observe(indexSt, {
					childList: true,
					characterData: true,
					subtree: true,
				});
			}
			setInterval(updateBatchVisibility, 2000);
			updateBatchVisibility();
			updateBatchButtons();

			// ── Main batch export handler ──

			goBtn.onclick = async () => {
				if (batchRunning) return;

				const pageInfo = Utils.parsePage();
				if (!pageInfo) return;

				// If completed a full run, reset for new export
				if (batchCompleted > 0 && batchCompleted >= batchItems.length) {
					resetBatch();
					const items = RepoIndexModule._getItems
						? RepoIndexModule._getItems()
						: [];
					if (items.length > 0) buildPicker(items);
					updateBatchButtons();
					return;
				}

				// Fresh start: build from selection
				if (batchCompleted === 0 || batchItems.length === 0) {
					const allItems = RepoIndexModule._getItems
						? RepoIndexModule._getItems()
						: [];

					// Auto-index if no items yet
					if (allItems.length === 0) {
						stEl.textContent = "No index yet — fetching automatically…";
						const chkIss = sec.querySelector("#ghe-chk-iss");
						const chkPr = sec.querySelector("#ghe-chk-pr");
						const chkDsc = sec.querySelector("#ghe-chk-dsc");
						const opts = {
							issues: chkIss ? chkIss.checked : true,
							prs: chkPr ? chkPr.checked : true,
							discussions: chkDsc ? chkDsc.checked : true,
						};
						await new Promise((resolve) => {
							RepoIndexModule.run(() => {
								const s = RepoIndexModule.state();
								stEl.textContent = s.running
									? `Indexing… ${s.count} items found`
									: `Indexed ${s.count} items`;
								if (!s.running) resolve();
							}, opts);
						});
						const newItems = RepoIndexModule._getItems
							? RepoIndexModule._getItems()
							: [];
						if (newItems.length === 0) {
							stEl.textContent = "No items found in repository.";
							return;
						}
						buildPicker(newItems);
						batchGeneration = RepoIndexModule._generation || 0;
						updateBatchButtons();
						stEl.textContent = `Found ${newItems.length} items. Select items above, then click Export.`;
						return;
					}

					const selectedItems = allItems
						.filter((i) => batchSelected.has(i.number))
						.sort((a, b) => a.number - b.number);
					if (selectedItems.length === 0) {
						stEl.textContent = "No items selected. Check some items above.";
						return;
					}
					batchItems = selectedItems;
					batchGeneration = RepoIndexModule._generation || 0;
				}

				batchRunning = true;
				batchRateLimited = false;
				batchAbort = new AbortController();

				progressWrap.style.display = "block";
				updateBatchButtons();

				// Start inline RL polling
				const rlPollId = setInterval(updateBatchRL, 1000);
				updateBatchRL();

				const total = batchItems.length;
				const startFrom = batchCompleted;

				batchLogger.log(
					`=== Batch export start: ${total} items, resuming from ${startFrom} ===`,
				);

				stEl.textContent =
					startFrom > 0
						? `Resuming from ${startFrom}/${total}…`
						: `Exporting 0/${total}…`;

				for (let i = startFrom; i < total; i++) {
					if (batchAbort.signal.aborted) break;

					const item = batchItems[i];
					const num = item.number;
					const isPR = !!item.pull_request;
					const isDisc = !!item.discussion;
					const type = isDisc ? "discussion" : isPR ? "pr" : "issue";
					const label = `#${num} (${type})`;

					detailEl.textContent = `Exporting ${label}…`;
					batchLogger.log(`→ Exporting ${label}`);
					const t0 = performance.now();

					try {
						const result = await ThreadExportModule.exportThread(
							pageInfo.owner,
							pageInfo.repo,
							num,
							type,
							batchAbort.signal,
						);
						const elapsed = ((performance.now() - t0) / 1000).toFixed(1);
						batchLogger.log(`  ✓ ${label} — ${result.summary} (${elapsed}s)`);
						batchParts.push(
							`<!-- ═══ ${label}: ${(result.title || "").substring(0, 80)} ═══ -->\n\n${result.markdown}`,
						);
						batchMeta.push({
							number: num,
							type,
							title: result.title || "",
							markdown: result.markdown,
							failed: false,
						});
						batchCompleted++;
					} catch (err) {
						if (err.message === "Cancelled") {
							batchLogger.log(`  ⚠ Cancelled at ${label}`);
							break;
						}
						if (err.message.includes("Rate limit")) {
							batchRateLimited = true;
							batchLogger.log(`  ⚠ Rate limited at ${label}`);
							stEl.textContent = `⚠️ Rate limited at ${label}. ${batchCompleted}/${total} done. Click Resume when limit resets.`;
							stEl.className = "ghe-st ghe-warn";
							break;
						}
						const elapsed = ((performance.now() - t0) / 1000).toFixed(1);
						batchLogger.log(`  ✗ ${label} — ${err.message} (${elapsed}s)`);
						warn(`Batch export failed for ${label}:`, err.message);
						batchParts.push(
							`<!-- ═══ ${label}: EXPORT FAILED — ${err.message} ═══ -->\n`,
						);
						batchMeta.push({
							number: num,
							type,
							title: "(export failed)",
							markdown: `# Export Failed\n\n${err.message}\n`,
							failed: true,
						});
						batchFailed++;
						batchCompleted++;
					}

					const pct = Math.round((batchCompleted / total) * 100);
					bar.style.width = `${pct}%`;
					stEl.textContent = `Exporting ${batchCompleted}/${total}…${batchFailed ? ` (${batchFailed} failed)` : ""}`;
					updateBatchRL();
					refreshBatchDbg();

					if (!batchAbort.signal.aborted && batchCompleted < total) {
						await Utils.delay(200);
					}
				}

				clearInterval(rlPollId);
				batchRunning = false;
				batchAbort = null;

				if (!batchRateLimited) {
					if (batchCompleted >= total) {
						const msg = `✅ Done — ${batchCompleted} items exported${batchFailed ? ` (${batchFailed} failed)` : ""}.`;
						stEl.textContent = msg;
						stEl.className = "ghe-st";
						detailEl.textContent = "";
						batchLogger.log(`=== ${msg} ===`);
					} else {
						stEl.textContent = `Paused. ${batchCompleted}/${total} exported. Click Resume to continue.`;
						detailEl.textContent = "";
						batchLogger.log(`=== Paused at ${batchCompleted}/${total} ===`);
					}
				}

				refreshBatchDbg();
				updateBatchButtons();
			};

			cancelBtn.onclick = () => {
				if (batchAbort) batchAbort.abort();
			};

			resetBatchBtn.onclick = () => {
				if (batchAbort) batchAbort.abort();
				resetBatch();
				const items = RepoIndexModule._getItems
					? RepoIndexModule._getItems()
					: [];
				if (items.length > 0) buildPicker(items);
				updateBatchButtons();
				batchLogger.log("Batch reset by user");
				refreshBatchDbg();
			};

			dlBtn.onclick = () => {
				log("Single MD button clicked, batchParts.length =", batchParts.length);
				batchLogger.log(`Single MD download: ${batchParts.length} parts`);
				const md = buildBatchResult();
				if (!md) {
					warn("Single MD: buildBatchResult returned null");
					return;
				}
				const pageInfo = Utils.parsePage();
				const slug = pageInfo
					? Utils.sanitizeFilename(pageInfo.fullName)
					: "export";
				const suffix = batchCompleted < batchItems.length ? "-partial" : "";
				Utils.downloadBlob(
					md,
					`${slug}-full-export${suffix}.md`,
					"text/markdown",
				);
				batchLogger.log(
					`Single MD downloaded: ${Utils.formatBytes(md.length)} chars`,
				);
				refreshBatchDbg();
			};

			dlZipBtn.onclick = async () => {
				log("ZIP button clicked, batchMeta.length =", batchMeta.length);
				batchLogger.log(`ZIP build requested: ${batchMeta.length} items`);
				if (batchMeta.length === 0) {
					warn("ZIP: no items in batchMeta");
					showToast("No exported items to ZIP", "error");
					return;
				}
				if (typeof JSZip === "undefined") {
					error("JSZip not loaded");
					showToast("JSZip not loaded — cannot create ZIP", "error");
					return;
				}
				dlZipBtn.disabled = true;
				dlZipBtn.textContent = "⏳…";
				try {
					log("Creating JSZip instance…");
					batchLogger.log("Creating ZIP…");
					const zip = new JSZip();
					const pi = Utils.parsePage();
					const repoSlug = pi ? Utils.sanitizeFilename(pi.fullName) : "export";
					const folder = zip.folder(repoSlug);
					log(`ZIP folder: ${repoSlug}, adding ${batchMeta.length} files…`);

					const indexLines = [
						`# Export Index — ${pi ? pi.fullName : "unknown"}`,
						"",
					];
					indexLines.push(
						`> ${batchMeta.length} items exported on ${new Date().toLocaleString()}`,
						"",
					);
					indexLines.push("| # | Type | Title | File |");
					indexLines.push("|--:|:-----|:------|:-----|");

					for (const meta of batchMeta) {
						const typeTag =
							meta.type === "pr"
								? "PR"
								: meta.type === "discussion"
									? "disc"
									: "issue";
						const safeTitle = Utils.sanitizeFilename(meta.title, 60);
						const filename = `${String(meta.number).padStart(5, "0")}-${typeTag}-${safeTitle}.md`;
						folder.file(filename, meta.markdown);
						const status = meta.failed ? "❌" : "✅";
						indexLines.push(
							`| ${status} ${meta.number} | ${typeTag} | ${meta.title.substring(0, 60)} | [${filename}](./${filename}) |`,
						);
					}
					indexLines.push("");
					folder.file("_index.md", indexLines.join("\n"));

					const singleMd = buildBatchResult();
					if (singleMd) {
						folder.file("_all-combined.md", singleMd);
					}

					log("Generating ZIP blob…");
					batchLogger.log("Compressing ZIP…");
					let blob;
					try {
						const uint8 = await zip.generateAsync({
							type: "uint8array",
							compression: "DEFLATE",
							compressionOptions: { level: 6 },
						});
						log(`ZIP uint8array ready: ${uint8.length} bytes`);
						blob = new Blob([uint8], { type: "application/zip" });
					} catch (zipErr) {
						error("zip.generateAsync failed:", zipErr);
						batchLogger.log(`zip.generateAsync error: ${zipErr.message}`);
						throw zipErr;
					}
					log(`ZIP blob created: ${blob.size} bytes`);
					batchLogger.log(`ZIP ready: ${Utils.formatBytes(blob.size)}`);
					const suffix = batchCompleted < batchItems.length ? "-partial" : "";
					const url = URL.createObjectURL(blob);
					const a = Object.assign(document.createElement("a"), {
						href: url,
						download: `${repoSlug}-export${suffix}.zip`,
					});
					document.body.appendChild(a);
					a.click();
					document.body.removeChild(a);
					URL.revokeObjectURL(url);
					showToast(`ZIP downloaded: ${batchMeta.length} files`, "success");
					batchLogger.log(`ZIP downloaded: ${batchMeta.length} files`);
				} catch (err) {
					error("ZIP creation failed:", err);
					batchLogger.log(`ZIP FAILED: ${err.message}`);
					showToast(`ZIP failed: ${err.message}`, "error");
				}
				dlZipBtn.disabled = batchMeta.length === 0;
				dlZipBtn.textContent = "📦 ZIP";
				refreshBatchDbg();
			};
		}

		// ── Thread Export ──
		function wireThreadExport(panel, pageInfo) {
			const sec = panel.querySelector("#ghe-exp-sec");
			if (!sec) return;
			const goBtn = sec.querySelector("#ghe-exp-go");
			const stEl = sec.querySelector("#ghe-exp-st");
			const resetBtn = sec.querySelector("#ghe-exp-reset");

			// ── Dependent state logic for export settings ──

			/** Update disabled/enabled state of all child checkboxes based on parent state */
			function updateExpDeps() {
				for (const [parentKey, childKeys] of Object.entries(EXP_DEPS)) {
					const parentChk = sec.querySelector(
						`input[data-exp-key="${parentKey}"]`,
					);
					if (!parentChk) continue;
					const parentOn = parentChk.checked;
					for (const childKey of childKeys) {
						const childChk = sec.querySelector(
							`input[data-exp-key="${childKey}"]`,
						);
						if (!childChk) continue;
						childChk.disabled = !parentOn;
						const label = childChk.closest("label");
						if (label) {
							label.classList.toggle("disabled", !parentOn);
						}
					}
				}
			}

			// Wire all export settings checkboxes
			sec.querySelectorAll("input[data-exp-key]").forEach((chk) => {
				chk.addEventListener("change", () => {
					ThreadConfig.set(chk.dataset.expKey, chk.checked);
					// If this is a parent, update its children
					if (EXP_DEPS[chk.dataset.expKey]) {
						updateExpDeps();
					}
				});
			});

			// Apply initial dependent state
			updateExpDeps();

			// Reset button
			if (resetBtn) {
				resetBtn.onclick = () => {
					ThreadConfig.reset();
					sec.querySelectorAll("input[data-exp-key]").forEach((chk) => {
						chk.checked = ThreadConfig.get(chk.dataset.expKey);
					});
					updateExpDeps();
					showToast("Settings reset to defaults", "info");
				};
			}

			const previewBtn = sec.querySelector("#ghe-exp-preview");
			let lastExportResult = null; // cache for preview reuse

			/** Shared export runner — returns result or null on error */
			async function runExport(actionLabel) {
				if (threadExporting && threadExportAbort) {
					threadExportAbort.abort();
					showToast("Export cancelled", "info");
					return null;
				}

				threadExporting = true;
				threadExportAbort = new AbortController();
				goBtn.textContent = "⏳ Exporting… (click to cancel)";
				goBtn.classList.remove("ghe-g");
				goBtn.classList.add("ghe-r");
				previewBtn.disabled = true;
				stEl.textContent = "Fetching data…";

				try {
					const result = await ThreadExportModule.exportThread(
						pageInfo.owner,
						pageInfo.repo,
						pageInfo.number,
						pageInfo.type,
						threadExportAbort.signal,
						(step) => {
							stEl.textContent = step;
						},
					);
					lastExportResult = result;
					stEl.textContent = `✅ ${actionLabel}: ${result.summary}`;
					return result;
				} catch (err) {
					if (err.message === "Cancelled") {
						stEl.textContent = "Cancelled.";
					} else {
						error("Export failed:", err);
						stEl.textContent = `❌ ${err.message}`;
						showToast(`Export failed: ${err.message}`, "error");
					}
					return null;
				} finally {
					threadExporting = false;
					threadExportAbort = null;
					goBtn.textContent = "📥 Export to Markdown";
					goBtn.classList.remove("ghe-r");
					goBtn.classList.add("ghe-g");
					previewBtn.disabled = false;
				}
			}

			// Export button — download
			goBtn.onclick = async () => {
				const result = await runExport("Exported");
				if (result) {
					const filename = `${Utils.sanitizeFilename(result.title)}_${pageInfo.owner}_${pageInfo.repo}_${pageInfo.number}.md`;
					Utils.downloadBlob(result.markdown, filename, "text/markdown");
					showToast(`Exported ${result.summary}`, "success");
				}
			};

			// Preview button — open in new tab as rendered HTML
			previewBtn.onclick = async () => {
				const result = lastExportResult || (await runExport("Preview ready"));
				if (!result) return;

				const escapedMd = Utils.escapeHtml(result.markdown);
				const filename = JSON.stringify(
					Utils.sanitizeFilename(result.title) +
						"_" +
						pageInfo.owner +
						"_" +
						pageInfo.repo +
						"_" +
						pageInfo.number +
						".md",
				);

				// Render Markdown to HTML using marked.js (already @required)
				let renderedHtml = "";
				try {
					const renderer =
						typeof marked.parse === "function" ? marked : marked.marked;
					renderedHtml = renderer.parse(result.markdown, {
						breaks: true,
						gfm: true,
					});
					// biome-ignore lint/correctness/noUnusedVariables: catch variable required by syntax
				} catch (e) {}

				// Extract TOC from headings (H1–H3)
				const tocEntries = [];
				const headingRegex = /^(#{1,3})\s+(.+)$/gm;
				let hMatch;
				// biome-ignore lint/suspicious/noAssignInExpressions: intentional assignment loop
				while ((hMatch = headingRegex.exec(result.markdown)) !== null) {
					const level = hMatch[1].length;
					const text = hMatch[2].replace(/[`*_~$$]/g, "").trim();
					const slug = text
						.toLowerCase()
						.replace(/[^\w\s-]/g, "")
						.replace(/\s+/g, "-")
						.replace(/-+/g, "-");
					tocEntries.push({ level, text, slug });
				}
				let tocHtml = "";
				if (tocEntries.length > 2) {
					tocHtml =
						'<nav class="toc"><details open><summary>📑 Table of Contents</summary><ul>' +
						tocEntries
							.map((e) => {
								const indent = (e.level - 1) * 16;
								return `<li style="padding-left:${indent}px"><a href="#${e.slug}">${Utils.escapeHtml(e.text)}</a></li>`;
							})
							.join("") +
						"</ul></details></nav>";
				}

				const previewHtml = `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">
<title>Preview: ${Utils.escapeHtml(result.title)}</title>
<style>
*{box-sizing:border-box}
body{font-family:system-ui,-apple-system,'Segoe UI',sans-serif;margin:0;background:#0d1117;color:#c9d1d9;line-height:1.6}
.toolbar{position:sticky;top:0;background:#161b22;border-bottom:1px solid #30363d;padding:8px 20px;display:flex;align-items:center;gap:12px;z-index:10;flex-wrap:wrap}
.toolbar button{padding:6px 14px;border:none;border-radius:6px;cursor:pointer;font-size:13px;font-weight:600;color:#fff}
.btn-dl{background:#238636}.btn-dl:hover{background:#2ea043}
.btn-copy{background:#1f6feb}.btn-copy:hover{background:#388bfd}
.btn-raw{background:#6e7781}.btn-raw:hover{background:#848d97}
.info{font-size:12px;color:#8b949e;margin-left:auto}
.toc{margin:20px 20px 0;padding:12px 16px;background:#161b22;border:1px solid #30363d;border-radius:8px}
.toc summary{cursor:pointer;font-weight:600;font-size:14px;color:#f0f6fc}
.toc ul{list-style:none;padding:0;margin:8px 0 0}
.toc li{padding:3px 0}
.toc a{color:#58a6ff;text-decoration:none;font-size:13px}
.toc a:hover{text-decoration:underline}
.content{max-width:900px;margin:0 auto;padding:20px}
.content h1,.content h2,.content h3,.content h4{color:#f0f6fc;border-bottom:1px solid #21262d;padding-bottom:6px;margin-top:24px}
.content h1{font-size:1.8em;border-bottom:2px solid #30363d}
.content h2{font-size:1.4em}
.content h3{font-size:1.15em}
.content a{color:#58a6ff}
.content blockquote{border-left:3px solid #30363d;padding:4px 16px;margin:12px 0;color:#8b949e;background:#161b22;border-radius:0 6px 6px 0}
.content code{background:#21262d;padding:2px 6px;border-radius:4px;font-size:0.9em;font-family:'SF Mono',Consolas,'Liberation Mono',monospace}
.content pre{background:#161b22;border:1px solid #30363d;border-radius:8px;padding:14px;overflow-x:auto;line-height:1.45}
.content pre code{background:none;padding:0;font-size:13px}
.content table{border-collapse:collapse;width:100%;margin:12px 0}
.content th,.content td{border:1px solid #30363d;padding:8px 12px;text-align:left}
.content th{background:#161b22;font-weight:600}
.content tr:hover{background:#161b2288}
.content img{max-width:100%;border-radius:6px}
.content hr{border:none;border-top:1px solid #30363d;margin:24px 0}
.content ul,.content ol{padding-left:24px}
.content li{margin:4px 0}
.content details{background:#161b22;border:1px solid #30363d;border-radius:6px;padding:8px 12px;margin:8px 0}
.content summary{cursor:pointer;font-weight:600}
.raw-view{display:none;margin:20px;padding:20px;background:#161b22;border:1px solid #30363d;border-radius:8px;font-family:'SF Mono',Consolas,monospace;font-size:13px;line-height:1.6;white-space:pre-wrap;word-wrap:break-word;overflow-x:auto;color:#c9d1d9}
</style></head><body>
<div class="toolbar">
  <button class="btn-dl" onclick="downloadMd()">💾 Download .md</button>
  <button class="btn-copy" onclick="copyMd()">📋 Copy Markdown</button>
  <button class="btn-raw" id="toggle-raw" onclick="toggleRaw()">{ } Raw</button>
  <span class="info">${Utils.escapeHtml(result.summary)} · ${result.markdown.length.toLocaleString()} chars</span>
</div>
${tocHtml}
<div class="content" id="rendered-view">${renderedHtml}</div>
<pre class="raw-view" id="raw-view">${escapedMd}</pre>
<script>
var rawMd=${JSON.stringify(result.markdown)};
var showRaw=false;
function toggleRaw(){showRaw=!showRaw;document.getElementById('rendered-view').style.display=showRaw?'none':'block';document.getElementById('raw-view').style.display=showRaw?'block':'none';document.getElementById('toggle-raw').textContent=showRaw?'📄 Rendered':'{ } Raw'}
function downloadMd(){var b=new Blob([rawMd],{type:'text/markdown;charset=utf-8'});var u=URL.createObjectURL(b);var a=document.createElement('a');a.href=u;a.download=${filename};document.body.appendChild(a);a.click();document.body.removeChild(a);URL.revokeObjectURL(u)}
function copyMd(){navigator.clipboard.writeText(rawMd).then(function(){var b=document.querySelector('.btn-copy');b.textContent='✅ Copied!';setTimeout(function(){b.textContent='📋 Copy Markdown'},2000)}).catch(function(){prompt('Copy failed. Markdown:',rawMd.substring(0,500))})}
</script></body></html>`;

				Utils.openBlob(previewHtml, "text/html");
				showToast("Preview opened in new tab", "info");
			};
		}

		/**
		 * Dynamically adjust tooltip position so it doesn't clip outside
		 * the panel edges — both horizontally and vertically.
		 * Vertical: default "above"; flips to "below" if not enough space.
		 * Horizontal: shifts left/right to stay within panel bounds.
		 */
		function wireTooltipPositioning(panel) {
			panel.querySelectorAll(".ghe-info").forEach((info) => {
				info.addEventListener("mouseenter", () => {
					const tip = info.querySelector(".ghe-tip");
					if (!tip) return;

					// ── Reset to default centered-above position ──
					tip.classList.remove("above", "below");
					tip.style.left = "50%";
					tip.style.transform = "translateX(-50%)";
					tip.style.right = "auto";
					tip.style.removeProperty("--arrow-left");

					// Start as "above"
					tip.classList.add("above");

					// ── Vertical: flip to below if clipped at top ──
					const panelRect = panel.getBoundingClientRect();
					const infoRect = info.getBoundingClientRect();
					// Measure how much space is above the ? relative to the panel's visible top
					const spaceAbove = infoRect.top - panelRect.top;
					// Estimate tip height (force layout)
					const tipRect = tip.getBoundingClientRect();
					const tipHeight = tipRect.height + 8; // 8px gap

					if (spaceAbove < tipHeight) {
						tip.classList.remove("above");
						tip.classList.add("below");
					}

					// ── Horizontal: shift if clipped left or right ──
					const tipRect2 = tip.getBoundingClientRect();

					if (tipRect2.right > panelRect.right - 8) {
						tip.style.left = "auto";
						tip.style.right = "0";
						tip.style.transform = "none";
						const arrowOffset =
							infoRect.left - tip.getBoundingClientRect().left + 7;
						tip.style.setProperty("--arrow-left", arrowOffset + "px");
					} else if (tipRect2.left < panelRect.left + 8) {
						tip.style.left = "0";
						tip.style.transform = "none";
						const arrowOffset =
							infoRect.left - tip.getBoundingClientRect().left + 7;
						tip.style.setProperty("--arrow-left", arrowOffset + "px");
					}
				});
			});
		}

		/** Generate and open the help/documentation window */
		function openHelpWindow() {
			const helpMd = `# ${SCRIPT_NAME} — Help

## Overview

All-in-one GitHub exporter. Download releases, generate issue/PR/discussion indexes, and export full threads as Markdown.

---

## Quick Start

### 1. Set up a Token *(recommended)*

Click **🔑 GitHub Token** to expand the token section. A personal access token increases your rate limit from 60 to 5,000 requests/hour and enables Discussion exports.

1. Go to [GitHub Settings → Tokens](https://github.com/settings/tokens)
2. Create a token with the \`public_repo\` scope
3. Paste it in the token field and click Save

### 2. Export a Single Issue / PR / Discussion

Navigate to any issue, PR, or discussion page. The panel shows **📥 Export This Page**:

- **Export to Markdown** — downloads a \`.md\` file with full content
- **Preview** — opens a rendered HTML preview in a new tab
- **⚙️ Export Settings** — configure what to include (reviews, timeline, etc.)

### 3. Export Release Notes

On any repository page, expand **📦 Release Notes & Changelogs**:

1. *(Optional)* Enter a filter: tag glob (\`v1.*\`), tag range (\`v1.0..v2.0\`), or date range (\`2024-01..2025-06\`)
2. Click **Start**
3. Download as HTML, Markdown, or ZIP (one file per release)

### 4. Generate an Index

Expand **📋 Issues · PRs · Discussions — Index**:

1. Check which types to include (Issues, PRs, Discussions)
2. *(Optional)* Enter a number range (e.g. \`1-500\`)
3. Click **Start**
4. Download as HTML or Markdown

> **Note:** The index contains titles, status, and links only — not full content.

### 5. Batch Full Export

To export full content for multiple items:

1. **Run the index first** (step 4 above) — this discovers all items
2. The **📥 Batch Full Export** section shows "ready" with a picker
3. **Select items** using the checkboxes, or use the quick-select buttons (All, None, Issues, PRs, Open, Closed, etc.)
4. Click **📥 Export Selected**
5. Download as a single combined Markdown file or as a ZIP with one file per item

> **Tip:** You can **pause** a batch export at any time and **resume** later. If you hit a rate limit, it pauses automatically.

---

## Pause / Resume / Reset

All export sections support:

- **⏸ Pause** — stops the current export, keeping all progress
- **▶ Resume** — continues from where it stopped
- **✕ Reset** — discards progress and starts fresh

---

## Export Settings

The **⚙️ Export Settings** panel lets you control exactly what's included:

| Category | Examples |
|:---------|:--------|
| **Content** | Header, labels, assignees, reactions, timestamps |
| **Issue** | Type, closed-by, dependencies, sub-issues, pinned comment |
| **Pull Request** | Reviews, review comments, commits, changed files, checks, merge requirements |
| **Discussion** | Category, answer, poll, upvotes, replies, timeline |
| **References** | Cross-references, related PRs/issues, commits |
| **Timeline** | Verbose audit log of all events |
| **Formatting** | Collapsible long comments |

Settings are shared between single-page export and batch export, and persist across sessions.

---

## Rate Limits

The top of the panel shows your current rate limit:

- **REST** — used for issues, PRs, releases (5,000/hr with token, 60/hr without)
- **GraphQL** — used for discussions, review threads, polls (5,000/hr, requires token)

When a limit is hit, exports pause automatically. Wait for the reset time shown, then resume.

---

## Debug Logs

Each section has a **Log** checkbox that shows API request details. Useful for troubleshooting. The log area has:

- 📋 Copy to clipboard
- 🗑 Clear log

---

## Keyboard Shortcuts

*None currently — all interaction is via the panel UI.*

---

## Links

- [Report Issues](https://greasyfork.org/en/users/1462137-piknockyou)
- [Support on Ko-Fi](https://ko-fi.com/piknockyou)

---

*${SCRIPT_NAME} v${typeof GM_info !== "undefined" ? GM_info.script.version : "?"} · License: AGPL-3.0*
`;

			let renderedHtml = "";
			try {
				const renderer =
					typeof marked.parse === "function" ? marked : marked.marked;
				renderedHtml = renderer.parse(helpMd, { breaks: true, gfm: true });
				// biome-ignore lint/correctness/noUnusedVariables: catch variable required by syntax
			} catch (e) {}

			const html = `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">
<title>${SCRIPT_NAME} — Help</title>
<style>
*{box-sizing:border-box}
body{font-family:system-ui,-apple-system,'Segoe UI',sans-serif;margin:0;padding:0;background:#0d1117;color:#c9d1d9;line-height:1.6}
.wrap{max-width:760px;margin:0 auto;padding:32px 24px}
h1{font-size:1.8em;color:#f0f6fc;border-bottom:2px solid #30363d;padding-bottom:8px;margin-bottom:16px}
h2{font-size:1.35em;color:#f0f6fc;border-bottom:1px solid #21262d;padding-bottom:6px;margin-top:32px}
h3{font-size:1.1em;color:#f0f6fc;margin-top:20px}
a{color:#58a6ff;text-decoration:none}
a:hover{text-decoration:underline}
code{background:#21262d;padding:2px 6px;border-radius:4px;font-size:0.9em;font-family:'SF Mono',Consolas,monospace}
pre{background:#161b22;border:1px solid #30363d;border-radius:8px;padding:14px;overflow-x:auto}
pre code{background:none;padding:0}
blockquote{border-left:3px solid #30363d;padding:4px 16px;margin:12px 0;color:#8b949e;background:#161b22;border-radius:0 6px 6px 0}
table{border-collapse:collapse;width:100%;margin:12px 0}
th,td{border:1px solid #30363d;padding:8px 12px;text-align:left}
th{background:#161b22;font-weight:600}
hr{border:none;border-top:1px solid #21262d;margin:24px 0}
strong{color:#f0f6fc}
em{color:#8b949e}
ul,ol{padding-left:24px}
li{margin:4px 0}
</style></head><body>
<div class="wrap">${renderedHtml}</div>
</body></html>`;

			Utils.openBlob(html, "text/html");
		}

		function remove() {
			const el = document.getElementById(ID);
			if (el) el.remove();
		}

		return { create, remove };
	})();

	// ════════════════════════════════════════════════════════════════════════════
	// NAVIGATION & INITIALIZATION
	// ════════════════════════════════════════════════════════════════════════════

	let lastUrl = "";

	function initPage() {
		const pageInfo = Utils.parsePage();
		if (!pageInfo) {
			log("Not a repo page.");
			return;
		}
		log(
			`Init: ${pageInfo.type} — ${pageInfo.fullName}${pageInfo.number ? "#" + pageInfo.number : ""}`,
		);
		ReleasesModule.reset();
		RepoIndexModule.reset();
		Panel.create();
	}

	function cleanup() {
		Panel.remove();
	}

	function handleNav() {
		const url = location.href;
		if (url === lastUrl) return;
		lastUrl = url;
		cleanup();
		setTimeout(initPage, 300);
	}

	// Initial load
	lastUrl = location.href;
	initPage();

	// SPA navigation
	document.addEventListener("turbo:load", handleNav);
	document.addEventListener("turbo:render", handleNav);
	document.addEventListener("turbo:frame-load", handleNav);
	window.addEventListener("popstate", () => setTimeout(handleNav, 100));

	const navObserver = new MutationObserver(() => {
		if (location.href !== lastUrl) handleNav();
	});
	navObserver.observe(document.body, { childList: true, subtree: true });
})();