import { mkdir } from 'node:fs/promises'
import { setTimeout as delay } from 'node:timers/promises'
import { type BrowserContext, chromium, type Page } from 'playwright'

const env = (name: string, fallback: string) => process.env[name] || fallback
const appUrl = env('E2E_APP_URL', 'http://127.0.0.1:8000').replace(/\/$/, '')
const appUpstream = env('E2E_APP_UPSTREAM', 'http://main-app:8000').replace(/\/$/, '')
const hostingUrl = env('E2E_HOSTING_URL', 'http://hosting-service:3001').replace(/\/$/, '')
const hostingBrowserUrl = env('E2E_HOSTING_BROWSER_URL', 'http://127.0.0.1:3001').replace(/\/$/, '')
const pdsBrowserUrl = env('E2E_PDS_BROWSER_URL', 'http://127.0.0.1:3300').replace(/\/$/, '')
const pdsUpstream = env('E2E_PDS_UPSTREAM', 'http://pds:3300').replace(/\/$/, '')
const atprotoPassword = env('E2E_ATPROTO_PASSWORD', 'alice-dev-password')
const timeoutMs = Number.parseInt(env('E2E_TIMEOUT_MS', '180000'), 10)
const artifactsDir = env('E2E_ARTIFACTS_DIR', '/artifacts')
const chromiumExecutable = process.env.E2E_CHROMIUM_EXECUTABLE || undefined

type Auth = { handle: string; did: string; password: string }
type JsonResponse<T = Record<string, unknown>> = { status: number; data: T; text: string }

const alice: Auth = { handle: env('E2E_ATPROTO_HANDLE', 'alice.test'), did: '', password: atprotoPassword }
const bob: Auth = {
	handle: env('E2E_AUDIENCE_HANDLE', 'bob.test'),
	did: '',
	password: env('E2E_AUDIENCE_PASSWORD', 'bob-dev-password'),
}

function proxy(listen: string, upstream: string, preserveHost = false) {
	const listenUrl = new URL(listen)
	const upstreamUrl = new URL(upstream)
	return Bun.serve({
		hostname: listenUrl.hostname,
		port: Number(listenUrl.port || 80),
		async fetch(request) {
			const incoming = new URL(request.url)
			const target = new URL(`${incoming.pathname}${incoming.search}`, upstreamUrl)
			const headers = new Headers(request.headers)
			headers.set('accept-encoding', 'identity')
			if (preserveHost) headers.set('host', incoming.host)
			else headers.delete('host')
			headers.set('x-forwarded-host', listenUrl.host)
			headers.set('x-forwarded-proto', listenUrl.protocol.replace(':', ''))
			return fetch(target, {
				method: request.method,
				headers,
				body: request.method === 'GET' || request.method === 'HEAD' ? undefined : request.body,
				redirect: 'manual',
			})
		},
	})
}

async function waitFor(url: string) {
	const started = Date.now()
	while (Date.now() - started < timeoutMs) {
		try {
			if ((await fetch(url)).ok) return
		} catch {}
		await delay(1000)
	}
	throw new Error(`timed out waiting for ${url}`)
}

async function json<T>(page: Page, path: string, init?: RequestInit): Promise<JsonResponse<T>> {
	return page.evaluate(
		async ({ path, init }) => {
			const response = await fetch(path, { credentials: 'include', ...init } as any)
			const text = await response.text()
			let data: Record<string, unknown> = {}
			try {
				data = text ? JSON.parse(text) : {}
			} catch {}
			return { status: response.status, data, text }
		},
		{ path, init },
	) as Promise<JsonResponse<T>>
}

async function login(page: Page, account: Auth): Promise<string> {
	await page.goto(`${appUrl}/api/auth/login?login_hint=${encodeURIComponent(account.handle)}`, {
		waitUntil: 'commit',
		timeout: timeoutMs,
	})
	const started = Date.now()
	while (Date.now() - started < timeoutMs) {
		if (page.url().startsWith(appUrl)) {
			const status = (await page
				.evaluate(() => fetch('/api/auth/status', { credentials: 'include' }).then((r) => r.json()))
				.catch(() => null)) as { authenticated?: boolean; did?: string } | null
			if (status?.authenticated && status.did) {
				account.did = status.did
				return status.did
			}
		}
		const identifier = page
			.locator(
				'input[name="identifier"]:not([disabled]):not([readonly]), input[name="handle"]:not([disabled]):not([readonly]), input[autocomplete="username"]:not([disabled]):not([readonly]), input[placeholder*="handle" i]:not([disabled]):not([readonly])',
			)
			.first()
		const password = page
			.locator(
				'input[type="password"]:not([disabled]):not([readonly]), input[autocomplete="current-password"]:not([disabled]):not([readonly])',
			)
			.first()
		if (await identifier.isVisible().catch(() => false)) await identifier.fill(account.handle)
		if (await password.isVisible().catch(() => false)) await password.fill(account.password)
		const button = page.getByRole('button', { name: /next|continue|log in|sign in|authorize|allow|accept/i }).first()
		if ((await button.isVisible().catch(() => false)) && (await button.isEnabled().catch(() => false)))
			await button.click()
		else await page.keyboard.press('Enter').catch(() => undefined)
		await Promise.race([page.waitForLoadState('networkidle', { timeout: 2000 }).catch(() => undefined), delay(750)])
	}
	throw new Error(`timed out signing in ${account.handle}`)
}

async function screenshot(page: Page, name: string) {
	await page.screenshot({ path: `${artifactsDir}/${name}.png`, fullPage: true })
}

async function upload(page: Page): Promise<{ siteId: string; url: string }> {
	const result = await page.evaluate(async () => {
		const form = new FormData()
		form.append('name', 'agent architecture review')
		form.append(
			'files',
			new File(
				[
					`<!doctype html><html><head><title>agent architecture review</title><link rel="stylesheet" href="/report.css"></head><body><main><p class="eyebrow">private report</p><h1>permissioned data, in practice</h1><p>this is an owner-only report rendered from the private storage origin.</p><details open><summary>the honest tradeoff</summary><p>wisp can read and serve this content; it is access control, not encryption.</p></details></main></body></html>`,
				],
				'index.html',
				{ type: 'text/html' },
			),
		)
		form.append(
			'files',
			new File(
				[
					'body { font-family: system-ui; max-width: 48rem; margin: 12vh auto; padding: 2rem; color: #24202a; } .eyebrow { text-transform: uppercase; letter-spacing: .15em; color: #d34d78; }',
				],
				'report.css',
				{ type: 'text/css' },
			),
		)
		const response = await fetch('/api/user/private-sites/', { method: 'POST', body: form, credentials: 'include' })
		const text = await response.text()
		return {
			status: response.status,
			data: JSON.parse(text) as { siteId: string; url: string; success?: boolean },
			text,
		}
	})
	if (result.status !== 200 || !result.data.success)
		throw new Error(`private upload failed: ${result.status} ${result.text}`)
	return result.data
}

async function main() {
	await mkdir(artifactsDir, { recursive: true })
	const proxies = [
		proxy(appUrl, appUpstream),
		proxy(pdsBrowserUrl, pdsUpstream, true),
		proxy(hostingBrowserUrl, hostingUrl, true),
	]
	const browser = await chromium.launch({ headless: true, executablePath: chromiumExecutable })
	const contexts: BrowserContext[] = []
	try {
		await waitFor(`${appUpstream}/api/health`)
		await waitFor(`${hostingUrl}/health`)
		const owner = await browser.newContext({
			baseURL: appUrl,
			ignoreHTTPSErrors: true,
			recordVideo: { dir: `${artifactsDir}/video`, size: { width: 1440, height: 1000 } },
		})
		contexts.push(owner)
		const ownerPage = await owner.newPage()
		await login(ownerPage, alice)
		const site = await upload(ownerPage)
		const siteUrl = site.url.replace('http://', `${hostingBrowserUrl.split('://')[0]}://`).replace(/:\d+\//, ':3001/')
		console.log(`[capture] site=${site.siteId} url=${siteUrl}`)

		const ownerOpen = await json<{ success?: boolean; url?: string }>(
			ownerPage,
			`/api/user/private-sites/${site.siteId}/open`,
			{ method: 'POST' },
		)
		if (!ownerOpen.data.url) throw new Error(`owner open failed: ${ownerOpen.status} ${ownerOpen.text}`)
		await ownerPage.goto(ownerOpen.data.url, { waitUntil: 'domcontentloaded' })
		await screenshot(ownerPage, '01-owner-private-site')
		await ownerPage.goto(`${appUrl}/onboarding`, { waitUntil: 'domcontentloaded' })

		const anonymous = await browser.newContext({
			ignoreHTTPSErrors: true,
			recordVideo: { dir: `${artifactsDir}/video`, size: { width: 1440, height: 1000 } },
		})
		contexts.push(anonymous)
		const anonymousPage = await anonymous.newPage()
		const anonymousResponse = await anonymousPage.goto(siteUrl, { waitUntil: 'domcontentloaded' })
		if (anonymousResponse?.status() !== 200 || !(await anonymousPage.getByText('private site').count()))
			throw new Error('anonymous private-site denial was not shown')
		await screenshot(anonymousPage, '02-anonymous-access-denied')

		const share = await json<{ success?: boolean; shareId?: string; url?: string; directUrl?: string }>(
			ownerPage,
			`/api/user/private-sites/${site.siteId}/shares`,
			{
				method: 'POST',
				headers: { 'content-type': 'application/json' },
				body: JSON.stringify({ label: 'bob review', audienceDid: await resolveDid(bob.handle) }),
			},
		)
		if (!share.data.shareId || !share.data.directUrl)
			throw new Error(`share creation failed: ${share.status} ${share.text}`)
		await screenshot(ownerPage, '03-share-link-created')

		const audience = await browser.newContext({
			ignoreHTTPSErrors: true,
			recordVideo: { dir: `${artifactsDir}/video`, size: { width: 1440, height: 1000 } },
		})
		contexts.push(audience)
		const audiencePage = await audience.newPage()
		await audiencePage.goto(share.data.directUrl, { waitUntil: 'domcontentloaded' })
		if (!(await audiencePage.getByText('this link is for a specific account').count()))
			throw new Error('audience sign-in gate was not shown')
		await screenshot(audiencePage, '04-audience-restricted-sign-in')

		const bobContext = await browser.newContext({
			baseURL: appUrl,
			ignoreHTTPSErrors: true,
			recordVideo: { dir: `${artifactsDir}/video`, size: { width: 1440, height: 1000 } },
		})
		contexts.push(bobContext)
		const bobPage = await bobContext.newPage()
		await login(bobPage, bob)
		// Submit the gate's form from the browser rather than via the API client: the
		// cross-origin POST only carries a usable Origin header when the gate page's
		// referrer policy allows one, and the main app's CSRF check rejects `null`.
		await bobPage.goto(share.data.directUrl, { waitUntil: 'domcontentloaded' })
		await bobPage.getByRole('button', { name: /sign in with atproto/i }).click()
		await bobPage.waitForLoadState('domcontentloaded')
		try {
			await bobPage.getByText('permissioned data, in practice').first().waitFor({ timeout: 15_000 })
		} catch {
			throw new Error(
				`audience account could not open site: ${bobPage.url()} ${await bobPage.locator('body').innerText()}`,
			)
		}
		await screenshot(bobPage, '05-audience-site-open')

		const revoke = await json(ownerPage, `/api/user/private-sites/${site.siteId}/shares/${share.data.shareId}`, {
			method: 'DELETE',
		})
		if (revoke.status !== 200) throw new Error(`share revoke failed: ${revoke.status} ${revoke.text}`)
		await bobPage.reload({ waitUntil: 'domcontentloaded' })
		if ((await bobPage.locator('body').innerText()).includes('permissioned data, in practice'))
			throw new Error('revoked audience session still served private content')
		await screenshot(bobPage, '06-revoked-share-denied')
		console.log('[capture] completed private-site evidence capture')
	} finally {
		for (const context of contexts) await context.close()
		await browser.close()
		for (const server of proxies) server.stop(true)
	}
}

async function resolveDid(handle: string): Promise<string> {
	const response = await fetch(
		`${pdsUpstream}/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`,
	)
	const result = (await response.json()) as { did?: string }
	if (!response.ok || !result.did) throw new Error(`could not resolve ${handle}`)
	return result.did
}

main().catch((error) => {
	console.error('[capture] failed', error)
	process.exit(1)
})
