import { expect, type Page } from '@playwright/test';
import { test } from '../fixtures/ids';
import { CleanupRegistry } from '../fixtures/cleanup';
import { adminApi } from '../fixtures/api';
import { loginAsUserToken, pageWithUserToken } from '../fixtures/role-page';

const MEMBER_PW = 'e2e-member-pw';

async function seedAppScriptAndMember(opts: {
	slug: string;
	username: string;
	role: 'viewer' | 'editor';
}): Promise<{ scriptId: string; userId: string }> {
	const api = await adminApi();
	try {
		const appRes = await api.post('/api/v1/admin/apps', {
			data: { slug: opts.slug, name: opts.slug }
		});
		expect(appRes.ok()).toBe(true);
		const appId = ((await appRes.json()) as { id: string }).id;
		const scriptRes = await api.post('/api/v1/admin/scripts', {
			data: { app_id: appId, name: `${opts.slug}-sc`, source: HELLO_RHAI }
		});
		expect(scriptRes.ok()).toBe(true);
		const scriptId = ((await scriptRes.json()) as { id: string }).id;
		const userRes = await api.post('/api/v1/admin/admins', {
			data: { username: opts.username, password: MEMBER_PW, instance_role: 'member' }
		});
		expect(userRes.ok()).toBe(true);
		const userId = ((await userRes.json()) as { id: string }).id;
		const memberRes = await api.post(`/api/v1/admin/apps/${opts.slug}/members`, {
			data: { user_id: userId, role: opts.role }
		});
		expect(memberRes.ok()).toBe(true);
		return { scriptId, userId };
	} finally {
		await api.dispose();
	}
}

// Phase B3 — Scripts CRUD + Editor. The script editor lives at
// /admin/scripts/{id}. Setup uses the API to create the app (and
// sometimes a baseline script) so each test can focus on the editor
// flow it actually covers.

const HELLO_RHAI = `return #{ statusCode: 200, body: #{ ok: true } };`;

const cleanup = new CleanupRegistry();
test.afterEach(async () => {
	await cleanup.run();
});

async function createAppViaApi(slug: string): Promise<string> {
	const api = await adminApi();
	try {
		const res = await api.post('/api/v1/admin/apps', {
			data: { slug, name: slug }
		});
		expect(res.ok()).toBe(true);
		const body = (await res.json()) as { id: string };
		return body.id;
	} finally {
		await api.dispose();
	}
}

async function createScriptViaApi(
	appId: string,
	name: string,
	source = HELLO_RHAI
): Promise<string> {
	const api = await adminApi();
	try {
		const res = await api.post('/api/v1/admin/scripts', {
			data: { app_id: appId, name, source }
		});
		expect(res.ok()).toBe(true);
		const body = (await res.json()) as { id: string };
		return body.id;
	} finally {
		await api.dispose();
	}
}

async function fillCodeMirror(page: Page, locator: string, text: string): Promise<void> {
	const cm = page.locator(locator).first();
	await cm.click();
	await page.keyboard.press('ControlOrMeta+A');
	await page.keyboard.press('Delete');
	await page.keyboard.type(text);
}

test.describe('B3 scripts CRUD', () => {
	test('create script via UI navigates to scripts list with the new entry', async ({
		page,
		uniqueSlug
	}) => {
		const slug = uniqueSlug('cscr');
		await createAppViaApi(slug);
		cleanup.app(slug);

		await page.goto(`/admin/apps/${slug}`);
		await page.getByRole('button', { name: /^New script$/ }).click();
		await page.getByLabel('Name').fill('echo');
		// The CodeMirror editor starts empty in create mode; type a
		// minimal valid script.
		await fillCodeMirror(page, '.cm-content', HELLO_RHAI);
		await page.getByRole('button', { name: 'Create script' }).click();

		await expect(page.getByRole('link', { name: /echo/i })).toBeVisible();
	});

	test('edit + save Rhai source persists across reload', async ({ page, uniqueSlug }) => {
		const slug = uniqueSlug('edit');
		const appId = await createAppViaApi(slug);
		const scriptId = await createScriptViaApi(appId, 'edit-target');
		cleanup.app(slug);

		await page.goto(`/admin/scripts/${scriptId}`);
		await expect(page.locator('.cm-content').first()).toContainText('statusCode');

		const updated = `// edited by e2e\nreturn #{ statusCode: 201, body: #{ edited: true } };`;
		await fillCodeMirror(page, '.cm-content', updated);
		await page.getByRole('button', { name: /^Save$/ }).click();
		// Save button becomes disabled once the buffer matches the
		// just-saved source — that's our settle signal.
		await expect(page.getByRole('button', { name: /^Save$/ })).toBeDisabled();

		await page.reload();
		await expect(page.locator('.cm-content').first()).toContainText('edited by e2e');
	});

	test('invalid Rhai source: Format shows a parse error', async ({ page, uniqueSlug }) => {
		const slug = uniqueSlug('invrhai');
		const appId = await createAppViaApi(slug);
		const scriptId = await createScriptViaApi(appId, 'bad-syntax');
		cleanup.app(slug);

		await page.goto(`/admin/scripts/${scriptId}`);
		await fillCodeMirror(page, '.cm-content', 'this is not rhai @@@ {{{');
		await page
			.locator('.editor-header')
			.getByRole('button', { name: 'Format' })
			.click();

		await expect(page.locator('.error.inline').first()).toBeVisible();
	});
});

test.describe('B3 test-invoke', () => {
	test('valid JSON body returns status + body in the result panel', async ({
		page,
		uniqueSlug
	}) => {
		const slug = uniqueSlug('inv-ok');
		const appId = await createAppViaApi(slug);
		const scriptId = await createScriptViaApi(appId, 'invoke-ok');
		cleanup.app(slug);

		await page.goto(`/admin/scripts/${scriptId}`);
		// Body editor is the second .cm-content (source is first).
		const bodyEditor = page.locator('.cm-content').nth(1);
		await bodyEditor.click();
		await page.keyboard.press('ControlOrMeta+A');
		await page.keyboard.press('Delete');
		await page.keyboard.type('{"hello":"world"}');

		await page.getByRole('button', { name: /^Send$/ }).click();
		await expect(page.locator('.status')).toContainText('HTTP 200');
		await expect(page.locator('.result pre')).toContainText('ok');
	});

	test('malformed JSON body: Format surfaces the parse error', async ({ page, uniqueSlug }) => {
		const slug = uniqueSlug('inv-bad');
		const appId = await createAppViaApi(slug);
		const scriptId = await createScriptViaApi(appId, 'invoke-bad');
		cleanup.app(slug);

		await page.goto(`/admin/scripts/${scriptId}`);
		const bodyEditor = page.locator('.cm-content').nth(1);
		await bodyEditor.click();
		await page.keyboard.press('ControlOrMeta+A');
		await page.keyboard.press('Delete');
		await page.keyboard.type('{not valid json,');

		// The Format button for the request body sits inside the
		// Test-invoke card next to the body editor.
		await page
			.locator('.json-block')
			.first()
			.getByRole('button', { name: 'Format' })
			.click();
		await expect(page.locator('.error.inline').first()).toBeVisible();
	});
});

test.describe('B3 settings', () => {
	test('timeout input rejects zero and non-positive values', async ({ page, uniqueSlug }) => {
		const slug = uniqueSlug('settz');
		const appId = await createAppViaApi(slug);
		const scriptId = await createScriptViaApi(appId, 'settings-target');
		cleanup.app(slug);

		await page.goto(`/admin/scripts/${scriptId}`);
		await page.getByRole('button', { name: 'Settings' }).click();
		const timeout = page.getByLabel(/Timeout/);
		await timeout.fill('0');
		const invalid = await timeout.evaluate((el: HTMLInputElement) => !el.validity.valid);
		expect(invalid).toBe(true);
	});
});

test.describe('B3 scripts role shadowing', () => {
	test('viewer: no Delete header, no Save/Format on Edit, no Add route on Routing', async ({
		browser,
		uniqueSlug,
		uniqueUsername
	}) => {
		const slug = uniqueSlug('vscr');
		const username = uniqueUsername('viewer');
		const { scriptId, userId } = await seedAppScriptAndMember({
			slug,
			username,
			role: 'viewer'
		});
		cleanup.app(slug);
		cleanup.adminUser(userId);

		const token = await loginAsUserToken(username, MEMBER_PW);
		const page = await pageWithUserToken(browser, token);
		try {
			await page.goto(`/admin/scripts/${scriptId}`);
			// Header Delete is hidden for non-admins.
			await expect(page.getByRole('button', { name: /^Delete$/ })).toHaveCount(0);
			// Save/Format on the Edit tab are hidden for viewers.
			await expect(page.getByRole('button', { name: /^Save$/ })).toHaveCount(0);
			await expect(
				page.locator('.editor-header').getByRole('button', { name: 'Format' })
			).toHaveCount(0);
			// Test invoke is still visible (everyone with read access).
			await expect(page.getByRole('button', { name: /^Send$/ })).toBeVisible();
			// Routing tab loads, no +Add route.
			await page.getByRole('button', { name: /Routing/ }).click();
			await expect(page.getByRole('button', { name: /\+ Add route/ })).toHaveCount(0);
			// Settings tab is absent for non-admins.
			await expect(page.getByRole('button', { name: /^Settings$/ })).toHaveCount(0);
		} finally {
			await page.context().close();
		}
	});

	test('viewer: CodeMirror is read-only', async ({
		browser,
		uniqueSlug,
		uniqueUsername
	}) => {
		const slug = uniqueSlug('vro');
		const username = uniqueUsername('viewer');
		const { scriptId, userId } = await seedAppScriptAndMember({
			slug,
			username,
			role: 'viewer'
		});
		cleanup.app(slug);
		cleanup.adminUser(userId);

		const token = await loginAsUserToken(username, MEMBER_PW);
		const page = await pageWithUserToken(browser, token);
		try {
			await page.goto(`/admin/scripts/${scriptId}`);
			const cm = page.locator('.cm-content').first();
			await expect(cm).toBeVisible();
			// CodeMirror sets contenteditable=false when EditorView.editable.of(false)
			// is in effect; that's the canonical signal for read-only mode.
			await expect(cm).toHaveAttribute('contenteditable', 'false');
		} finally {
			await page.context().close();
		}
	});

	test('editor: Save visible, Delete header hidden', async ({
		browser,
		uniqueSlug,
		uniqueUsername
	}) => {
		const slug = uniqueSlug('escr');
		const username = uniqueUsername('editor');
		const { scriptId, userId } = await seedAppScriptAndMember({
			slug,
			username,
			role: 'editor'
		});
		cleanup.app(slug);
		cleanup.adminUser(userId);

		const token = await loginAsUserToken(username, MEMBER_PW);
		const page = await pageWithUserToken(browser, token);
		try {
			await page.goto(`/admin/scripts/${scriptId}`);
			// Editor sees Save (disabled until the buffer changes — that's fine).
			await expect(page.getByRole('button', { name: /^Save$/ })).toBeVisible();
			// Delete stays admin-only.
			await expect(page.getByRole('button', { name: /^Delete$/ })).toHaveCount(0);
			// Settings stays admin-only.
			await expect(page.getByRole('button', { name: /^Settings$/ })).toHaveCount(0);
		} finally {
			await page.context().close();
		}
	});
});

test.describe('B3 adversarial', () => {
	test('infinite loop script hits the sandbox timeout', async ({ page, uniqueSlug }) => {
		const slug = uniqueSlug('loop');
		const appId = await createAppViaApi(slug);
		const scriptId = await createScriptViaApi(
			appId,
			'inf-loop',
			'loop { let x = 1; }'
		);
		cleanup.app(slug);

		await page.goto(`/admin/scripts/${scriptId}`);
		await page.getByRole('button', { name: /^Send$/ }).click();

		// Either the status renders with a 5xx code, or an error
		// banner shows up. Either way, the page recovers.
		await Promise.race([
			expect(page.locator('.status')).toBeVisible({ timeout: 30_000 }),
			expect(page.locator('.error.inline').last()).toBeVisible({ timeout: 30_000 })
		]);

		// The dashboard must remain interactive after the timeout.
		await page.getByRole('button', { name: 'Settings' }).click();
		await expect(page.getByLabel(/Timeout/)).toBeVisible();
	});
});
