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

type JsonResponse<T = unknown> = {
	ok: boolean
	status: number
	data: T | null
	text: string
}

type UploadStartResponse = {
	success?: boolean
	jobId?: string
	error?: string
}

type UploadDoneResponse = {
	success?: boolean
	fileCount?: number
	uploadedCount?: number
	hasFailures?: boolean
	failedFiles?: Array<{ name: string; error: string }>
}

const requiredEnv = (name: string): string => {
	const value = process.env[name]
	if (!value) {
		throw new Error(`${name} is required`)
	}
	return value
}

const env = (name: string, fallback: string): 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 firehoseUrl = env('E2E_FIREHOSE_URL', 'http://firehose-service:3001').replace(/\/$/, '')
const atprotoHandle = requiredEnv('E2E_ATPROTO_HANDLE')
const atprotoPassword = requiredEnv('E2E_ATPROTO_PASSWORD')
const timeoutMs = Number.parseInt(env('E2E_TIMEOUT_MS', '180000'), 10)
const cleanupEnabled = env('E2E_CLEANUP', 'true') !== 'false'
const headless = env('E2E_HEADLESS', 'true') !== 'false'
const chromiumExecutable = process.env.E2E_CHROMIUM_EXECUTABLE || undefined

const runId = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`
const domainHandle = env('E2E_DOMAIN_HANDLE', `e2e-${runId}`).toLowerCase()
const domain = `${domainHandle}.wisp.place`
const siteName = env('E2E_SITE_RKEY', `e2e-${runId}`).toLowerCase()
const marker = `wisp-e2e-${runId}`

function startLoopbackProxy(listen: string, upstream: string, label: string, preserveHost = false) {
	const listenUrl = new URL(listen)
	const upstreamUrl = new URL(upstream)

	const server = Bun.serve({
		hostname: listenUrl.hostname,
		port: Number(listenUrl.port || '80'),
		async fetch(request) {
			const incomingUrl = new URL(request.url)
			const target = new URL(`${incomingUrl.pathname}${incomingUrl.search}`, upstreamUrl)
			const headers = new Headers(request.headers)
			headers.set('accept-encoding', 'identity')
			if (preserveHost) headers.set('host', incomingUrl.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',
			})
		},
	})

	console.log(`[e2e] ${label} loopback proxy listening on ${listen} -> ${upstream}`)
	return server
}

async function waitForHttpOk(url: string, label: string): Promise<void> {
	await poll(
		async () => {
			try {
				const response = await fetch(url)
				return response.ok
			} catch {
				return false
			}
		},
		{ label, timeout: timeoutMs, interval: 1000 },
	)
}

async function poll(
	check: () => Promise<boolean>,
	options: { label: string; timeout: number; interval?: number },
): Promise<void> {
	const started = Date.now()
	const interval = options.interval ?? 1000
	let attempts = 0

	while (Date.now() - started < options.timeout) {
		attempts++
		if (await check()) return
		if (attempts % 10 === 0) {
			console.log(`[e2e] Still waiting for ${options.label} (${Math.round((Date.now() - started) / 1000)}s)`)
		}
		await delay(interval)
	}

	throw new Error(`Timed out waiting for ${options.label}`)
}

async function maybeFill(page: Page, selectors: string[], value: string): Promise<boolean> {
	for (const selector of selectors) {
		const locator = page.locator(selector).first()
		if ((await locator.count()) === 0) continue
		if (!(await locator.isVisible().catch(() => false))) continue
		if (!(await locator.isEnabled().catch(() => false))) continue

		const current = await locator.inputValue().catch(() => '')
		if (current !== value) {
			await locator.fill(value)
		}
		return true
	}
	return false
}

async function clickFirstButton(page: Page, names: RegExp[]): Promise<boolean> {
	for (const name of names) {
		const button = page.getByRole('button', { name }).first()
		if ((await button.count()) > 0 && (await button.isVisible().catch(() => false))) {
			if (await button.isEnabled().catch(() => false)) {
				await button.click()
				return true
			}
		}
	}

	const submit = page.locator('button[type="submit"], input[type="submit"]').first()
	if ((await submit.count()) > 0 && (await submit.isVisible().catch(() => false))) {
		if (await submit.isEnabled().catch(() => false)) {
			await submit.click()
			return true
		}
	}

	return false
}

async function appAuthStatus(page: Page): Promise<{ authenticated: boolean; did?: string }> {
	return await page.evaluate(async () => {
		const response = await fetch('/api/auth/status', { credentials: 'include' })
		return response.json()
	})
}

async function completeAtprotoLogin(page: Page): Promise<string> {
	const loginUrl = `${appUrl}/api/auth/login?login_hint=${encodeURIComponent(atprotoHandle)}`
	// The reference PDS authorization page can keep loading subresources after the
	// redirect commits. Return as soon as navigation commits; the loop below owns the
	// form interaction and waits for each subsequent state transition explicitly.
	await page.goto(loginUrl, { waitUntil: 'commit', timeout: timeoutMs })

	const started = Date.now()
	let lastLoggedUrl = ''

	while (Date.now() - started < timeoutMs) {
		const currentUrl = page.url()
		if (currentUrl !== lastLoggedUrl) {
			console.log(`[e2e] Auth flow at ${currentUrl}`)
			lastLoggedUrl = currentUrl
		}

		if (currentUrl.startsWith(appUrl)) {
			const status: { authenticated: boolean; did?: string } = await appAuthStatus(page).catch(() => ({
				authenticated: false,
			}))
			if (status.authenticated && status.did) {
				console.log(`[e2e] Signed in as ${status.did}`)
				return status.did
			}
		}

		const twoFactorInput = page
			.locator('input[name*="code" i], input[autocomplete="one-time-code"], input[inputmode="numeric"]')
			.first()
		if ((await twoFactorInput.count()) > 0 && (await twoFactorInput.isVisible().catch(() => false))) {
			throw new Error('Two-factor auth is not supported by this harness; use a dedicated test account without 2FA')
		}

		const filledIdentifier = await maybeFill(
			page,
			[
				'input[name="identifier"]',
				'input[name="handle"]',
				'input[name="login"]',
				'input[autocomplete="username"]',
				'input[type="email"]',
				'input[placeholder*="handle" i]',
				'input[placeholder*="email" i]',
			],
			atprotoHandle,
		)

		const filledPassword = await maybeFill(
			page,
			['input[type="password"]', 'input[name="password"]', 'input[autocomplete="current-password"]'],
			atprotoPassword,
		)

		const clicked = await clickFirstButton(
			page,
			filledPassword
				? [/log in/i, /sign in/i, /continue/i, /authorize/i, /allow/i, /accept/i]
				: filledIdentifier
					? [/next/i, /continue/i, /log in/i, /sign in/i]
					: [/authorize/i, /allow/i, /accept/i, /continue/i, /yes/i],
		)

		if (!clicked) {
			await page.keyboard.press('Enter').catch(() => undefined)
		}

		await Promise.race([page.waitForLoadState('networkidle', { timeout: 2000 }).catch(() => undefined), delay(750)])
	}

	throw new Error('Timed out completing ATProto OAuth login')
}

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

async function claimDomain(page: Page): Promise<void> {
	const response = await appJson<{ success?: boolean; domain?: string; error?: string }>(page, '/api/domain/claim', {
		method: 'POST',
		headers: { 'Content-Type': 'application/json' },
		body: JSON.stringify({ handle: domainHandle }),
	})

	if (!response.ok || !response.data?.success) {
		throw new Error(`Domain claim failed (${response.status}): ${response.text}`)
	}

	console.log(`[e2e] Claimed ${response.data.domain}`)
}

async function uploadSite(page: Page): Promise<UploadDoneResponse> {
	const html = `<!doctype html>
<html>
	<head>
		<meta charset="utf-8">
		<title>wisp e2e</title>
		<link rel="stylesheet" href="/style.css">
	</head>
	<body data-marker="${marker}">
		<h1>${marker}</h1>
	</body>
</html>`
	const css = `body { font-family: monospace; color: #123; }`

	const started = await page.evaluate(
		async ({ siteName, html, css }) => {
			const formData = new FormData()
			formData.append('siteName', siteName)
			formData.append('files', new File([html], 'index.html', { type: 'text/html' }))
			formData.append('files', new File([css], 'style.css', { type: 'text/css' }))

			const response = await fetch('/wisp/upload-files', {
				method: 'POST',
				body: formData,
				credentials: 'include',
			})
			const text = await response.text()
			let data: unknown
			try {
				data = JSON.parse(text)
			} catch {
				data = { error: text }
			}

			return { ok: response.ok, status: response.status, data, text }
		},
		{ siteName, html, css },
	)

	const data = started.data as UploadStartResponse
	if (!started.ok || !data.success || !data.jobId) {
		throw new Error(`Upload did not start (${started.status}): ${started.text}`)
	}

	console.log(`[e2e] Upload job ${data.jobId} started`)

	const done = await page.evaluate(
		async ({ jobId, timeoutMs }) => {
			return await new Promise<UploadDoneResponse>((resolve, reject) => {
				const eventSource = new EventSource(`/wisp/upload-progress/${jobId}`)
				const timeout = setTimeout(() => {
					eventSource.close()
					reject(new Error('Timed out waiting for upload SSE'))
				}, timeoutMs)

				const finish = (result: UploadDoneResponse) => {
					clearTimeout(timeout)
					eventSource.close()
					resolve(result)
				}

				eventSource.addEventListener('progress', (event) => {
					const payload = JSON.parse(event.data)
					if (payload.status === 'completed') finish(payload.result || {})
					if (payload.status === 'failed') reject(new Error(payload.error || 'Upload failed'))
				})

				eventSource.addEventListener('done', (event) => {
					finish(JSON.parse(event.data))
				})

				eventSource.addEventListener('error', (event) => {
					const message = 'data' in event && event.data ? String(event.data) : 'Upload SSE failed'
					clearTimeout(timeout)
					eventSource.close()
					reject(new Error(message))
				})
			})
		},
		{ jobId: data.jobId, timeoutMs },
	)

	if (done.hasFailures || (done.failedFiles && done.failedFiles.length > 0)) {
		throw new Error(`Upload completed with failed files: ${JSON.stringify(done.failedFiles)}`)
	}

	console.log(`[e2e] Uploaded ${done.uploadedCount ?? done.fileCount ?? 0} files to ${siteName}`)
	return done
}

async function waitForFirehoseProjection(page: Page): Promise<void> {
	await poll(
		async () => {
			const response = await appJson<{ sites?: Array<{ rkey: string }> }>(page, '/api/user/sites')
			return response.ok && Boolean(response.data?.sites?.some((site) => site.rkey === siteName))
		},
		{ label: `firehose projection for ${siteName}`, timeout: timeoutMs, interval: 2000 },
	)
	console.log(`[e2e] Firehose projected ${siteName} into site_cache`)
}

async function mapDomain(page: Page): Promise<void> {
	const response = await appJson<{ success?: boolean }>(page, '/api/domain/wisp/map-site', {
		method: 'POST',
		headers: { 'Content-Type': 'application/json' },
		body: JSON.stringify({ domain, siteRkey: siteName }),
	})

	if (!response.ok || !response.data?.success) {
		throw new Error(`Domain mapping failed (${response.status}): ${response.text}`)
	}

	console.log(`[e2e] Mapped ${domain} -> ${siteName}`)
}

async function fetchHostedSite(): Promise<{ status: number; text: string; tier: string | null }> {
	const response = await fetch(`${hostingUrl}/`, {
		headers: {
			Host: domain,
			Accept: 'text/html',
			'Accept-Encoding': 'identity',
		},
		redirect: 'manual',
	})
	const text = await response.text()
	return {
		status: response.status,
		text,
		tier: response.headers.get('x-cache-tier'),
	}
}

async function fetchHostedDirect(did: string): Promise<{ status: number; text: string; tier: string | null }> {
	const response = await fetch(`${hostingUrl}/${did}/${siteName}/`, {
		headers: {
			Host: 'sites.wisp.place',
			Accept: 'text/html',
			'Accept-Encoding': 'identity',
		},
		redirect: 'manual',
	})
	const text = await response.text()
	return {
		status: response.status,
		text,
		tier: response.headers.get('x-cache-tier'),
	}
}

async function verifyHostingCacheFlow(): Promise<void> {
	let first: { status: number; text: string; tier: string | null } | undefined

	await poll(
		async () => {
			const response = await fetchHostedSite()
			if (response.status === 200 && response.text.includes(marker)) {
				first = response
				return true
			}
			console.log(
				`[e2e] Hosting not ready: status=${response.status} tier=${response.tier} body=${response.text.slice(0, 80)}`,
			)
			return false
		},
		{ label: `hosting response for ${domain}`, timeout: timeoutMs, interval: 2000 },
	)

	const firstResponse = first
	if (!firstResponse) {
		throw new Error('Hosting response missing after readiness poll')
	}

	if (firstResponse.tier !== 'cold') {
		throw new Error(`Expected first hosting read to come from cold/S3, got ${firstResponse.tier || '(missing)'}`)
	}

	const second = await fetchHostedSite()
	if (second.status !== 200 || !second.text.includes(marker)) {
		throw new Error(`Second hosting read failed: status=${second.status} body=${second.text.slice(0, 120)}`)
	}

	if (second.tier !== 'hot') {
		throw new Error(`Expected second hosting read to come from hot/memory, got ${second.tier || '(missing)'}`)
	}

	console.log('[e2e] Hosting served first from cold/S3 and second from hot/memory')
}

async function deleteDomainAndVerify(page: Page): Promise<void> {
	const domainDelete = await appJson(page, `/api/domain/wisp/${encodeURIComponent(domain)}`, {
		method: 'DELETE',
	})
	if (!domainDelete.ok) {
		throw new Error(`Domain delete failed (${domainDelete.status}): ${domainDelete.text}`)
	}

	await poll(
		async () => {
			const response = await appJson(page, `/api/domain/registered?domain=${encodeURIComponent(domain)}`)
			return response.status === 404
		},
		{ label: `main-app domain deletion for ${domain}`, timeout: timeoutMs, interval: 1000 },
	)

	await poll(
		async () => {
			const response = await fetchHostedSite()
			if (response.status === 404 && !response.text.includes(marker)) return true
			console.log(
				`[e2e] Domain cache still serving or not settled: status=${response.status} tier=${response.tier} body=${response.text.slice(0, 80)}`,
			)
			return false
		},
		{ label: `hosting domain cache invalidation for ${domain}`, timeout: timeoutMs, interval: 1000 },
	)

	console.log(`[e2e] Deleted ${domain} and verified hosting domain cache invalidation`)
}

async function primeDirectSiteCache(did: string): Promise<void> {
	await poll(
		async () => {
			const response = await fetchHostedDirect(did)
			if (response.status === 200 && response.text.includes(marker)) return true
			console.log(
				`[e2e] Direct site route not ready: status=${response.status} tier=${response.tier} body=${response.text.slice(0, 80)}`,
			)
			return false
		},
		{ label: `direct hosted site route for ${did}/${siteName}`, timeout: timeoutMs, interval: 1000 },
	)

	const cached = await fetchHostedDirect(did)
	if (cached.status !== 200 || !cached.text.includes(marker)) {
		throw new Error(`Direct site cache prime failed: status=${cached.status} body=${cached.text.slice(0, 120)}`)
	}

	if (cached.tier !== 'hot') {
		throw new Error(`Expected direct route second read to come from hot/memory, got ${cached.tier || '(missing)'}`)
	}

	console.log('[e2e] Primed direct site route into hosting hot cache')
}

async function deleteSiteRecordAndVerify(page: Page, did: string): Promise<void> {
	const siteDelete = await appJson(page, `/api/site/${encodeURIComponent(siteName)}`, {
		method: 'DELETE',
	})
	if (!siteDelete.ok) {
		throw new Error(`Site record delete failed (${siteDelete.status}): ${siteDelete.text}`)
	}

	await poll(
		async () => {
			const response = await appJson<{ sites?: Array<{ rkey: string }> }>(page, '/api/user/sites')
			return response.ok && !response.data?.sites?.some((site) => site.rkey === siteName)
		},
		{ label: `firehose deletion projection for ${siteName}`, timeout: timeoutMs, interval: 2000 },
	)

	await poll(
		async () => {
			const response = await fetchHostedDirect(did)
			if (response.status === 404 && !response.text.includes(marker)) return true
			console.log(
				`[e2e] Site cache still serving or not settled: status=${response.status} tier=${response.tier} body=${response.text.slice(0, 80)}`,
			)
			return false
		},
		{ label: `hosting site storage invalidation for ${did}/${siteName}`, timeout: timeoutMs, interval: 1000 },
	)

	console.log('[e2e] Deleted place.wisp.fs record and verified firehose + hosting cache eviction')
}

async function cleanup(page: Page): Promise<void> {
	if (!cleanupEnabled) return

	console.log('[e2e] Cleaning up test records')

	const domainDelete = await appJson(page, `/api/domain/wisp/${encodeURIComponent(domain)}`, {
		method: 'DELETE',
	})
	if (!domainDelete.ok) {
		console.warn(`[e2e] Domain cleanup failed (${domainDelete.status}): ${domainDelete.text}`)
	}

	const siteDelete = await appJson(page, `/api/site/${encodeURIComponent(siteName)}`, {
		method: 'DELETE',
	})
	if (!siteDelete.ok) {
		console.warn(`[e2e] Site cleanup failed (${siteDelete.status}): ${siteDelete.text}`)
	}
}

async function main(): Promise<void> {
	console.log(`[e2e] domain=${domain} site=${siteName} marker=${marker}`)
	const proxies = [
		startLoopbackProxy(appUrl, appUpstream, 'main app'),
		startLoopbackProxy(pdsBrowserUrl, pdsUpstream, 'pds', true),
		startLoopbackProxy(hostingBrowserUrl, hostingUrl, 'hosting', true),
	]
	let browser: Browser | null = null
	let page: Page | null = null
	let needsCleanup = true

	try {
		await waitForHttpOk(`${appUpstream}/api/health`, 'main app upstream')
		await waitForHttpOk(`${firehoseUrl}/health`, 'firehose health endpoint')
		await waitForHttpOk(`${hostingUrl}/health`, 'hosting health endpoint')

		browser = await chromium.launch({ headless, executablePath: chromiumExecutable })
		const context = await browser.newContext({
			baseURL: appUrl,
			ignoreHTTPSErrors: true,
		})
		page = await context.newPage()

		const did = await completeAtprotoLogin(page)
		await page.goto(`${appUrl}/onboarding`, { waitUntil: 'domcontentloaded' })

		await claimDomain(page)
		await uploadSite(page)
		await waitForFirehoseProjection(page)

		// Let the cache-invalidation update clear settle before the first hosting read.
		await delay(2000)

		await mapDomain(page)
		await verifyHostingCacheFlow()
		await deleteDomainAndVerify(page)
		await primeDirectSiteCache(did)
		await deleteSiteRecordAndVerify(page, did)

		needsCleanup = false
		console.log('[e2e] Harness completed successfully')
	} finally {
		if (page && needsCleanup && page.url().startsWith(appUrl)) {
			await cleanup(page)
		}
		await browser?.close()
		for (const proxy of proxies) proxy.stop(true)
	}
}

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