import { execFile } from 'node:child_process';
import type {
	Browser,
	BrowserContext,
	Dialog,
	FileChooser,
	Locator,
	Page,
	Request,
	Response,
} from 'playwright-core';
import { chromium } from 'playwright-core';

import { CDPRelayServer } from '../cdp-relay';
import {
	BrowserExecutableNotFoundError,
	ElementNotActionableError,
	PageNotFoundError,
	StaleRefError,
	type ConnectionLostReason,
	type UnactionableReason,
} from '../errors';
import { buildExtensionConnectUrl } from '../extension-connect';
import { createLogger } from '../logger';
import { HTML_PROBE_SCRIPT, parseHtmlProbeResult } from '../sensitivity/html-probe';
import type {
	ClickOptions,
	ConnectConfig,
	ConsoleEntry,
	Cookie,
	ElementTarget,
	ErrorEntry,
	ModalState,
	NavigateResult,
	NetworkEntry,
	PageInfo,
	ResolvedConfig,
	HtmlProbeResult,
	ScreenshotOptions,
	ScrollOptions,
	SnapshotResult,
	TypeOptions,
	WaitOptions,
	DisconnectDetails,
} from '../types';
import { generateId, toError } from '../utils';

const log = createLogger('playwright');

/**
 * How long a control may stay unactionable before the action is refused.
 * Generous on purpose: a control disabled by a debounce or an in-flight save
 * was acted on before this guard existed, and refusing one is worse than
 * waiting. Overridable because it pre-empts Playwright's own configurable
 * timeout, and a slow backend can legitimately need longer.
 */
const ACTIONABLE_GRACE_MS = Number(process.env.N8N_MCP_BROWSER_ACTIONABLE_GRACE_MS) || 5_000;
/** Re-read cadence within the grace window. */
const ACTIONABLE_POLL_MS = 250;
/** Budget for one read, which crosses the relay and so is not instant. */
const ACTIONABLE_READ_TIMEOUT_MS = 2_000;

/**
 * What an action needs of its target.
 *
 * `click` and `select` mirror Playwright's own actionability, so the guard
 * cannot refuse what Playwright would have performed — note a read-only
 * `<select>` still accepts `selectOption`, so it needs `enabled`, not
 * `editable`.
 *
 * `type` is deliberately STRICTER than Playwright: `pressSequentially` types
 * into a disabled or read-only field without complaint and reports success,
 * so the caller believes a value was entered when nothing was. Refusing is the
 * lesser evil, and it is a correctness fix rather than a saved timeout.
 */
type Actionability = 'enabled' | 'editable';

// ---------------------------------------------------------------------------
// Per-page state tracked by the adapter
// ---------------------------------------------------------------------------

interface PageState {
	page: Page;
	info: PageInfo;
	consoleBuffer: ConsoleEntry[];
	errorBuffer: ErrorEntry[];
	networkBuffer: NetworkEntry[];
	pendingDialog?: Dialog;
	pendingFileChooser?: FileChooser;
}

// ---------------------------------------------------------------------------
// Adapter
// ---------------------------------------------------------------------------

export interface PlaywrightAdapterOptions {
	/**
	 * Externally managed relay (remote mode). When provided, launch() does not
	 * start a local browser or a relay of its own; it waits for the extension
	 * on this relay and connects Playwright through it. The relay's lifecycle
	 * is owned by the embedder.
	 */
	relay?: CDPRelayServer;
	/** Explicit CDP endpoint to connect to in remote mode. Overrides `relay.cdpEndpoint()`. */
	cdpEndpoint?: string;
	/** Headers sent when connecting to the CDP endpoint (e.g. an auth token). */
	cdpConnectHeaders?: Record<string, string>;
}

export class PlaywrightAdapter {
	readonly name = 'playwright';

	private resolvedConfig: ResolvedConfig;
	private browser?: Browser;
	private context?: BrowserContext;
	private pageStates = new Map<string, PageState>();
	private relay?: CDPRelayServer;
	private readonly externalRelay?: CDPRelayServer;
	private readonly externalCdpEndpoint?: string;
	private readonly cdpConnectHeaders?: Record<string, string>;
	/** Puts the embedder's relay handlers back; set while ours are installed. */
	private restoreRelayHandlers?: () => void;
	/** Pending activation: set by ensurePage(), consumed by context.on('page'). */
	private pendingActivation?: { id: string; resolve: (page: Page) => void };

	/** Called when the browser connection is unexpectedly lost. */
	onDisconnect?: (reason: ConnectionLostReason, details?: DisconnectDetails) => void;

	onBlocked?: (details: DisconnectDetails) => void;

	constructor(config: ResolvedConfig, options?: PlaywrightAdapterOptions) {
		this.resolvedConfig = config;
		this.externalRelay = options?.relay;
		this.externalCdpEndpoint = options?.cdpEndpoint;
		this.cdpConnectHeaders = options?.cdpConnectHeaders;
	}

	// =========================================================================
	// Lifecycle
	// =========================================================================

	async launch(config: ConnectConfig): Promise<void> {
		log.debug('launch: browser =', config.browser);

		if (this.externalRelay) {
			// Remote mode - the extension connects to an externally managed relay
			// (e.g. exposed by the n8n server). No local browser is launched.
			this.relay = this.externalRelay;
			log.debug('remote mode: waiting for extension on external relay...');
			await this.relay.waitForExtension({ browserWasLaunched: true });
			await this.connectPlaywright(this.externalCdpEndpoint ?? this.relay.cdpEndpoint());
			return;
		}

		// Local mode — connect to the user's running Chrome via extension bridge.
		// The CDPRelayServer bridges Playwright ↔ Chrome extension (chrome.debugger).
		this.relay = new CDPRelayServer();
		const port = await this.relay.listen();
		const extensionEndpoint = this.relay.extensionEndpoint(port);

		// Open the extension's connect page with the relay URL so it auto-connects.
		// `N8N_EVAL_AUTO_BROWSER_CONNECT=1` (set by the eval daemon spawn) appends
		// `autoConnect=1` so the extension UI clicks Connect itself — keeps eval
		// runs human-out-of-the-loop across reconnect cycles. The extension only
		// honors `autoConnect=1` when the relay URL is localhost (which the relay
		// always is — see `cdp-relay.ts`), so a crafted chrome-extension URL
		// pointing at a remote relay can't trigger this path.
		const autoConnect = process.env.N8N_EVAL_AUTO_BROWSER_CONNECT === '1';
		const connectUrl = buildExtensionConnectUrl(extensionEndpoint, { autoConnect });
		const browserInfo = this.resolvedConfig.browsers.get(config.browser);
		const chromePath = browserInfo?.executablePath;
		if (!chromePath) {
			throw new BrowserExecutableNotFoundError(config.browser);
		}

		log.debug('launching browser:', chromePath);
		log.debug('connect URL:', connectUrl);

		// Launch the browser and detect early spawn failures (ENOENT, EACCES, etc.)
		await new Promise<void>((resolve, reject) => {
			const child = execFile(chromePath, [connectUrl]);
			const earlyFailTimer = setTimeout(() => resolve(), 2_000);
			child.on('error', (spawnError: Error) => {
				clearTimeout(earlyFailTimer);
				log.error('browser spawn error:', spawnError.message);
				reject(new BrowserExecutableNotFoundError(`${config.browser} (${spawnError.message})`));
			});
		});

		// Wait for the extension to connect and attach to tabs
		log.debug('waiting for extension...');
		await this.relay.waitForExtension({ browserWasLaunched: true });

		await this.connectPlaywright(this.relay.cdpEndpoint(port));
	}

	/** Connect Playwright over CDP through the relay and wire up handlers. */
	private async connectPlaywright(cdpEndpoint: string): Promise<void> {
		const relay = this.relay!;
		log.debug('connecting Playwright over CDP:', cdpEndpoint);
		this.browser = await chromium.connectOverCDP(cdpEndpoint, {
			headers: this.cdpConnectHeaders,
			noDefaults: true,
		});
		const contexts = this.browser.contexts();
		log.debug('browser contexts:', contexts.length);
		this.context = contexts[0] ?? (await this.browser.newContext({ colorScheme: null }));

		// Two-tier model: pages are created lazily via ensurePage().
		// When ensurePage() triggers activateTab(), it sets pendingActivation
		// so we know which ID to assign to the incoming Page object.
		this.context.on('page', (page: Page) => {
			if (this.pendingActivation) {
				const { id, resolve } = this.pendingActivation;
				this.pendingActivation = undefined;
				log.debug('page event: consumed pendingActivation, id =', id);
				if (!this.pageStates.has(id)) {
					this.trackPage(page, id);
				}
				resolve(page);
				return;
			}
			// Fallback: page appeared without a pending activation (e.g. popup)
			log.debug('page event: no pendingActivation, assigning random id');
			if (!this.findPageState(page)) {
				this.trackPage(page);
			}
		});

		// Detect unexpected disconnection from the browser (process crash, etc.)
		this.browser.on('disconnected', () => {
			log.debug('browser disconnected event');
			this.onDisconnect?.('browser_closed');
		});

		// In remote mode the relay outlives us, so chain onto the embedder's handlers
		// and restore them on close. One closure so neither can be forgotten.
		const previousDisconnect = relay.onExtensionDisconnect;
		const previousBlocked = relay.onTabBlocked;
		relay.onExtensionDisconnect = (reason, details) => {
			log.debug('relay: extension disconnected, reason:', reason);
			previousDisconnect?.(reason, details);
			this.onDisconnect?.(reason, details);
		};
		relay.onTabBlocked = (details) => {
			log.debug('relay: tab blocked by', details.blockingExtensionIds);
			previousBlocked?.(details);
			this.onBlocked?.(details);
		};
		this.restoreRelayHandlers = () => {
			relay.onExtensionDisconnect = previousDisconnect;
			relay.onTabBlocked = previousBlocked;
		};

		log.debug('launch complete, context ready for lazy activation');
	}

	async close(): Promise<void> {
		try {
			if (this.context) await this.context.close();
		} catch {
			// context may already be closed
		}
		try {
			if (this.browser) await this.browser.close();
		} catch {
			// browser may already be closed
		}
		if (this.relay) {
			if (this.relay === this.externalRelay) {
				// Externally managed relay: restore handlers, leave it running.
				this.restoreRelayHandlers?.();
				this.restoreRelayHandlers = undefined;
			} else {
				this.relay.stop();
			}
			this.relay = undefined;
		}
		this.pageStates.clear();
	}

	// =========================================================================
	// Pages
	// =========================================================================

	async newPage(
		url?: string,
		waitUntil: 'load' | 'domcontentloaded' | 'networkidle' = 'domcontentloaded',
	): Promise<PageInfo> {
		log.debug('newPage: creating page, url =', url ?? '(none)');
		const page = await this.requireContext().newPage();
		// The relay assigned an ID during Target.createTarget → createTab()
		const tabId = this.relay?.getLastCreatedTabId();
		log.debug('newPage: relay tabId =', tabId);
		const state = this.findPageState(page) ?? this.trackPage(page, tabId);

		if (url) {
			await page.goto(url, { waitUntil });
			state.info.title = await page.title();
			state.info.url = page.url();
		}

		return { ...state.info };
	}

	async closePage(pageId: string): Promise<void> {
		// Clean up local Playwright state if tracked (may not be if never activated)
		this.pageStates.delete(pageId);

		// Close via relay → extension → chrome.tabs.remove.
		// The relay sends Target.detachedFromTarget to Playwright, which internally
		// cleans up the Page object and fires the 'close' event.
		if (this.relay) {
			await this.relay.closeTab(pageId);
		}
	}

	async focusPage(pageId: string): Promise<void> {
		const state = await this.ensurePage(pageId);
		await state.page.bringToFront();
	}

	async listPages(): Promise<PageInfo[]> {
		const result: PageInfo[] = [];
		for (const state of this.pageStates.values()) {
			// Refresh title/url
			try {
				state.info.title = await state.page.title();
				state.info.url = state.page.url();
			} catch {
				// page may have been closed externally
			}
			result.push({ ...state.info });
		}
		return result;
	}

	/**
	 * Two-tier model: listTabs() returns metadata from the relay cache (no debugger
	 * attachment). Falls back to listPages() when no relay is available.
	 */
	async listTabs(): Promise<PageInfo[]> {
		if (this.relay) {
			const tabs = (await this.relay.listTabs()).map((t) => ({
				id: t.id,
				title: t.title,
				url: t.url,
			}));
			log.debug('listTabs: relay returned', tabs.length, 'tabs');
			return tabs;
		}
		const pages = await this.listPages();
		log.debug('listTabs: fallback to listPages, returned', pages.length, 'pages');
		return pages;
	}

	/** Return the session IDs of all currently tracked pages. */
	async listTabSessionIds() {
		return await Promise.resolve(Array.from(this.pageStates.keys()));
	}

	/** Return IDs of all known tabs (relay cache + local pages). */
	async listTabIds(): Promise<string[]> {
		if (this.relay) {
			const tabs = await this.relay.listTabs();
			log.debug(`listTabIds: relay returned ${tabs.length} tab(s)`);
			return tabs.map((t) => t.id);
		}
		const ids = await this.listTabSessionIds();
		log.debug(`listTabIds: fallback to pageStates, ${ids.length} page(s)`);
		return ids;
	}

	// =========================================================================
	// Navigation
	// =========================================================================

	async navigate(
		pageId: string,
		url: string,
		waitUntil: 'load' | 'domcontentloaded' | 'networkidle' = 'domcontentloaded',
	): Promise<NavigateResult> {
		const { page } = await this.ensurePage(pageId);
		const response = await page.goto(url, { waitUntil });
		return {
			title: await page.title(),
			url: page.url(),
			status: response?.status() ?? 0,
		};
	}

	async back(
		pageId: string,
		waitUntil: 'load' | 'domcontentloaded' | 'networkidle' = 'domcontentloaded',
	): Promise<NavigateResult> {
		const { page } = await this.ensurePage(pageId);
		await page.goBack({ waitUntil });
		return { title: await page.title(), url: page.url(), status: 0 };
	}

	async forward(
		pageId: string,
		waitUntil: 'load' | 'domcontentloaded' | 'networkidle' = 'domcontentloaded',
	): Promise<NavigateResult> {
		const { page } = await this.ensurePage(pageId);
		await page.goForward({ waitUntil });
		return { title: await page.title(), url: page.url(), status: 0 };
	}

	async reload(
		pageId: string,
		waitUntil: 'load' | 'domcontentloaded' | 'networkidle' = 'domcontentloaded',
	): Promise<NavigateResult> {
		const { page } = await this.ensurePage(pageId);
		const response = await page.reload({ waitUntil });
		return {
			title: await page.title(),
			url: page.url(),
			status: response?.status() ?? 0,
		};
	}

	// =========================================================================
	// Interaction
	// =========================================================================

	async click(pageId: string, target: ElementTarget, options?: ClickOptions): Promise<void> {
		await this.ensurePage(pageId);
		const locator = await this.resolveLocator(pageId, target);
		await this.ensureActionable(locator, target, 'enabled');
		await locator.click({
			button: options?.button,
			clickCount: options?.clickCount,
			modifiers: options?.modifiers as Array<'Alt' | 'Control' | 'Meta' | 'Shift'>,
		});
	}

	async type(
		pageId: string,
		target: ElementTarget,
		text: string,
		options?: TypeOptions,
	): Promise<void> {
		await this.ensurePage(pageId);
		const locator = await this.resolveLocator(pageId, target);
		await this.ensureActionable(locator, target, 'editable');

		if (options?.mode === 'paste') {
			// Code editors mangle key-by-key entry: auto-close and auto-indent fire per keystroke.
			await locator.fill(text);
		} else {
			if (options?.clear) {
				await locator.clear();
			}

			await locator.pressSequentially(text, { delay: options?.delay });
		}

		if (options?.submit) {
			await locator.press('Enter');
		}
	}

	async select(pageId: string, target: ElementTarget, values: string[]): Promise<string[]> {
		await this.ensurePage(pageId);
		const locator = await this.resolveLocator(pageId, target);
		await this.ensureActionable(locator, target, 'enabled');
		return await locator.selectOption(values);
	}

	async hover(pageId: string, target: ElementTarget): Promise<void> {
		await this.ensurePage(pageId);
		const locator = await this.resolveLocator(pageId, target);
		await locator.hover();
	}

	async press(pageId: string, keys: string): Promise<void> {
		const { page } = await this.ensurePage(pageId);
		await page.keyboard.press(keys);
	}

	async drag(pageId: string, from: ElementTarget, to: ElementTarget): Promise<void> {
		await this.ensurePage(pageId);
		const fromLocator = await this.resolveLocator(pageId, from);
		const toLocator = await this.resolveLocator(pageId, to);
		await fromLocator.dragTo(toLocator);
	}

	async scroll(pageId: string, target?: ElementTarget, options?: ScrollOptions): Promise<void> {
		const { page } = await this.ensurePage(pageId);

		if (target) {
			const locator = await this.resolveLocator(pageId, target);
			await locator.scrollIntoViewIfNeeded();
		} else {
			const amount = options?.amount ?? 500;
			const delta = options?.direction === 'up' ? -amount : amount;
			await page.mouse.wheel(0, delta);
		}
	}

	async upload(pageId: string, target: ElementTarget | undefined, files: string[]): Promise<void> {
		const state = await this.ensurePage(pageId);

		// If a file chooser dialog is pending, use it directly
		if (state.pendingFileChooser) {
			await state.pendingFileChooser.setFiles(files);
			state.pendingFileChooser = undefined;
			return;
		}

		// Otherwise, set files on the input element
		if (!target) {
			throw new Error('No file chooser pending and no element target provided');
		}
		const locator = await this.resolveLocator(pageId, target);
		await locator.setInputFiles(files);
	}

	async dialog(pageId: string, action: 'accept' | 'dismiss', text?: string): Promise<string> {
		const state = await this.ensurePage(pageId);

		// If a dialog is already pending, handle it immediately
		if (state.pendingDialog) {
			const dialogType = state.pendingDialog.type();
			if (action === 'accept') {
				await state.pendingDialog.accept(text);
			} else {
				await state.pendingDialog.dismiss();
			}
			state.pendingDialog = undefined;
			return dialogType;
		}

		// Otherwise, arm a one-shot handler and wait
		return await new Promise<string>((resolve, reject) => {
			const timeout = setTimeout(() => {
				reject(new Error('No dialog appeared within 10 seconds'));
			}, 10_000);

			state.page.once('dialog', async (dlg: Dialog) => {
				clearTimeout(timeout);
				try {
					const dialogType = dlg.type();
					if (action === 'accept') {
						await dlg.accept(text);
					} else {
						await dlg.dismiss();
					}
					resolve(dialogType);
				} catch (error) {
					reject(toError(error));
				}
			});
		});
	}

	// =========================================================================
	// Inspection
	// =========================================================================

	async screenshot(
		pageId: string,
		target?: ElementTarget,
		options?: ScreenshotOptions,
	): Promise<string> {
		const { page } = await this.ensurePage(pageId);

		let buffer: Buffer;
		if (target) {
			const locator = await this.resolveLocator(pageId, target);
			buffer = await locator.screenshot({ type: 'png' });
		} else {
			buffer = await page.screenshot({
				type: 'png',
				fullPage: options?.fullPage,
			});
		}

		return buffer.toString('base64');
	}

	async snapshot(
		pageId: string,
		target?: ElementTarget,
		interactive = true,
	): Promise<SnapshotResult> {
		const { page } = await this.ensurePage(pageId);
		const mode = interactive ? 'ai' : 'default';

		// Use Playwright's public ariaSnapshot() API.
		// mode='ai' includes [ref=eN] annotations for interaction tools.
		let yaml: string;
		if (target) {
			const locator = await this.resolveLocator(pageId, target);
			// Scoped snapshots use ariaSnapshot() on the locator.
			yaml = await locator.ariaSnapshot({ mode });
		} else {
			yaml = await page.ariaSnapshot({ mode });
		}

		if (!yaml) {
			return { tree: '(empty page)', refCount: 0 };
		}

		// Count refs in the output (format: [ref=eN])
		const refMatches = yaml.match(/\[ref=e\d+\]/g);
		const refCount = refMatches?.length ?? 0;

		return { tree: yaml, refCount };
	}

	async probePageHtml(pageId: string): Promise<HtmlProbeResult> {
		try {
			const { page } = await this.ensurePage(pageId);
			const raw = await page.evaluate(HTML_PROBE_SCRIPT);
			return parseHtmlProbeResult(raw);
		} catch (error) {
			return { ok: false, error: error instanceof Error ? error.message : String(error) };
		}
	}

	async getText(pageId: string, target?: ElementTarget): Promise<string> {
		const { page } = await this.ensurePage(pageId);

		if (target) {
			const locator = await this.resolveLocator(pageId, target);
			return await locator.innerText();
		}

		return await page.innerText('body');
	}

	async evaluate(pageId: string, script: string): Promise<unknown> {
		const { page } = await this.ensurePage(pageId);
		return await page.evaluate(script);
	}

	async getConsole(pageId: string, level?: string, clear?: boolean): Promise<ConsoleEntry[]> {
		const state = await this.ensurePage(pageId);

		// Merge page errors into console entries as level: 'error'
		let entries: ConsoleEntry[] = [
			...state.consoleBuffer,
			...state.errorBuffer.map((e) => ({
				level: 'error',
				text: e.stack ? `${e.message}\n${e.stack}` : e.message,
				timestamp: e.timestamp,
			})),
		];

		// Sort by timestamp so console messages and page errors are interleaved correctly
		entries.sort((a, b) => a.timestamp - b.timestamp);

		if (level) {
			entries = entries.filter((e) => e.level === level);
		}

		if (clear) {
			state.consoleBuffer = [];
			state.errorBuffer = [];
		}

		return entries;
	}

	getConsoleSummary(pageId: string): { errors: number; warnings: number } {
		// Sync — only works for already-activated pages. Returns zeros for unactivated tabs.
		const state = this.pageStates.get(pageId);
		if (!state) return { errors: 0, warnings: 0 };
		const consoleErrors = state.consoleBuffer.filter((e) => e.level === 'error').length;
		const pageErrors = state.errorBuffer.length;
		const warnings = state.consoleBuffer.filter((e) => e.level === 'warning').length;
		return { errors: consoleErrors + pageErrors, warnings };
	}

	async pdf(
		pageId: string,
		options?: { format?: string; landscape?: boolean },
	): Promise<{ data: string; pages: number }> {
		const { page } = await this.ensurePage(pageId);
		const buffer = await page.pdf({
			format: (options?.format as 'A4' | 'Letter' | 'Legal') ?? 'A4',
			landscape: options?.landscape,
		});
		// Rough page count estimation (PDF doesn't easily expose page count)
		const data = buffer.toString('base64');
		return { data, pages: 1 };
	}

	async getNetwork(pageId: string, filter?: string, clear?: boolean): Promise<NetworkEntry[]> {
		const state = await this.ensurePage(pageId);
		let entries = [...state.networkBuffer];

		if (filter) {
			const pattern = this.globToRegex(filter);
			entries = entries.filter((e) => pattern.test(e.url));
		}

		if (clear) {
			state.networkBuffer = [];
		}

		return entries;
	}

	// =========================================================================
	// Wait
	// =========================================================================

	async wait(pageId: string, options: WaitOptions): Promise<number> {
		const { page } = await this.ensurePage(pageId);
		const start = Date.now();
		const timeout = options.timeoutMs ?? 30_000;

		const promises: Array<Promise<unknown>> = [];

		if (options.selector) {
			promises.push(page.waitForSelector(options.selector, { timeout }));
		}
		if (options.url) {
			promises.push(page.waitForURL(options.url, { timeout }));
		}
		if (options.loadState) {
			promises.push(page.waitForLoadState(options.loadState, { timeout }));
		}
		if (options.predicate) {
			promises.push(page.waitForFunction(options.predicate, undefined, { timeout }));
		}
		if (options.text) {
			promises.push(page.waitForSelector(`text=${options.text}`, { timeout }));
		}

		if (promises.length === 0) {
			// No conditions — just wait a beat
			await page.waitForTimeout(100);
		} else {
			await Promise.all(promises);
		}

		return Date.now() - start;
	}

	// =========================================================================
	// State
	// =========================================================================

	async getCookies(pageId: string, url?: string): Promise<Cookie[]> {
		await this.ensurePage(pageId);
		const context = this.requireContext();
		const cookies = url ? await context.cookies(url) : await context.cookies();

		return cookies.map((c) => ({
			name: c.name,
			value: c.value,
			domain: c.domain,
			path: c.path,
			expires: c.expires,
			httpOnly: c.httpOnly,
			secure: c.secure,
			sameSite: c.sameSite as Cookie['sameSite'],
		}));
	}

	async setCookies(pageId: string, cookies: Cookie[]): Promise<void> {
		await this.ensurePage(pageId);
		const context = this.requireContext();
		await context.addCookies(
			cookies.map((c) => ({
				name: c.name,
				value: c.value,
				domain: c.domain,
				path: c.path ?? '/',
				expires: c.expires,
				httpOnly: c.httpOnly,
				secure: c.secure,
				sameSite: c.sameSite,
			})),
		);
	}

	async clearCookies(pageId: string): Promise<void> {
		await this.ensurePage(pageId);
		await this.requireContext().clearCookies();
	}

	async getStorage(pageId: string, kind: 'local' | 'session'): Promise<Record<string, string>> {
		const { page } = await this.ensurePage(pageId);
		const storageObj = kind === 'local' ? 'localStorage' : 'sessionStorage';
		return await page.evaluate((s) => {
			const storage = s === 'localStorage' ? localStorage : sessionStorage;
			const result: Record<string, string> = {};
			for (let i = 0; i < storage.length; i++) {
				const key = storage.key(i);
				if (key !== null) result[key] = storage.getItem(key) ?? '';
			}
			return result;
		}, storageObj);
	}

	async setStorage(
		pageId: string,
		kind: 'local' | 'session',
		data: Record<string, string>,
	): Promise<void> {
		const { page } = await this.ensurePage(pageId);
		await page.evaluate(
			({ kind: k, data: d }) => {
				const storage = k === 'local' ? localStorage : sessionStorage;
				for (const [key, value] of Object.entries(d)) {
					storage.setItem(key, value);
				}
			},
			{ kind, data },
		);
	}

	async clearStorage(pageId: string, kind: 'local' | 'session'): Promise<void> {
		const { page } = await this.ensurePage(pageId);
		await page.evaluate((k) => {
			const storage = k === 'local' ? localStorage : sessionStorage;
			storage.clear();
		}, kind);
	}

	// =========================================================================
	// Post-action settling — waits for network/navigation to complete
	// Matches Playwright MCP's waitForCompletion() behavior.
	// =========================================================================

	async waitForCompletion<T>(pageId: string, action: () => Promise<T>): Promise<T> {
		// Guard: if the page doesn't exist yet (e.g. tab_open before creation),
		// just run the action without waiting for completion.
		if (!this.pageStates.has(pageId) && !this.relay?.hasTab(pageId)) {
			log.debug('waitForCompletion: page not found, running action directly:', pageId);
			return await action();
		}
		const { page } = await this.ensurePage(pageId);
		const requests: Request[] = [];

		const requestListener = (request: Request) => requests.push(request);
		page.on('request', requestListener);

		let result: T;
		try {
			result = await action();
			await page.waitForTimeout(500);
		} finally {
			page.off('request', requestListener);
		}

		// If any navigation request was made, wait for load state
		if (requests.some((r) => r.isNavigationRequest())) {
			await page
				.mainFrame()
				.waitForLoadState('load', { timeout: 10_000 })
				.catch(() => {});
			return result;
		}

		// Wait for resource requests to finish
		const resourceTypes = new Set(['document', 'stylesheet', 'script', 'xhr', 'fetch']);
		const promises = requests.map(async (r) => {
			if (resourceTypes.has(r.resourceType())) {
				const resp = await r.response().catch(() => undefined);
				await resp?.finished().catch(() => {});
				return;
			}
			await r.response().catch(() => {});
		});

		await Promise.race([
			Promise.all(promises),
			new Promise((resolve) => setTimeout(resolve, 5_000)),
		]);

		if (requests.length > 0) {
			await page.waitForTimeout(500);
		}

		return result;
	}

	// =========================================================================
	// Modal state — surfaces pending dialogs and file choosers
	// =========================================================================

	getModalStates(pageId: string): ModalState[] {
		// Sync — only works for already-activated pages. Returns empty for unactivated tabs.
		const state = this.pageStates.get(pageId);
		if (!state) return [];
		const modals: ModalState[] = [];

		if (state.pendingDialog) {
			modals.push({
				type: 'dialog',
				description: `JavaScript ${state.pendingDialog.type()} dialog: "${state.pendingDialog.message()}"`,
				clearedBy: 'browser_dialog',
				dialogType: state.pendingDialog.type() as ModalState['dialogType'],
				message: state.pendingDialog.message(),
			});
		}

		if (state.pendingFileChooser) {
			modals.push({
				type: 'filechooser',
				description: 'File chooser is open, waiting for file selection.',
				clearedBy: 'browser_upload',
			});
		}

		return modals;
	}

	// =========================================================================
	// Content extraction — raw HTML + URL for markdown conversion
	// =========================================================================

	async getContent(pageId: string, selector?: string): Promise<{ html: string; url: string }> {
		const { page } = await this.ensurePage(pageId);

		if (selector) {
			const locator = page.locator(selector);
			const html = await locator.evaluate((el) => el.outerHTML);
			return { html, url: page.url() };
		}

		return { html: await page.content(), url: page.url() };
	}

	// =========================================================================
	// Ref resolution — uses Playwright's built-in aria-ref selector engine
	// =========================================================================

	async resolveRef(pageId: string, ref: string): Promise<unknown> {
		const { page } = await this.ensurePage(pageId);
		const locator = page.locator(`aria-ref=${ref}`);

		// Verify the element exists
		try {
			const count = await locator.count();
			if (count === 0) throw new StaleRefError(ref);
		} catch (error) {
			if (error instanceof StaleRefError) throw error;
			throw new StaleRefError(ref);
		}

		return locator;
	}

	// =========================================================================
	// Private helpers
	// =========================================================================

	/**
	 * Refuse an action whose target is still unactionable once the grace window is
	 * up. Left alone, `click` and `select` wait out the full action timeout and
	 * then fail with a message that says nothing about why, and `type` reports a
	 * success it did not achieve.
	 */
	private async ensureActionable(
		locator: Locator,
		target: ElementTarget,
		need: Actionability,
	): Promise<void> {
		const deadline = Date.now() + ACTIONABLE_GRACE_MS;
		while (await this.readBlocked(locator, need)) {
			const remaining = deadline - Date.now();
			if (remaining <= 0) {
				const described = 'ref' in target ? target.ref : target.selector;
				throw new ElementNotActionableError(described, await this.blockedReason(locator));
			}
			await new Promise((resolve) => setTimeout(resolve, Math.min(ACTIONABLE_POLL_MS, remaining)));
		}
	}

	/**
	 * Uses Playwright's own predicates. They resolve `aria-ref` locators, which
	 * most other locator reads do not — the callers target elements from a
	 * snapshot. An unreadable state counts as fine, leaving the action to run.
	 */
	private async readBlocked(locator: Locator, need: Actionability): Promise<boolean> {
		const options = { timeout: ACTIONABLE_READ_TIMEOUT_MS };
		const blocked =
			need === 'editable'
				? locator.isEditable(options).then((editable) => !editable)
				: locator.isDisabled(options);
		return await blocked.catch((error: unknown) => {
			// Only the error type: Playwright embeds matched element HTML in some
			// messages, and this logger does not redact.
			log.warn('actionability check failed:', toError(error).name);
			return false;
		});
	}

	/**
	 * Only asked once the action is already refused, so the extra read costs
	 * nothing on the happy path. Unreadable defaults to `disabled` — the far more
	 * common cause, and the hint it picks is the more useful of the two.
	 */
	private async blockedReason(locator: Locator): Promise<UnactionableReason> {
		const disabled = await locator
			.isDisabled({ timeout: ACTIONABLE_READ_TIMEOUT_MS })
			.catch(() => true);
		return disabled ? 'disabled' : 'readonly';
	}

	private requireContext(): BrowserContext {
		if (!this.context) throw new Error('Browser context not initialized');
		return this.context;
	}

	private requirePage(pageId: string): PageState {
		const state = this.pageStates.get(pageId);
		if (!state) throw new PageNotFoundError(pageId);
		return state;
	}

	/**
	 * Lazy page activation: return the page if already tracked, otherwise
	 * tell the relay to activate the tab (attach debugger + emit
	 * Target.attachedToTarget) and wait for Playwright to create the Page.
	 */
	private async ensurePage(pageId: string): Promise<PageState> {
		const existing = this.pageStates.get(pageId);
		if (existing) {
			log.debug('ensurePage: page already tracked:', pageId);
			return existing;
		}

		if (!this.relay || !this.context) throw new PageNotFoundError(pageId);

		// Guard: don't attempt lazy activation for unknown/empty tab IDs
		if (!pageId || !this.relay.hasTab(pageId)) {
			throw new PageNotFoundError(pageId);
		}

		log.debug('ensurePage: activating tab', pageId, 'current pages:', [...this.pageStates.keys()]);

		const pagePromise = new Promise<Page>((resolve, reject) => {
			const timeout = setTimeout(() => {
				this.pendingActivation = undefined;
				log.error('ensurePage: timed out waiting for page event after activateTab:', pageId);
				reject(new Error(`Timed out waiting for page after activateTab (${pageId})`));
			}, 10_000);
			this.pendingActivation = {
				id: pageId,
				resolve: (page) => {
					clearTimeout(timeout);
					log.debug('ensurePage: page event received for', pageId);
					resolve(page);
				},
			};
		});

		log.debug('ensurePage: calling activateTab for', pageId);
		await this.relay.activateTab(pageId);
		log.debug('ensurePage: activateTab completed for', pageId, '— waiting for page event');

		const page = await pagePromise;

		// Wait for page to be ready
		log.debug('ensurePage: waiting for domcontentloaded on', pageId);
		await page.waitForLoadState('domcontentloaded', { timeout: 5_000 }).catch(() => {
			log.debug('ensurePage: domcontentloaded timeout (non-fatal) for', pageId);
		});

		log.debug('ensurePage: page ready:', pageId);
		// The context.on('page') listener should have tracked it with the right ID
		return this.pageStates.get(pageId) ?? this.trackPage(page, pageId);
	}

	private findPageState(page: Page): PageState | undefined {
		for (const state of this.pageStates.values()) {
			if (state.page === page) return state;
		}
		return undefined;
	}

	private trackPage(page: Page, explicitId?: string): PageState {
		const id = explicitId ?? generateId('page');
		log.debug('trackPage: id =', id, 'url =', page.url());
		const state: PageState = {
			page,
			info: { id, title: '', url: page.url() },
			consoleBuffer: [],
			errorBuffer: [],
			networkBuffer: [],
		};

		// Console listener
		page.on('console', (msg) => {
			state.consoleBuffer.push({
				level: msg.type(),
				text: msg.text(),
				timestamp: Date.now(),
			});
		});

		// Error listener
		page.on('pageerror', (error) => {
			state.errorBuffer.push({
				message: error.message,
				stack: error.stack,
				timestamp: Date.now(),
			});
		});

		// Network listeners
		page.on('response', (response: Response) => {
			state.networkBuffer.push({
				url: response.url(),
				method: response.request().method(),
				status: response.status(),
				contentType: response.headers()['content-type'],
				timestamp: Date.now(),
			});
		});

		// Dialog listener — capture pending dialogs
		page.on('dialog', (dlg: Dialog) => {
			state.pendingDialog = dlg;
		});

		// File chooser listener — capture pending file choosers
		page.on('filechooser', (chooser: FileChooser) => {
			state.pendingFileChooser = chooser;
		});

		// Clean up on page close — also clear relay activation so re-activation works
		page.on('close', () => {
			log.debug('page closed:', id);
			this.pageStates.delete(id);
			this.relay?.deactivateTab(id);
		});

		this.pageStates.set(id, state);
		return state;
	}

	/** Get the live URL for a tracked page (synchronous, from Playwright's page.url()). */
	getPageUrl(pageId: string): string | undefined {
		const state = this.pageStates.get(pageId);
		if (!state) return undefined;
		try {
			return state.page.url();
		} catch {
			return undefined;
		}
	}

	async getElementValue(pageId: string, target: ElementTarget): Promise<string> {
		const locator = await this.resolveLocator(pageId, target);
		try {
			return await locator.inputValue();
		} catch (error) {
			// Only fall back to reading text when the element is genuinely not an
			// <input>/<textarea>/<select>. If it's unreachable (timeout / detached /
			// not connected) rethrow the original error so the caller fails once,
			// promptly, instead of spending a second implicit timeout on innerText()
			// and surfacing a misleading "not an input" error on the secret path.
			const message = error instanceof Error ? error.message : String(error);
			const isWrongElementKind = /not an?\b.*(input|textarea|select)/i.test(message);
			if (!isWrongElementKind) throw error;
			return await locator.innerText();
		}
	}

	private async resolveLocator(pageId: string, target: ElementTarget): Promise<Locator> {
		if ('ref' in target) {
			return (await this.resolveRef(pageId, target.ref)) as Locator;
		}
		const { page } = this.requirePage(pageId);
		return page.locator(target.selector);
	}

	private globToRegex(pattern: string): RegExp {
		const escaped = pattern
			.replace(/[.+^${}()|[\]\\]/g, '\\$&')
			.replace(/\*\*/g, '.*')
			.replace(/\*/g, '[^/]*')
			.replace(/\?/g, '.');
		return new RegExp(`^${escaped}$`);
	}
}
