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 seedAppAndMember(opts: {
	slug: string;
	username: string;
	role: 'viewer' | 'editor' | 'app_admin';
}): Promise<{ appId: 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 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 { appId, userId };
	} finally {
		await api.dispose();
	}
}

// Phase B2 — Apps Lifecycle. Create, view, edit, delete, plus the
// historical-slug takeover flow and adversarial inputs.

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

function failOnDialog(page: Page): void {
	page.on('dialog', async (dialog) => {
		await dialog.dismiss();
		throw new Error(`Unexpected browser dialog fired: ${dialog.type()} — "${dialog.message()}"`);
	});
}

async function openCreateForm(page: Page): Promise<void> {
	await page.goto('/admin/apps');
	await page.getByRole('button', { name: 'New app' }).click();
}

async function createApp(
	page: Page,
	opts: { name: string; slug: string; description?: string }
): Promise<void> {
	await openCreateForm(page);
	await page.getByLabel('Name').fill(opts.name);
	// Clear the auto-derived slug and type the test-controlled one so
	// we know exactly which slug we'll register for cleanup.
	const slugInput = page.getByLabel('Slug');
	await slugInput.fill('');
	await slugInput.fill(opts.slug);
	if (opts.description !== undefined) {
		await page.getByLabel('Description').fill(opts.description);
	}
	await page.getByRole('button', { name: 'Create app' }).click();
}

test.describe('B2 apps lifecycle', () => {
	test('create app: slug auto-derives from name, app appears in list', async ({
		page,
		uniqueSlug
	}) => {
		const slug = uniqueSlug('lifecycle');
		const displayName = slug.replace(/-/g, ' ');

		await openCreateForm(page);
		await page.getByLabel('Name').fill(displayName);
		// Slug auto-derives — the input value is set, no extra typing.
		const slugInput = page.getByLabel('Slug');
		await expect(slugInput).toHaveValue(slug);
		await page.getByRole('button', { name: 'Create app' }).click();
		cleanup.app(slug);

		await expect(page.getByRole('link', { name: new RegExp(displayName) })).toBeVisible();
	});

	test('edit name + description in settings persists across reload', async ({
		page,
		uniqueSlug
	}) => {
		const slug = uniqueSlug('edit');
		await createApp(page, { name: slug, slug });
		cleanup.app(slug);

		await page.getByRole('link', { name: new RegExp(slug) }).click();
		await expect(page).toHaveURL(new RegExp(`/admin/apps/${slug}$`));
		await page.getByRole('link', { name: 'Settings' }).click();

		const newName = `${slug} renamed`;
		const newDesc = 'updated description';
		await page.getByLabel('Name').fill(newName);
		await page.getByLabel('Description').fill(newDesc);
		await page.getByRole('button', { name: 'Save changes' }).click();
		// Wait for the network round-trip to settle — the busy label
		// flips back to "Save changes" when done.
		await expect(page.getByRole('button', { name: 'Save changes' })).toBeEnabled();

		await page.reload();
		await page.getByRole('link', { name: 'Settings' }).click();
		await expect(page.getByLabel('Name')).toHaveValue(newName);
		await expect(page.getByLabel('Description')).toHaveValue(newDesc);
	});

	test('delete: wrong phrase keeps button disabled, right phrase removes app', async ({
		page,
		uniqueSlug
	}) => {
		const slug = uniqueSlug('delete');
		await createApp(page, { name: slug, slug });
		cleanup.app(slug); // belt-and-braces; cleanup is best-effort

		await page.getByRole('link', { name: new RegExp(slug) }).click();
		await page.getByRole('link', { name: 'Settings' }).click();
		await page.getByRole('button', { name: 'Delete app' }).click();

		const dialog = page.getByRole('dialog');
		await expect(dialog).toBeVisible();
		const phraseInput = dialog.getByRole('textbox');
		const confirmBtn = dialog.getByRole('button', { name: 'Delete app' });
		await expect(confirmBtn).toBeDisabled();

		await phraseInput.fill('wrong-phrase');
		await expect(confirmBtn).toBeDisabled();

		await phraseInput.fill(slug);
		await expect(confirmBtn).toBeEnabled();
		await confirmBtn.click();

		await expect(page).toHaveURL(/\/admin\/apps$/);
		await expect(page.getByRole('link', { name: new RegExp(slug) })).toHaveCount(0);
	});

	test('historical slug warning surfaces; force-takeover succeeds', async ({
		page,
		uniqueSlug
	}) => {
		const origSlug = uniqueSlug('hist');
		const renamedSlug = `${origSlug}-r`;

		// Historical-redirect rows are created on RENAME, not on
		// delete. So: create app, rename it, original slug now lives
		// in app_slug_history.
		const api = await adminApi();
		try {
			const created = await api.post('/api/v1/admin/apps', {
				data: { slug: origSlug, name: origSlug }
			});
			expect(created.ok()).toBe(true);
			const renamed = await api.patch(
				`/api/v1/admin/apps/${encodeURIComponent(origSlug)}`,
				{ data: { slug: renamedSlug } }
			);
			expect(renamed.ok()).toBe(true);
		} finally {
			await api.dispose();
		}
		cleanup.app(renamedSlug); // the renamed app still exists

		await openCreateForm(page);
		await page.getByLabel('Name').fill(origSlug);
		await page.getByLabel('Slug').fill('');
		await page.getByLabel('Slug').fill(origSlug);
		await page.getByRole('button', { name: 'Create app' }).click();

		await expect(page.locator('.warning')).toBeVisible();
		await expect(page.locator('.warning')).toContainText(/previously redirected/i);
		await page.getByRole('button', { name: /claim slug anyway/i }).click();
		cleanup.app(origSlug); // the takeover created a new app

		await expect(page.getByRole('link', { name: new RegExp(origSlug) })).toBeVisible();
	});
});

test.describe('B2 apps adversarial', () => {
	test('slug with uppercase + spaces is normalized in-place', async ({ page, uniqueSlug }) => {
		const base = uniqueSlug('norm');
		await openCreateForm(page);
		await page.getByLabel('Name').fill(base);
		const slugInput = page.getByLabel('Slug');
		await slugInput.fill('');
		// Simulate the user typing/pasting an invalid slug. The
		// oninput handler runs slugify() and rewrites the input value.
		await slugInput.fill(`  Hello WORLD  ${base}!`);
		await expect(slugInput).toHaveValue(`hello-world-${base}`);
	});

	test('xss in name and description renders as text everywhere', async ({ page, uniqueSlug }) => {
		failOnDialog(page);
		const slug = uniqueSlug('xss');
		const payload = '<img src=x onerror=alert(1)><script>window.__xss=true;</script>';

		await createApp(page, { name: payload, slug, description: payload });
		cleanup.app(slug);

		// List page — the link's accessible name contains the literal
		// payload text, not the parsed HTML.
		await expect(page.getByRole('link', { name: new RegExp('img src=x') })).toBeVisible();

		// Detail page — open it; payload renders in the breadcrumb /
		// header as text only.
		await page.goto(`/admin/apps/${slug}`);
		const xssRan = await page.evaluate(
			() => (window as unknown as { __xss?: boolean }).__xss === true
		);
		expect(xssRan).toBe(false);
		expect(await page.locator('script:has-text("__xss")').count()).toBe(0);
	});

	test('very long name does not crash the dashboard', async ({ page, uniqueSlug }) => {
		// The backend currently has no name length cap; the dashboard
		// just needs to keep rendering when handed an unusually long
		// value. Guards against layout / locator regressions when a
		// future test or user creates an oversized app.
		const slug = uniqueSlug('long');
		const longName = 'A'.repeat(10_000);

		await openCreateForm(page);
		await page.getByLabel('Name').fill(longName);
		await page.getByLabel('Slug').fill('');
		await page.getByLabel('Slug').fill(slug);
		await page.getByRole('button', { name: 'Create app' }).click();

		const errorVisible = await page
			.locator('.create-form .error')
			.isVisible()
			.catch(() => false);

		if (errorVisible) {
			// Server rejected — fine, no cleanup needed.
			await expect(page.getByRole('link', { name: new RegExp(slug) })).toHaveCount(0);
			return;
		}

		// Server accepted — confirm the dashboard still renders and is
		// navigable. Detail page must load too.
		cleanup.app(slug);
		await expect(page.getByRole('link', { name: new RegExp(slug) })).toBeVisible();
		await page.goto(`/admin/apps/${slug}`);
		await expect(page.getByRole('link', { name: 'Settings' })).toBeVisible();
	});
});

test.describe('B2 apps role shadowing', () => {
	test('viewer member sees no "New app" on the apps list', async ({
		browser,
		uniqueSlug,
		uniqueUsername
	}) => {
		const slug = uniqueSlug('vlist');
		const username = uniqueUsername('viewer');
		const { userId } = await seedAppAndMember({ 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/apps');
			// Member can see the apps list (just the one they belong to)
			// but the create-app affordance is hidden.
			await expect(page.getByRole('link', { name: new RegExp(slug) })).toBeVisible();
			await expect(page.getByRole('button', { name: /^New app$/ })).toHaveCount(0);
		} finally {
			await page.context().close();
		}
	});

	test('viewer sees no Add domain form and no Settings tab on app detail', async ({
		browser,
		uniqueSlug,
		uniqueUsername
	}) => {
		const slug = uniqueSlug('vdom');
		const username = uniqueUsername('viewer');
		const { userId } = await seedAppAndMember({ 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/apps/${slug}`);
			await expect(
				page.getByRole('link', { name: 'Scripts' })
			).toBeVisible();
			// Settings tab is absent.
			await expect(page.getByRole('link', { name: 'Settings' })).toHaveCount(0);
			// Domains tab still listable, but no Add-domain submit.
			await page.getByRole('link', { name: 'Domains' }).click();
			await expect(page.getByRole('button', { name: /^Add domain$/ })).toHaveCount(0);
		} finally {
			await page.context().close();
		}
	});

	test('editor sees New script but no Settings tab', async ({
		browser,
		uniqueSlug,
		uniqueUsername
	}) => {
		const slug = uniqueSlug('edit');
		const username = uniqueUsername('editor');
		const { userId } = await seedAppAndMember({ 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/apps/${slug}`);
			await expect(page.getByRole('button', { name: /^New script$/ })).toBeVisible();
			await expect(page.getByRole('link', { name: 'Settings' })).toHaveCount(0);
			await expect(
				page.getByRole('link', { name: 'Members' })
			).toHaveCount(0);
		} finally {
			await page.context().close();
		}
	});
});
