// Real-surface driver: installs the production VSIX into an isolated profile,
// launches VS Code through Playwright, and reaches into the chat
// webview iframe. Playwright replaces the hand-rolled CDP drive.mjs and handles
// frame-switching, auto-waiting, keyboard and screenshots natively.
//
// Isolation is fully hermetic (see createIsolatedProfile): a throwaway
// --user-data-dir / --extensions-dir, git config neutralised via GIT_CONFIG_*,
// an isolated HOME, and an injected COMMAND_CODE_API_KEY so the composer renders
// signed-in without touching a developer's real ~/.commandcode. A developer's
// own key still passes through for the opt-in live-turn specs.

import {
	existsSync,
	mkdirSync,
	readdirSync,
	readFileSync,
	rmSync,
	statSync,
	writeFileSync,
} from 'node:fs';
import {join, resolve} from 'node:path';
import {
	type ElectronApplication,
	_electron as electron,
	expect,
	type FrameLocator,
	type Locator,
	type Page,
} from '@playwright/test';
import {createIsolatedProfile} from '../e2e/profile.js';

const PACKAGE_ROOT = resolve(__dirname, '..', '..');

// VS Code's own Node runtime emits deprecation warnings twice through its
// workbench console after an editor is opened (DEP0040 punycode on the pinned
// 1.106.3, DEP0169 url.parse on newer builds, and so on). These originate in
// workbench.desktop.main.js — never in Command Code — so the gate treats the
// whole class as benign: it matches any `(node:N) [DEP####] DeprecationWarning`
// framed by the Extension Host and sourced from workbench.desktop.main.js, with
// the version / arch / line-number portion of the path wildcarded. The framing
// stays strict (Extension Host + that exact source file + the trace-deprecation
// hint), so a product warning, an extension error, or a genuine failure still
// fails the gate. This keeps the gate robust across Node bumps as the local VS
// Code moves; note it still can't anticipate every build's non-deprecation
// console noise (e.g. a newer build's bundled chat features).
const NODE_DEPRECATION_CONSOLE_NOISE = [
	/^%c {2}ERR color: #f33 \[Extension Host\] \(node:\d+\) \[DEP\d+\] DeprecationWarning: .*\n\(Use `Code Helper \(Plugin\) --trace-deprecation \.\.\.` to show where the warning was created\) \(vscode-file:\/\/vscode-app\/.*\/workbench\.desktop\.main\.js:\d+\)$/,
	/^%c\[Extension Host\] %c\(node:\d+\) \[DEP\d+\] DeprecationWarning: .*\n\(Use `Code Helper \(Plugin\) --trace-deprecation \.\.\.` to show where the warning was created\) color: blue color: {2}\(vscode-file:\/\/vscode-app\/.*\/workbench\.desktop\.main\.js:\d+\)$/,
	// VS Code ≥1.131 ships a builtin mermaid extension that logs a proposal-gate
	// error on every boot — host noise from the editor's own bundle, not ours.
	/^%c {2}ERR color: #f33 \[vscode\.mermaid-markdown-features\]: Extension 'vscode\.mermaid-markdown-features' CANNOT use 'legacyToolReferenceFullNames' without the 'chatParticipantPrivate' API proposal enabled \(vscode-file:\/\/vscode-app\/.*\/workbench\.desktop\.main\.js:\d+\)$/,
	// The same bundled mermaid extension registers a chat tool the pinned
	// build's workbench refuses ("was not contributed") — also editor-owned
	// boot noise, seen intermittently on 1.106.3. Exact tool name + workbench
	// source, so any Command Code tool failure still fails the gate.
	/^%c {2}ERR color: #f33 Tool "renderMermaidDiagram" was not contributed\. \(vscode-file:\/\/vscode-app\/.*\/workbench\.desktop\.main\.js:\d+\)$/,
] as const;

// VS Code 1.106+ can emit this workbench-owned error when its bundled Mermaid
// extension probes a private chat API proposal. It is unrelated to installed
// extensions and varies with the local VS Code build. Keep the exception exact:
// extension id, proposal names, and workbench source must all match, so a
// Command Code error or a different Mermaid failure still fails teardown.
const MERMAID_PRIVATE_PROPOSAL_CONSOLE_NOISE =
	/^%c {2}ERR color: #f33 \[vscode\.mermaid-markdown-features\]: Extension 'vscode\.mermaid-markdown-features' CANNOT use 'legacyToolReferenceFullNames' without the 'chatParticipantPrivate' API proposal enabled \(vscode-file:\/\/vscode-app\/.*\/workbench\.desktop\.main\.js:\d+\)$/;

// VS Code ≥1.108 runs a builtin agent-discovery scan on boot that stats
// `.github/agents`, `.copilot/agents`, `.claude/agents`, and `User/prompts`
// under the workspace/home/user-data roots. In the hermetic profile those dirs
// don't exist, so each stat rejects and the scan logs a "Failed to resolve files
// at location: … Canceled: Canceled" error — workbench chrome noise from the
// editor's own agent discovery (computeAgentDiscoveryInfo), not our extension.
// Framed by that exact discovery frame + workbench source, so any other file
// resolution failure still fails the gate.
const AGENT_DISCOVERY_CONSOLE_NOISE =
	/^%c {2}ERR color: #f33 Failed to resolve files at location: \S+ Canceled: Canceled\n[\s\S]*computeAgentDiscoveryInfo[\s\S]*workbench\.desktop\.main\.js:\d+\)$/;

// VS Code 1.136 can cancel its own account cache and user-data reads while an
// Electron window is closing. Match the owning workbench call paths and the
// three known profile files so extension diagnostics remain visible.
const WORKBENCH_SHUTDOWN_CONSOLE_NOISE = [
	/^%c {2}ERR color: #f33 Canceled: Canceled\n[\s\S]*getSessions[\s\S]*initializeExtensionUsageCache[\s\S]*workbench\.desktop\.main\.js:\d+\)$/,
	/^%c {2}ERR color: #f33 \w+: Unable to read file 'vscode-userdata:\/[^']+\/User\/(?:settings|tasks|mcp)\.json' \(Canceled: Canceled\)\n[\s\S]*workbench\.desktop\.main\.js:\d+\)$/,
] as const;

export function isExpectedBuiltInConsoleNoise(message: string): boolean {
	if (matchingPattern(message, NODE_DEPRECATION_CONSOLE_NOISE)) return true;
	if (matchingPattern(message, WORKBENCH_SHUTDOWN_CONSOLE_NOISE)) return true;
	if (AGENT_DISCOVERY_CONSOLE_NOISE.test(message)) return true;
	return MERMAID_PRIVATE_PROPOSAL_CONSOLE_NOISE.test(message);
}

// Extension-host log lines that are EXPECTED under the hermetic profile and must
// not fail the diagnostics gate. The isolated profile injects a FAKE
// COMMAND_CODE_API_KEY so the composer renders signed-in, but that key can't
// authenticate against the real backend — so any background "taste learning"
// POST degrades gracefully with a 401 or a network error and logs it. That is
// the fake key working as designed, not a Command Code regression; the framing
// is exact (the taste-learning message + the /alpha/generate endpoint) so a real
// taste failure on a genuine key still surfaces.
const EXPECTED_EXTENSION_HOST_NOISE = [
	/taste learning failed: POST \/alpha\/generate → (?:\d{3} error|network error)/,
] as const;

// Request failures that are EXPECTED churn, not product faults. Disposing a
// webview editor tab (a routine multi-session close/reload) aborts its pending
// navigation to the placeholder document VS Code loads into every webview
// (`fake.html`), surfacing as a cancelled vscode-webview request. The scheme +
// the exact placeholder name are pinned so a real product fetch failure (a
// blocked CDN, a dead API port) still fails the gate.
const EXPECTED_REQUEST_FAILURE_NOISE = [
	/GET vscode-webview:\/\/\S+\/fake\.html\?id=\S+: net::ERR_ABORTED/,
] as const;

export function isExpectedExtensionHostNoise(message: string): boolean {
	return matchingPattern(message, EXPECTED_EXTENSION_HOST_NOISE);
}

export function isExpectedRequestFailureNoise(message: string): boolean {
	return matchingPattern(message, EXPECTED_REQUEST_FAILURE_NOISE);
}

export interface LaunchedVscode {
	readonly app: ElectronApplication;
	readonly diagnostics: () => readonly UiDiagnostic[];
	readonly window: Page;
	readonly workspaceDir: string;
	// The isolated HOME the extension host reads/writes under. Exposed so a spec
	// can seed on-disk state the host consumes — e.g. a persisted session
	// transcript at <homeDir>/.commandcode/projects/<slug(workspaceDir)>/ that a
	// real resume then reconstructs into the webview.
	readonly homeDir: string;
	close(): Promise<void>;
}

export interface UiDiagnostic {
	readonly kind: 'console' | 'extension-host' | 'page' | 'request';
	readonly message: string;
}

export interface LaunchVscodeOptions {
	/**
	 * Extra environment for the VS Code (and thus extension host) process,
	 * merged over the inherited env. Specs that need to poison the transport
	 * (e.g. error-live's dead API port) MUST launch their own instance with
	 * this instead of mutating process.env at module scope — the shared
	 * worker-scoped fixture window would inherit the mutation and every other
	 * spec's live turns would fail (or worse, run un-poisoned).
	 */
	readonly env?: Readonly<Record<string, string>>;
	/** Exact exceptions for an intentionally failing acceptance probe. */
	readonly expectedConsoleErrors?: readonly RegExp[];
	readonly expectedExtensionHostErrors?: readonly RegExp[];
	readonly expectedRequestFailures?: readonly RegExp[];
}

function matchingPattern(
	message: string,
	patterns: readonly RegExp[] | undefined,
): boolean {
	return patterns?.some(pattern => pattern.test(message)) ?? false;
}

function filesBelow(path: string): readonly string[] {
	if (!existsSync(path)) return [];
	if (!statSync(path).isDirectory()) return [path];
	return readdirSync(path).flatMap(entry => filesBelow(join(path, entry)));
}

function extensionHostDiagnostics(
	userDataDir: string,
): readonly UiDiagnostic[] {
	const logDirectory = join(userDataDir, 'logs');
	return filesBelow(logDirectory).flatMap(path => {
		const relevantLog =
			path.endsWith('Command Code.log') || path.endsWith('exthost.log');
		if (!relevantLog) return [];
		return readFileSync(path, 'utf8')
			.split('\n')
			.filter(line => /\berror\b/i.test(line))
			.filter(
				line =>
					path.endsWith('Command Code.log') ||
					/commandcode/i.test(line),
			)
			.map(line => ({
				kind: 'extension-host' as const,
				message: `${path}: ${line}`,
			}));
	});
}

function unexpectedDiagnostics(
	diagnostics: readonly UiDiagnostic[],
	options: LaunchVscodeOptions,
): readonly UiDiagnostic[] {
	return diagnostics.filter(diagnostic => {
		if (diagnostic.kind === 'console') {
			if (isExpectedBuiltInConsoleNoise(diagnostic.message)) return false;
			return !matchingPattern(
				diagnostic.message,
				options.expectedConsoleErrors,
			);
		}
		if (diagnostic.kind === 'extension-host') {
			if (isExpectedExtensionHostNoise(diagnostic.message)) return false;
			return !matchingPattern(
				diagnostic.message,
				options.expectedExtensionHostErrors,
			);
		}
		if (diagnostic.kind === 'request') {
			if (isExpectedRequestFailureNoise(diagnostic.message)) return false;
			return !matchingPattern(
				diagnostic.message,
				options.expectedRequestFailures,
			);
		}
		return true;
	});
}

function diagnosticError(diagnostics: readonly UiDiagnostic[]): Error {
	const details = diagnostics
		.map(diagnostic => `[${diagnostic.kind}] ${diagnostic.message}`)
		.join('\n');
	return new Error(`Unexpected installed-VSIX diagnostics:\n${details}`);
}

export async function launchVscode(
	options: LaunchVscodeOptions = {},
): Promise<LaunchedVscode> {
	const {
		dataDir,
		userDataDir,
		extensionsDir,
		workspaceDir,
		homeDir,
		vscodeExecutablePath,
		env,
	} = await createIsolatedProfile(PACKAGE_ROOT, options.env ?? {});
	const userDir = join(userDataDir, 'User');
	mkdirSync(userDir, {recursive: true});
	// Skip every onboarding surface so the panel isn't covered on first run.
	writeFileSync(
		join(userDir, 'settings.json'),
		JSON.stringify({
			'workbench.startupEditor': 'none',
			'workbench.welcomePage.walkthroughs.openOnInstall': false,
			'extensions.autoCheckUpdates': false,
			'extensions.autoUpdate': false,
			'extensions.ignoreRecommendations': true,
			'update.mode': 'none',
			// The relauncher watches workbench.enableExperiments plus a dozen
			// chat.agentHost.* settings; a remote experiment treatment flipping one
			// after startup pops the native "restart to take effect" dialog over
			// every test window. Freeze experiments so defaults can't move mid-run.
			'workbench.enableExperiments': false,
			'telemetry.telemetryLevel': 'off',
			'security.workspace.trust.enabled': false,
			'window.dialogStyle': 'custom',
			'window.zoomLevel': 0,
		}),
	);

	const app = await electron.launch({
		executablePath: vscodeExecutablePath,
		args: [
			`--user-data-dir=${userDataDir}`,
			`--extensions-dir=${extensionsDir}`,
			'--disable-workspace-trust',
			'--skip-welcome',
			'--skip-release-notes',
			'--disable-updates',
			'--new-window',
			'--disable-gpu',
			// HOME is isolated, so the OS keychain (which lives under the real
			// home) is unreachable — without this VS Code pops a blocking
			// "Keychain Not Found" modal trying to store its Code Key.
			// `--password-store=basic` is the Linux fix (a no-op on macOS, where
			// Chromium ignores it); `--use-inmemory-secretstorage` is what keeps
			// the macOS host off the OS keychain. Ship both so every host stays
			// self-contained.
			'--password-store=basic',
			'--use-inmemory-secretstorage',
			'--force-device-scale-factor=2',
			'--enable-features=OverlayScrollbar',
			'--enable-precise-memory-info',
			'--js-flags=--expose-gc',
			'--window-size=1440,1000',
			workspaceDir,
		],
		env,
		timeout: 90_000,
	});

	const window = await app.firstWindow();
	const captured: UiDiagnostic[] = [];
	window.on('console', message => {
		if (message.type() !== 'error') return;
		const location = message.location();
		const suffix = location.url
			? ` (${location.url}:${location.lineNumber})`
			: '';
		captured.push({kind: 'console', message: `${message.text()}${suffix}`});
	});
	window.on('pageerror', error => {
		captured.push({kind: 'page', message: error.message});
	});
	window.on('requestfailed', request => {
		captured.push({
			kind: 'request',
			message: `${request.method()} ${request.url()}: ${request.failure()?.errorText ?? 'unknown failure'}`,
		});
	});
	// Turn CSP reports into first-class diagnostics for every future webview
	// frame. The deliberate negative probe opts into only its exact violations.
	await window.addInitScript({
		content: `document.addEventListener('securitypolicyviolation', event => {
			console.error('[CSP violation]', event.effectiveDirective, event.blockedURI);
		});`,
	});
	await window.waitForLoadState('domcontentloaded');
	const diagnostics = (): readonly UiDiagnostic[] => [
		...captured,
		...extensionHostDiagnostics(userDataDir),
	];
	const close = async (): Promise<void> => {
		await app.close();
		// Read diagnostics (they parse logs under userDataDir) BEFORE deleting the
		// profile. Each launch mkdtemps a full ~11MB+ profile (installed VSIX +
		// extensions + user-data) under the OS temp dir; without this the whole
		// tree leaks per window and accumulates into gigabytes across runs, which
		// eventually starves the disk and destabilizes the VS Code the suite drives.
		const unexpected = unexpectedDiagnostics(diagnostics(), options);
		rmSync(dataDir, {recursive: true, force: true});
		if (unexpected.length > 0) throw diagnosticError(unexpected);
	};
	return {
		app,
		diagnostics,
		window,
		workspaceDir,
		homeDir,
		close,
	};
}

/** Dismiss blocking first-run dialogs (sign-in nudges, onboarding). */
export async function dismissDialogs(window: Page): Promise<void> {
	for (let attempt = 0; attempt < 4; attempt++) {
		const skipped = await window.evaluate(() => {
			const skip = [...document.querySelectorAll('a, button')].find(el =>
				/continue without|skip for now|not now|maybe later/i.test(
					el.textContent ?? '',
				),
			) as HTMLElement | undefined;
			if (skip) {
				skip.click();
				return true;
			}
			return false;
		});
		if (!skipped) break;
		await window.waitForTimeout(700);
	}
}

/**
 * Open the Command Code chat panel (the secondary-side-bar chat view). The
 * reliable path is the extension's own "Command Code: Open Chat in Secondary
 * Sidebar" palette command. Deliberately NOT the activity-bar icon: since the
 * session-explorer landed, that icon opens the Sessions webview — also a
 * commandcode iframe — so both the icon fast-path and an "any commandcode
 * iframe exists" early-return would latch onto the wrong view.
 */
export async function openChatPanel(
	window: Page,
	readySelector = '.cc-app',
): Promise<void> {
	await window.waitForTimeout(1500);
	await dismissDialogs(window);
	const mod = process.platform === 'darwin' ? 'Meta' : 'Control';
	// One shot races extension-host startup — the command only exists once the
	// host has activated — so retry until the chat app has actually booted
	// inside a commandcode webview iframe. The contributed keybinding
	// (cmd/ctrl+shift+') is the primary path: it maps straight to
	// commandcode.focusChat with no palette text-matching to go wrong. The
	// palette run stays as a fallback in case the chord is swallowed.
	const ATTEMPTS = 10;
	for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
		if ((await chatFrame(window).locator(readySelector).count()) > 0)
			return;
		await window.keyboard.press('Escape');
		if (attempt % 2 === 0) {
			await window.keyboard.press(`${mod}+Shift+'`);
		} else {
			await window.keyboard.press(`${mod}+Shift+P`);
			await window.keyboard.type('Open Chat in Secondary Sidebar', {
				delay: 15,
			});
			await window.waitForTimeout(500);
			await window.keyboard.press('Enter');
		}
		await window.waitForTimeout(1500);
	}
	// Fail fast and name the real problem. Returning silently would leave
	// every later chatFrame() selector timing out with misleading errors.
	throw new Error(
		`Command Code chat webview never mounted after ${ATTEMPTS} attempts — ` +
			'extension host may have failed to activate (check exthost logs).',
	);
}

/** The chat app lives in the nested #active-frame iframe inside the webview.
 * Scoped by extensionId (VS Code's built-in Chat view mounts its own
 * `iframe.webview` alongside ours) and to the FIRST match: the Sessions
 * explorer is a second commandcode webview, but it can only come into
 * existence after openChatPanel has already booted the chat view (a test
 * revealing it later — /resume — appends its iframe after ours), so the
 * first-created iframe is deterministically the chat panel. */
export function chatFrame(window: Page): FrameLocator {
	return window
		.locator('iframe.webview[src*="extensionId=commandcode"]')
		.first()
		.contentFrame()
		.frameLocator('iframe#active-frame');
}

const MAX_OVERLAY_DISMISSALS = 8;

/**
 * Return the shared chat webview to a neutral interaction state without
 * resetting its persisted session data. Overlay shells only handle Escape
 * while focus is inside the shell, so target the visible shell directly.
 */
export async function dismissChatOverlays(window: Page): Promise<void> {
	// VS Code parks an overlay above an unfocused webview. Re-run the extension's
	// focus command so the next test can use ordinary actionability checks.
	const mod = process.platform === 'darwin' ? 'Meta' : 'Control';
	await window.keyboard.press(`${mod}+Shift+'`);
	const frame = chatFrame(window);
	await expect(frame.locator('.cc-app')).toBeVisible({timeout: 10_000});
	const overlays = frame.locator('.cc-overlay:visible');
	for (let attempt = 0; attempt < MAX_OVERLAY_DISMISSALS; attempt += 1) {
		const count = await overlays.count();
		if (count === 0) break;
		await overlays.last().press('Escape');
	}
	await expect(frame.locator('.cc-overlay')).toHaveCount(0, {timeout: 5_000});
	const composer = frame.getByRole('combobox', {
		name: 'Message Command Code',
	});
	if (!(await composer.isVisible().catch(() => false))) return;
	// The focus command can leave VS Code's one-click capture layer over an
	// already-mounted webview. Send that activation click at the real composer
	// coordinates, then prove ordinary pointer actionability is restored.
	const box = await composer.boundingBox();
	if (box !== null) {
		await window.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
	}
	await composer.click();
	await composer.fill('');
}

// Reveal the native Sessions TreeView (commandcode.sessions, titled
// "Sessions") in the primary-sidebar `commandcode` activity container and return
// its `role="tree"` Locator. The tree lives in the workbench DOM, NOT a
// commandcode webview iframe, so this returns a Playwright `Locator`, never a
// FrameLocator. Mirrors sessions-tree.spec.ts's focusNativeTree: keyed on the
// always-present section header (the tree ROLE is absent when the catalog is
// empty and viewsWelcome shows instead), it only activates the container when the
// section isn't already showing, since clicking an already-active activity item
// TOGGLES its container shut. Idempotent — safe to re-call between row opens,
// which can hide the sidebar behind a freshly opened editor tab.
export async function revealSessionsExplorer(
	window: Page,
	// Kept for call-site compatibility (composer-frame callers still pass one). No
	// longer used: the tree is revealed via its activity-bar container, never the
	// composer `/resume`, which now opens the in-panel SessionPicker instead.
	_frame?: FrameLocator,
): Promise<Locator> {
	// Scope every detection to the PRIMARY sidebar. The chat panel's own "New
	// Session" (+) button and "Show Sessions" clock live in the secondary
	// (auxiliary) bar, so an unscoped "New Session" / /Sessions/ match would
	// short-circuit the reveal before the tree's own container is ever shown.
	const sidebar = window.locator('.part.sidebar');
	// VS Code can name the root after the container rather than the view.
	const tree = sidebar.getByRole('tree', {name: /^(Command Code|Sessions)$/});
	// On an empty catalog the tree ROLE is absent and viewsWelcome shows a
	// "New Session" affordance inside the view instead — treat that as revealed too.
	const welcomeContent = sidebar.locator('.welcome-view-content');
	const welcome = welcomeContent
		.getByRole('link', {name: 'New Session'})
		.or(welcomeContent.getByRole('button', {name: 'New Session'}))
		.first();
	// The `commandcode` activity-bar item. Clicking it OPENS the primary sidebar on
	// this container when it's closed or showing another container; the same
	// CSS-scoped item the webview explorer reveal used, since the container title is
	// unchanged. The fast-path checks below return BEFORE any click when the view is
	// already on-screen, so an already-active item is never toggled shut.
	const item = window
		.locator('.part.activitybar a.action-label[aria-label="Command Code"]')
		.first();
	for (let attempt = 0; attempt < 8; attempt++) {
		if (await tree.isVisible().catch(() => false)) return tree;
		if (await welcome.isVisible().catch(() => false)) return tree;
		if ((await item.count()) > 0) await item.click();
		await window.waitForTimeout(900);
	}
	throw new Error('Native Sessions tree never became visible.');
}

// Escape a literal token for embedding in a RegExp — session tokens are plain
// slugs today, but a summary can carry regex metacharacters.
function escapeRegExp(value: string): string {
	return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

// A row in the native Sessions tree, matched by the distinctive title token its
// accessible name carries ("<name>, <statusText>, <relativeTime>"). Both leaf
// rows and date-group headers are `role="treeitem"`, but only a leaf carries a
// session token. An optional `status` phrase pins the live-state the old
// `.cc-sessions-dot-*` classes encoded — "closed", "open, idle", "home, idle",
// "streaming", "waiting for you" (see statusText in sessions-tree-provider.ts);
// it is spliced between the name and the relative time, so the regex anchors it
// with the trailing comma.
export function treeSessionRow(
	tree: Locator,
	token: string | RegExp,
	status?: string,
): Locator {
	if (status === undefined || typeof token !== 'string') {
		return tree.getByRole('treeitem', {name: token});
	}
	const pattern = RegExp(`${escapeRegExp(token)}, ${escapeRegExp(status)},`);
	return tree.getByRole('treeitem', {name: pattern});
}

// Mint a fresh session in its own editor tab from the native tree. On an empty
// catalog the viewsWelcome renders a "New Session" link; once rows exist the
// view-title action renders it as a button — accept either (mirrors the poc
// spec's New Session locator).
export async function newSessionFromExplorer(window: Page): Promise<void> {
	await revealSessionsExplorer(window);
	// Scope to the PRIMARY sidebar so this is the tree's New Session
	// (sessionsNewSession → openNewSessionInTab), never the secondary sidebar's
	// `+` (newSessionInPanel), which would mint a sidebar session, not an editor tab.
	const sidebar = window.locator('.part.sidebar');
	const newSession = sidebar
		.getByRole('link', {name: 'New Session'})
		.or(sidebar.getByRole('button', {name: 'New Session'}))
		.first();
	await expect(newSession).toBeVisible({timeout: 20_000});
	await newSession.click();
}

// The composer is the vendored GUI ARIA combobox (aria-label "Message Command
// Code", data-composer-input) that replaced the old `textarea.cc-input`. Scoped
// to a specific inner frame so each surface — the home sidebar and every editor
// tab — resolves ITS own composer.
export function composerInput(frame: FrameLocator): Locator {
	return frame.getByRole('combobox', {name: 'Message Command Code'});
}

// Pick a bare slash command from the composer's slash menu — the
// `#cc-composer-menu` combobox popup (flat, fuzzy-scored `.cc-slash-command-row`
// rows) that replaced the old `role="listbox"` "Slash commands". Types the
// command, then clicks its row outright rather than pressing Enter, so the
// selection never depends on which row the fuzzy filter highlighted.
export async function pickSlashCommand(
	window: Page,
	frame: FrameLocator,
	command: string,
): Promise<void> {
	const input = composerInput(frame);
	await expect(input).toBeVisible({timeout: 15_000});
	await input.click();
	await input.fill('');
	await window.keyboard.type(command, {delay: 12});
	const row = frame
		.locator('#cc-composer-menu .cc-slash-command-row')
		.filter({hasText: command})
		.first();
	await expect(row).toBeVisible({timeout: 10_000});
	await row.click();
}

// The secondary-sidebar chat's in-webview TopBar (Sessions · Settings · New
// Session), which replaced the native view/title `+` and clock. Only the
// secondary-sidebar chat draws it (editor-tab chats keep VS Code's own tab
// chrome), so the commandcode frame holding `.cc-topbar` IS the sidebar chat even
// while editor tabs are open. Scoping to that bar also keeps its "New Session"
// clear of the activity-bar commandcode.newSession, which shares the name.
interface SidebarTopBarButton {
	readonly frame: FrameLocator;
	readonly button: Locator;
}

async function sidebarTopBarFrameIndex(window: Page): Promise<number> {
	const frames = commandcodeFrames(window);
	const total = await frames.count();
	for (let index = 0; index < total; index++) {
		if ((await frames.nth(index).locator('.cc-topbar').count()) > 0) {
			return index;
		}
	}
	return -1;
}

async function sidebarTopBarButton(
	window: Page,
	name: string,
): Promise<SidebarTopBarButton> {
	await expect
		.poll(() => sidebarTopBarFrameIndex(window), {timeout: 15_000})
		.toBeGreaterThanOrEqual(0);
	const frame = commandcodeFrames(window).nth(
		await sidebarTopBarFrameIndex(window),
	);
	const button = frame
		.locator('.cc-topbar')
		.getByRole('button', {name, exact: true});
	await expect(button).toBeVisible({timeout: 15_000});
	return {frame, button};
}

// Click the sidebar TopBar's "New Session": mint a fresh CONCURRENT blank sidebar
// session and swap the panel to it (`newSession` → newSidebarSession). The prior
// sidebar sessions keep running in the background.
export async function newSidebarSession(window: Page): Promise<void> {
	const {button} = await sidebarTopBarButton(window, 'New Session');
	await button.click();
}

// Open the in-panel sessions dropdown via the sidebar TopBar's "Sessions" button
// (the `history` overlay). The overlay is the shared PickerMenu rendered INSIDE
// the sidebar chat webview; returns that frame with the `.cc-session-picker`
// popover confirmed visible. Rows are `[role="option"]` with a
// `.cc-sessions-dot-*` lead and a `.cc-session-picker-time` /
// `.cc-session-picker-check` trailing meta.
export async function openSidebarSessionPicker(
	window: Page,
): Promise<FrameLocator> {
	const {frame, button} = await sidebarTopBarButton(window, 'Sessions');
	await button.click();
	await expect(frame.locator('.cc-session-picker')).toBeVisible({
		timeout: 15_000,
	});
	return frame;
}

// Pick a row (matched by its distinctive title text) from an open sidebar
// sessions dropdown, posting switchSidebarSession for that session.
export async function pickSidebarSessionRow(
	frame: FrameLocator,
	token: string,
): Promise<void> {
	const row = frame
		.locator('.cc-session-picker [role="option"]')
		.filter({hasText: token});
	await expect(row).toBeVisible({timeout: 15_000});
	await row.click();
}

// Open a session row (matched by its distinctive title token) in its own editor
// tab. A row click routes through the tree's OPEN_COMMAND → openSessionInTab,
// which opens a tab for any non-home session. It then waits for THIS tab's feed to
// reconstruct before returning: a native treeitem click is instant (unlike the old
// webview round-trip), so a rapid second open could otherwise race and supersede
// this one's in-flight reconstruction, leaving the first tab's feed empty.
export async function openRowInTab(
	tree: Locator,
	token: string,
): Promise<void> {
	const row = treeSessionRow(tree, token);
	await expect(row).toBeVisible({timeout: 30_000});
	await row.click();
	await editorTabFrame(tree.page(), token);
}

// The workbench editor tab (VS Code chrome, outside any webview) whose title
// carries `token`. Matched by accessible name, not visible text: the tab bar
// ellipsizes a long label in the DOM but keeps the full title on the role=tab
// node, so `.filter({hasText})` on `.tabs-container .tab` would miss it.
export function workbenchTab(window: Page, token: string): Locator {
	return window.getByRole('tab', {name: token, exact: false});
}

// Focus an editor tab by title. A background tab keeps its webview mounted
// (retainContextWhenHidden), but its DOM is hidden, so its composer is not
// clickable until the tab is active — any spec that drives a background tab's
// own composer must focus it first.
export async function focusEditorTab(
	window: Page,
	token: string,
): Promise<void> {
	const tab = workbenchTab(window, token).first();
	await expect(tab).toBeVisible({timeout: 30_000});
	await tab.click();
}

// Every live commandcode webview inner-frame at once: the home sidebar, the
// sessions explorer, and each editor tab. Once tabs exist, DOM position can't
// tell them apart, so callers disambiguate on CONTENT (feed text) or on the
// `data-cc-container` marker the webview HTML stamps per surface.
export function commandcodeFrames(window: Page): {
	readonly count: () => Promise<number>;
	readonly nth: (index: number) => FrameLocator;
} {
	const webviews = window.locator(
		'iframe.webview[src*="extensionId=commandcode"]',
	);
	return {
		count: () => webviews.count(),
		nth: index =>
			webviews
				.nth(index)
				.contentFrame()
				.frameLocator('iframe#active-frame'),
	};
}

// Count the editor-tab chat frames whose feed currently contains `token`. Only
// editor tabs are counted (`data-cc-container="editor"`), so the home sidebar
// can never be mistaken for a tab. Zero once a token's tab is cleared or closed.
export async function editorTabsWithFeedText(
	window: Page,
	token: string,
): Promise<number> {
	const frames = commandcodeFrames(window);
	const total = await frames.count();
	let matches = 0;
	for (let index = 0; index < total; index++) {
		const frame = frames.nth(index);
		if (
			(await frame.locator('[data-cc-container="editor"]').count()) === 0
		) {
			continue;
		}
		matches += await frame
			.locator('.cc-feed')
			.filter({hasText: token})
			.count();
	}
	return matches;
}

// Resolve the editor-tab chat frame whose feed contains `token`, polling until
// its webview has mounted and reconstructed the seeded feed. The launcher opens
// a resumed (non-home) session in its own editor tab, so a resumed transcript's
// feed lands here, not in the home sidebar.
export async function editorTabFrame(
	window: Page,
	token: string,
): Promise<FrameLocator> {
	const deadline = Date.now() + 30_000;
	while (Date.now() < deadline) {
		const frames = commandcodeFrames(window);
		const total = await frames.count();
		const frame = await matchingEditorFrame(frames, total, token);
		if (frame) return frame;
		await window.waitForTimeout(500);
	}
	throw new Error(
		`No editor-tab chat frame with feed text "${token}" ever mounted.`,
	);
}

async function matchingEditorFrame(
	frames: ReturnType<typeof commandcodeFrames>,
	total: number,
	token: string,
): Promise<FrameLocator | null> {
	for (let index = 0; index < total; index++) {
		const frame = frames.nth(index);
		const isEditor =
			(await frame.locator('[data-cc-container="editor"]').count()) > 0;
		if (!isEditor) continue;
		const hasToken =
			(await frame.locator('.cc-feed').filter({hasText: token}).count()) >
			0;
		if (hasToken) return frame;
	}
	return null;
}

// Switch the PRIMARY sidebar to the built-in File Explorer by CLICKING its
// activity-bar item. A click on workbench chrome bypasses the webview focus-capture
// that swallows keyboard chords/palette input while the chat webview holds focus
// (a raw `cmd+shift+e` opens the chat's own Rewind overlay; the palette chord leaks
// its text into the composer). Switching away from the sessions explorer disposes
// it (it sets no retainContextWhenHidden), so the next reveal re-mounts fresh.
async function showFilesExplorer(window: Page): Promise<void> {
	const explorerItem = window
		.getByRole('tab', {name: /^Explorer/})
		.or(window.locator('.activitybar [aria-label^="Explorer"]'))
		.first();
	if ((await explorerItem.count()) === 0) return;
	await explorerItem.click();
	await window.waitForTimeout(300);
}

// Close every editor tab in the shared window WITHOUT killing Electron. Uses the
// default `cmd/ctrl+k cmd/ctrl+w` chord (View: Close All Editors) rather than a
// palette text-match, so it can't latch onto the wrong command. The home chat and
// sessions explorer are activity-bar/secondary-sidebar VIEWS, not editors, so they
// survive — only the per-session editor tabs are disposed. Idempotent: a no-op
// when no editor is open.
export async function closeAllEditors(window: Page): Promise<void> {
	const mod = process.platform === 'darwin' ? 'Meta' : 'Control';
	await window.keyboard.press('Escape');
	await window.keyboard.press(`${mod}+K`);
	await window.keyboard.press(`${mod}+W`);
	// Give the workbench a beat to tear the tabs down before the next assertion.
	await expect(window.locator('.tabs-container .tab')).toHaveCount(0, {
		timeout: 15_000,
	});
}

// Per-spec clean slate for the shared worker window (see fixtures.ts): close every
// editor tab, wipe the on-disk transcript catalog a spec may have seeded so the
// next spec lists a clean set, and re-focus the booted home chat panel. This is
// the ONE-WINDOW replacement for relaunching Electron between spec files — it
// resets the surfaces a spec touches without paying an Electron boot per file.
//
// Specs that must diverge on LAUNCH ARGS (a poisoned transport, memory-profiling
// flags, an acceptance fixture env) or that need a distinct diagnostics gate
// (deliberate tab-close teardown noise) still launch their OWN instance — this
// reset only serves specs that share the worker window.
export async function resetVscode(vs: LaunchedVscode): Promise<void> {
	await closeAllEditors(vs.window);
	rmSync(join(vs.homeDir, '.commandcode', 'projects'), {
		recursive: true,
		force: true,
	});
	// Park the primary sidebar on the built-in File Explorer so the native Sessions
	// tree goes INVISIBLE. Its provider re-collects the on-disk catalog on every
	// visibility gain (onDidChangeVisibility → refresh), so the next
	// revealSessionsExplorer re-reads disk from scratch. Without the hide/show
	// bounce, a spec that reveals a tree left showing by the previous spec could see
	// that spec's STALE catalog — the freshly-seeded rows never appearing (reused
	// row tokens can even mask the gap) because no visibility change forced a
	// re-list.
	await showFilesExplorer(vs.window);
	await openChatPanel(vs.window);
	await expect(chatFrame(vs.window).locator('.cc-app')).toBeVisible({
		timeout: 30_000,
	});
}
