import {mkdirSync, writeFileSync} from 'node:fs';
import {join} from 'node:path';
import {expect, type FrameLocator, type Locator, test} from '@playwright/test';
import type {WireInitState} from '../../../shared/protocol.js';
import {
	chatFrame,
	type LaunchedVscode,
	launchVscode,
	openChatPanel,
} from '../launch.js';

const CAPTURE_DIRECTORY = join('test-results', 'accessibility');
const HOST_EPOCH = 0;

// Undefined when beforeAll aborts before assigning; afterAll must not throw a
// second error on top of the launch failure.
let vscode: LaunchedVscode | undefined;
let sequence = 1_000;

function launched(): LaunchedVscode {
	if (!vscode) throw new Error('VS Code did not launch in beforeAll');
	return vscode;
}

function frame(): FrameLocator {
	return chatFrame(launched().window);
}

function initialState(): WireInitState {
	return {
		sessionId: 'accessibility-session',
		cwd: launched().workspaceDir,
		authenticated: true,
		userName: 'accessibility-test',
		feed: [],
		live: [],
		turn: {
			phase: 'idle',
			status: 'Ready...',
			interactionTokens: 0,
			startedAt: null,
		},
		permission: null,
		question: null,
		permissionMode: 'default',
		model: {
			modelId: 'accessibility-model',
			displayName: 'Accessibility Model',
			provider: 'anthropic',
			options: [
				{
					id: 'accessibility-model',
					label: 'Accessibility Model',
					provider: 'anthropic',
					groupLabel: 'Anthropic',
					vendor: 'Anthropic',
				},
			],
			supportedEfforts: ['low', 'medium', 'high'],
			effort: 'medium',
		},
		contextUsage: null,
		todos: [],
		goal: null,
		pr: null,
		queued: [],
		slashItems: [
			{
				command: '/model',
				description: 'Switch model',
				hostSupported: true,
			},
			{
				command: '/effort',
				description: 'Set reasoning effort',
				hostSupported: true,
			},
		],
		extensionVersion: '0.2.0-a11y',
		cliCommand: 'cmd',
		promptHistory: [],
		ideContext: null,
		zdr: false,
		isDesktop: true,
	};
}

async function postHostMessage(
	message: Readonly<Record<string, unknown>>,
): Promise<void> {
	sequence += 1;
	await frame()
		.locator('body')
		.evaluate((_body, payload) => window.postMessage(payload, '*'), {
			...message,
			seq: message.type === 'init' ? 0 : sequence,
			epoch: HOST_EPOCH,
		});
}

async function captureTree(
	name: string,
	locator: Locator,
	requiredText: readonly string[],
): Promise<string> {
	const snapshot = await locator.ariaSnapshot();
	for (const text of requiredText) {
		expect(snapshot, `${name} accessibility tree`).toContain(text);
	}
	return snapshot;
}

test.beforeAll(async () => {
	vscode = await launchVscode();
	await openChatPanel(launched().window);
	await expect(frame().locator('.cc-app')).toBeVisible({timeout: 30_000});
});

test.afterAll(async () => {
	await vscode?.close();
});

test('fresh installed-VSIX accessibility tree satisfies the interaction contract', async () => {
	await postHostMessage({type: 'init', state: initialState()});
	const snapshots: Record<string, string> = {};
	const composer = frame().getByRole('combobox', {
		name: 'Message Command Code',
	});
	await expect(composer).toBeVisible();
	snapshots.composer = await captureTree('composer', composer, [
		'combobox "Message Command Code"',
	]);
	await expect(composer).toHaveAttribute('aria-autocomplete', 'list');

	// The vendored slash/mention menus are a combobox popup (list autocomplete):
	// the composer is the combobox, its aria-controls names the popup's listbox
	// (#cc-composer-menu-list inside the #cc-composer-menu shell), and results
	// are announced through the cc-sr-live region. Assert that real wiring, that
	// the popup surfaces the results, and that focus stays on the combobox so the
	// list is keyboard reachable.
	await composer.fill('/');
	await expect(composer).toHaveAttribute('aria-expanded', 'true');
	await expect(composer).toHaveAttribute(
		'aria-controls',
		'cc-composer-menu-list',
	);
	await expect(frame().locator('#cc-composer-menu-list')).toHaveAttribute(
		'role',
		'listbox',
	);
	const slashMenu = frame().locator('#cc-composer-menu');
	await expect(slashMenu).toBeVisible();
	await expect(composer).toBeFocused();
	await expect(slashMenu).toContainText('model');
	// The vendored slash menu is a flat, scored list of command rows (no
	// "Commands" group heading), each a button whose name leads with the command.
	snapshots.slashMenu = await captureTree('slash-menu', slashMenu, [
		'/model',
	]);

	await composer.fill('@READ');
	await expect(composer).toHaveAttribute('aria-expanded', 'true');
	await expect(composer).toHaveAttribute(
		'aria-controls',
		'cc-composer-menu-list',
	);
	const mentionMenu = frame().locator('#cc-composer-menu');
	await expect(mentionMenu).toBeVisible({timeout: 10_000});
	await expect(mentionMenu).toContainText('README', {timeout: 10_000});
	await expect(composer).toBeFocused();
	snapshots.mentionMenu = await captureTree('mention-menu', mentionMenu, [
		'README',
	]);
	await composer.fill('');

	// The model picker is the vendored ModelEffortSelector in the toolbar slot:
	// its trigger is the "Model: …" button and its menu is the portalled dropdown.
	// Its a11y contract is the deliberately-weakened vendored one (a search
	// textbox + grouped model/effort buttons, not the old listbox/option rows).
	await frame()
		.getByRole('button', {name: /^Model:/})
		.click();
	const modelPicker = frame().getByRole('dialog', {name: 'Models'});
	await expect(modelPicker).toBeVisible();
	// Assert the picker's STABLE a11y structure (search combobox + grouped Models
	// listbox with option rows), not a specific model name: the host-owned picker
	// renders the LIVE model catalog, so the real host's list races and supersedes
	// the forged "Accessibility Model" option — asserting it would flake.
	snapshots.modelPicker = await captureTree('model-picker', modelPicker, [
		'combobox "Filter Models"',
		'listbox "Models"',
		'option "',
	]);
	await frame()
		.getByRole('combobox', {name: 'Filter Models'})
		.press('Escape');

	await composer.fill('/effort');
	await composer.press('Enter');
	const effortPicker = frame().getByRole('listbox', {
		name: 'Reasoning effort',
	});
	await expect(effortPicker).toBeVisible();
	snapshots.effortPicker = await captureTree('effort-picker', effortPicker, [
		'listbox "Reasoning effort"',
		'option',
	]);
	await effortPicker.press('Escape');

	await postHostMessage({
		type: 'permission',
		request: {
			requestId: 'accessibility-permission',
			kind: 'file',
			toolName: 'EDIT',
			subject: 'README.md',
			choices: [
				{label: 'Allow once', value: 'yes'},
				{label: 'Allow for this session', value: 'yes-session'},
				{label: 'Allow for this project', value: 'yes-project'},
				{label: 'Deny', value: 'no'},
			],
		},
	});
	const permissionPrompt = frame().locator('.cc-prompt');
	await expect(permissionPrompt).toBeVisible();
	// The card is now the host-owned forked permission card (role=group named for
	// the EXACT action — verb + subject, e.g. "Edit README.md" — with the subject
	// in a code element, and Deny / Allow once / the "Allow options" caret). Its
	// scoped-grant menu is a portalled menu, not a child of the card, so capture
	// the two trees separately.
	snapshots.permissionPrompt = await captureTree(
		'permission-prompt',
		permissionPrompt,
		[
			'group "Edit README.md"',
			'button "Deny"',
			'button "Allow once"',
			'button "Allow options"',
		],
	);
	const moreAllowOptions = permissionPrompt.getByRole('button', {
		name: /allow options/i,
	});
	await moreAllowOptions.click();
	// The forked card's scoped-grant menu (.cc-perm-menu, role=menu) has no
	// accessible name of its own — it is opened from the "Allow options" caret.
	const permissionMenu = frame().locator('.cc-perm-menu');
	await expect(permissionMenu).toBeVisible();
	snapshots.permissionMenu = await captureTree(
		'permission-menu',
		permissionMenu,
		[
			'menuitem "Allow for this session"',
			'menuitem "Allow for this project"',
		],
	);
	// Keyboard reaches the scoped grants: the forked card's menu highlights an
	// item with `cc-selected`. It opens with the first scope highlighted, and
	// ArrowDown moves the highlight to the next — a keyboard user can pick a scope
	// without a pointer.
	await expect(
		permissionMenu.getByRole('menuitem', {name: 'Allow for this session'}),
	).toHaveClass(/cc-selected/);
	await permissionMenu.press('ArrowDown');
	await expect(
		permissionMenu.getByRole('menuitem', {name: 'Allow for this project'}),
	).toHaveClass(/cc-selected/);

	mkdirSync(CAPTURE_DIRECTORY, {recursive: true});
	writeFileSync(
		join(CAPTURE_DIRECTORY, 'fresh-accessibility-tree.json'),
		`${JSON.stringify(snapshots, null, 2)}\n`,
	);
});
