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.

Você precisará instalar uma extensão como Tampermonkey, Greasemonkey ou Violentmonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey ou Violentmonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey ou Userscripts para instalar este script.

Você precisará instalar uma extensão como o Tampermonkey para instalar este script.

Você precisará instalar um gerenciador de scripts de usuário para instalar este script.

(Eu já tenho um gerenciador de scripts de usuário, me deixe instalá-lo!)

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

(Eu já possuo um gerenciador de estilos de usuário, me deixar fazer a instalação!)

// ==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 });
})();