#!/usr/bin/env bun
import { cancel, confirm, intro, isCancel, type Option, outro, select, text } from '@clack/prompts'
import type { OutputSchema as SiteDeleteOutput } from '@wispplace/lexicons/types/place/wisp/v2/site/delete'
import type { OutputSchema as SiteGetListOutput } from '@wispplace/lexicons/types/place/wisp/v2/site/getList'
import { Command } from 'commander'
import { deploy } from './commands/deploy.ts'
import {
	claimCustomDomain,
	claimWispSubdomain,
	deleteDomain,
	getDomainStatus,
	mapDomainToSite,
	verifyDomain,
} from './commands/domain.ts'
import { listDomains, listSites } from './commands/list.ts'
import {
	privateDelete,
	privateDeploy,
	privateList,
	privateRevoke,
	privateShare,
	privateShares,
} from './commands/private.ts'
import { pull } from './commands/pull.ts'
import { serve } from './commands/serve.ts'
import {
	authenticate,
	authenticateOAuth,
	clearDirSession,
	clearSessions,
	listStoredAccounts,
	loginWithAppPassword,
	logoutAccount,
	resolveAccountForCwd,
	setDefaultAccount,
} from './lib/auth.ts'
import {
	addXrpcAuthOptions,
	authenticateForXrpc,
	bindAuthStatusToSpinner,
	withExit,
	type XrpcCommandOptions,
} from './lib/command-utils.ts'
import { createSpinner, pc } from './lib/progress.ts'
import { callWispXrpc } from './lib/xrpc.ts'
import packageJson from './package.json'

const program = new Command()
program.enablePositionalOptions()

async function promptRequiredText(
	message: string,
	options: {
		placeholder?: string
		defaultValue?: string
		validate?: (value: string) => string | Error | undefined
		cancelMessage: string
	},
): Promise<string> {
	const result = await text({
		message,
		placeholder: options.placeholder,
		defaultValue: options.defaultValue,
		validate: options.validate,
	})

	if (isCancel(result)) {
		cancel(options.cancelMessage)
		process.exit(0)
	}

	return result
}

async function promptSelect<T extends string>(
	message: string,
	choices: Option<T>[],
	cancelMessage: string,
): Promise<T> {
	const result = await select({
		message,
		options: choices,
	})

	if (isCancel(result)) {
		cancel(cancelMessage)
		process.exit(0)
	}

	return result as T
}

async function promptConfirm(message: string, cancelMessage: string): Promise<boolean> {
	const result = await confirm({ message })

	if (isCancel(result)) {
		cancel(cancelMessage)
		process.exit(0)
	}

	return result
}

async function deleteSiteWithSelection(
	identifier: string | undefined,
	options: XrpcCommandOptions & { site?: string; yes?: boolean },
): Promise<void> {
	const { agent, serviceDid } = await authenticateForXrpc(identifier, options)

	let siteRkey = options.site
	if (!siteRkey) {
		const fetchSpinner = createSpinner('Fetching sites...').start()
		const listData = await callWispXrpc<SiteGetListOutput>(agent, 'place.wisp.v2.site.getList', {
			serviceDid,
		})
		fetchSpinner.succeed('Fetched sites')

		if (listData.sites.length === 0) {
			throw new Error('No sites found for this account')
		}

		siteRkey = await promptSelect(
			'Select site to delete',
			listData.sites.map((site) => ({
				value: site.siteRkey,
				label: site.displayName ? `${site.siteRkey} (${site.displayName})` : site.siteRkey,
				hint: site.domains.length > 0 ? `${site.domains.length} mapped domain(s)` : undefined,
			})),
			'Site deletion cancelled',
		)
	}

	if (!options.yes) {
		const shouldDelete = await promptConfirm(
			`Delete site "${siteRkey}" and unmap its domains?`,
			'Site deletion cancelled',
		)
		if (!shouldDelete) {
			cancel('Site deletion cancelled')
			process.exit(0)
		}
	}

	const deleteSpinner = createSpinner(`Deleting site ${siteRkey}...`).start()
	const data = await callWispXrpc<SiteDeleteOutput>(agent, 'place.wisp.v2.site.delete', {
		serviceDid,
		data: { siteRkey },
	})
	deleteSpinner.succeed(`Deleted site ${data.siteRkey}`)

	if (options.json) {
		console.log(JSON.stringify(data, null, 2))
		return
	}

	console.log(`${pc.bold(data.siteRkey)} deleted`)
	if (data.unmappedDomains.length > 0) {
		console.log('Unmapped domains:')
		for (const domain of data.unmappedDomains) {
			console.log(`- ${domain.domain} [${domain.kind}] ${domain.status}`)
		}
	}
}

program
	.name('wispctl')
	.description('CLI for wisp.place - deploy static sites to the AT Protocol')
	.version(packageJson.version)
	.option('-q, --quiet', 'Suppress progress output — useful for CI/agents (also set via WISPCTL_NO_PROGRESS=1)')

// Deploy command (default)
program
	.command('deploy [handle]', { isDefault: true })
	.description('Deploy a static site to wisp.place')
	.option('-p, --path <path>', 'Directory to deploy')
	.option('-s, --site <name>', 'Site name (defaults to directory name)')
	.option('--directory', 'Enable directory listing')
	.option('--spa', 'Enable SPA mode (serve index.html for all routes)')
	.option('-c, --concurrency <n>', 'Number of concurrent uploads (backs off to 2 on rate limit)', '3')
	.option('--force-gzip', 'Force gzip compression for all files regardless of type')
	.option('--password <password>', 'App password for headless authentication')
	.option('--db <path>', 'Account database path')
	.option('-y, --yes', 'Skip confirmation prompts')
	.action(
		withExit(async (handle: string | undefined, options) => {
			let resolvedHandle = handle
			let resolvedPath = options.path
			let resolvedSite = options.site

			const knownAccount = resolvedHandle || options.password ? undefined : await resolveAccountForCwd(options.db)
			const needsHandlePrompt = !resolvedHandle && !knownAccount
			const needsPrompts = needsHandlePrompt || !resolvedPath || !resolvedSite

			if (needsPrompts) {
				intro(pc.cyan('wisp.place deploy'))

				if (needsHandlePrompt) {
					resolvedHandle = await promptRequiredText('AT Protocol handle', {
						placeholder: 'alice.bsky.social',
						cancelMessage: 'Deploy cancelled',
						validate: (value) => {
							if (!value) return 'Handle is required'
							if (!value.includes('.')) return 'Handle must include a domain (e.g., alice.bsky.social)'
							return undefined
						},
					})
				}

				if (!resolvedPath) {
					resolvedPath = await promptRequiredText('Directory to deploy', {
						placeholder: '.',
						defaultValue: '.',
						cancelMessage: 'Deploy cancelled',
					})
				}

				if (!resolvedSite) {
					resolvedSite = await promptRequiredText('Site name', {
						placeholder: 'my-website',
						cancelMessage: 'Deploy cancelled',
						validate: (value) => {
							if (!value) return 'Site name is required'
							if (!/^[a-zA-Z0-9._~:-]{1,512}$/.test(value)) {
								return 'Site name must be 1-512 characters of [a-zA-Z0-9._~:-]'
							}
							return undefined
						},
					})
				}
			}

			const authSpinner = createSpinner('Authenticating...').start()
			const { agent, did } = await authenticate(resolvedHandle, {
				appPassword: options.password,
				dbPath: options.db,
				onStatus: bindAuthStatusToSpinner(authSpinner),
			})
			authSpinner.succeed(`Authenticated as ${did}`)

			const result = await deploy(agent, did, {
				path: resolvedPath,
				site: resolvedSite,
				directory: options.directory,
				spa: options.spa,
				yes: options.yes,
				concurrency: parseInt(options.concurrency, 10),
				forceGzip: options.forceGzip,
			})

			console.log()
			console.log(pc.dim(`  URI: ${result.uri}`))
			const displayHandle = resolvedHandle ?? knownAccount?.handle
			if (displayHandle) {
				console.log(pc.cyan(`  URL: https://sites.wisp.place/${displayHandle}/${resolvedSite}`))
			}
			console.log(pc.cyan(`  URL: ${result.url}`))

			if (needsPrompts) {
				outro(pc.green('Deployed successfully!'))
			} else {
				console.log()
				console.log(pc.green('✓ Deployed successfully!'))
			}
		}),
	)

// Pull command
program
	.command('pull <handle>')
	.description('Download a site from wisp.place to a local directory')
	.requiredOption('-s, --site <name>', 'Site name to pull')
	.option('-p, --path <path>', 'Output directory', '.')
	.action(
		withExit(async (handle: string, options) => {
			await pull(handle, {
				site: options.site,
				path: options.path,
			})
		}),
	)

// Serve command
program
	.command('serve <handle>')
	.description('Serve a site locally with live updates from firehose')
	.requiredOption('-s, --site <name>', 'Site name to serve')
	.option('-p, --path <path>', 'Local directory to cache site', '.wisp-serve')
	.option('-P, --port <port>', 'Port to serve on', '8080')
	.option('--host <host>', 'Bind address (defaults to loopback; use 0.0.0.0 for public access)', '127.0.0.1')
	.option('--spa [file]', 'Enable SPA mode (serve file for all unmatched routes, defaults to index.html)')
	.option('--directory-listing', 'Enable directory listing')
	.action(
		withExit(async (handle: string, options) => {
			await serve(handle, {
				site: options.site,
				path: options.path,
				port: parseInt(options.port, 10),
				host: options.host,
				spa: options.spa,
				directoryListing: options.directoryListing,
			})
		}),
	)

// Logout command
const listCommand = program
	.command('list')
	.description('List sites and domains from wisp XRPC routes')
	.enablePositionalOptions()

addXrpcAuthOptions(listCommand).action(
	withExit(async (options) => {
		intro(pc.cyan('wisp.place list'))
		const action = await promptSelect(
			'What do you want to list?',
			[
				{ value: 'domains', label: 'Domains', hint: 'Claimed, pending, and mapped domains' },
				{ value: 'sites', label: 'Sites', hint: 'Sites with mapped domains' },
			],
			'List cancelled',
		)

		if (action === 'domains') {
			await listDomains(undefined, options)
			return
		}

		await listSites(undefined, options)
	}),
)

addXrpcAuthOptions(listCommand.command('domains [handle]').description('List domains for an account')).action(
	withExit(async (handle: string | undefined, options) => {
		await listDomains(handle, options)
	}),
)

addXrpcAuthOptions(
	listCommand.command('sites [handle]').description('List sites and their mapped domains for an account'),
).action(
	withExit(async (handle: string | undefined, options) => {
		await listSites(handle, options)
	}),
)

const domainCommand = program
	.command('domain')
	.alias('domains')
	.description('Manage domains with wisp XRPC')
	.enablePositionalOptions()

addXrpcAuthOptions(
	domainCommand
		.command('claim [handle]')
		.description('Claim a custom domain')
		.option('-d, --domain <domain>', 'Custom domain')
		.option('-s, --site <rkey>', 'Optional site rkey to map'),
).action(
	withExit(async (handle: string | undefined, options) => {
		const domain =
			options.domain ??
			(await promptRequiredText('Custom domain', {
				placeholder: 'example.com',
				cancelMessage: 'Claim cancelled',
				validate: (value) => (!value ? 'Domain is required' : undefined),
			}))
		await claimCustomDomain(handle, domain, options.site, options)
	}),
)

addXrpcAuthOptions(
	domainCommand
		.command('claim-subdomain [handle]')
		.description('Claim a wisp subdomain')
		.option('-n, --subdomain <name>', 'Subdomain handle')
		.option('-s, --site <rkey>', 'Optional site rkey to map'),
).action(
	withExit(async (handle: string | undefined, options) => {
		const subdomain =
			options.subdomain ??
			(await promptRequiredText('Subdomain handle', {
				placeholder: 'alice',
				cancelMessage: 'Claim cancelled',
				validate: (value) => (!value ? 'Subdomain is required' : undefined),
			}))
		await claimWispSubdomain(handle, subdomain, options.site, options)
	}),
)

addXrpcAuthOptions(
	domainCommand
		.command('status [handle]')
		.description('Get domain verification/claim status')
		.option('-d, --domain <domain>', 'Domain'),
).action(
	withExit(async (handle: string | undefined, options) => {
		const domain =
			options.domain ??
			(await promptRequiredText('Domain', {
				placeholder: 'example.com',
				cancelMessage: 'Status check cancelled',
				validate: (value) => (!value ? 'Domain is required' : undefined),
			}))
		await getDomainStatus(handle, domain, options)
	}),
)

addXrpcAuthOptions(
	domainCommand
		.command('add-site [handle]')
		.description('Map a claimed domain to a site rkey')
		.option('-d, --domain <domain>', 'Domain')
		.option('-s, --site <rkey>', 'Site rkey'),
).action(
	withExit(async (handle: string | undefined, options) => {
		const domain =
			options.domain ??
			(await promptRequiredText('Domain', {
				placeholder: 'example.com',
				cancelMessage: 'Add-site cancelled',
				validate: (value) => (!value ? 'Domain is required' : undefined),
			}))
		const site =
			options.site ??
			(await promptRequiredText('Site rkey', {
				placeholder: 'mysite',
				cancelMessage: 'Add-site cancelled',
				validate: (value) => (!value ? 'Site rkey is required' : undefined),
			}))
		await mapDomainToSite(handle, domain, site, options)
	}),
)

addXrpcAuthOptions(
	domainCommand
		.command('delete [handle]')
		.description('Delete a claimed domain')
		.option('-d, --domain <domain>', 'Domain'),
).action(
	withExit(async (handle: string | undefined, options) => {
		const domain =
			options.domain ??
			(await promptRequiredText('Domain', {
				placeholder: 'example.com',
				cancelMessage: 'Delete cancelled',
				validate: (value) => (!value ? 'Domain is required' : undefined),
			}))
		await deleteDomain(handle, domain, options)
	}),
)

addXrpcAuthOptions(
	domainCommand
		.command('verify [handle]')
		.description('Run DNS verification for a claimed custom domain')
		.option('-d, --domain <domain>', 'Domain'),
).action(
	withExit(async (handle: string | undefined, options) => {
		const domain =
			options.domain ??
			(await promptRequiredText('Domain', {
				placeholder: 'example.com',
				cancelMessage: 'Verify cancelled',
				validate: (value) => (!value ? 'Domain is required' : undefined),
			}))
		await verifyDomain(handle, domain, options)
	}),
)

const siteCommand = program.command('site').description('Manage sites with wisp XRPC').enablePositionalOptions()

addXrpcAuthOptions(domainCommand).action(
	withExit(async (options) => {
		intro(pc.cyan('wisp.place domain'))
		const action = await promptSelect(
			'Choose domain action',
			[
				{ value: 'claim', label: 'Claim custom domain' },
				{ value: 'claim-subdomain', label: 'Claim wisp subdomain' },
				{ value: 'status', label: 'Get domain status' },
				{ value: 'add-site', label: 'Map domain to site' },
				{ value: 'verify', label: 'Verify domain' },
				{ value: 'delete', label: 'Delete domain' },
			],
			'Domain command cancelled',
		)

		if (action === 'claim') {
			const domain = await promptRequiredText('Custom domain', {
				placeholder: 'example.com',
				cancelMessage: 'Claim cancelled',
				validate: (value) => (!value ? 'Domain is required' : undefined),
			})
			await claimCustomDomain(undefined, domain, undefined, options)
			return
		}

		if (action === 'claim-subdomain') {
			const subdomain = await promptRequiredText('Subdomain handle', {
				placeholder: 'alice',
				cancelMessage: 'Claim cancelled',
				validate: (value) => (!value ? 'Subdomain is required' : undefined),
			})
			await claimWispSubdomain(undefined, subdomain, undefined, options)
			return
		}

		if (action === 'status') {
			const domain = await promptRequiredText('Domain', {
				placeholder: 'example.com',
				cancelMessage: 'Status check cancelled',
				validate: (value) => (!value ? 'Domain is required' : undefined),
			})
			await getDomainStatus(undefined, domain, options)
			return
		}

		if (action === 'add-site') {
			const domain = await promptRequiredText('Domain', {
				placeholder: 'example.com',
				cancelMessage: 'Add-site cancelled',
				validate: (value) => (!value ? 'Domain is required' : undefined),
			})
			const site = await promptRequiredText('Site rkey', {
				placeholder: 'mysite',
				cancelMessage: 'Add-site cancelled',
				validate: (value) => (!value ? 'Site rkey is required' : undefined),
			})
			await mapDomainToSite(undefined, domain, site, options)
			return
		}

		if (action === 'verify') {
			const domain = await promptRequiredText('Domain', {
				placeholder: 'example.com',
				cancelMessage: 'Verify cancelled',
				validate: (value) => (!value ? 'Domain is required' : undefined),
			})
			await verifyDomain(undefined, domain, options)
			return
		}

		const domain = await promptRequiredText('Domain', {
			placeholder: 'example.com',
			cancelMessage: 'Delete cancelled',
			validate: (value) => (!value ? 'Domain is required' : undefined),
		})
		await deleteDomain(undefined, domain, options)
	}),
)

addXrpcAuthOptions(siteCommand).action(
	withExit(async (options) => {
		intro(pc.cyan('wisp.place site'))
		const action = await promptSelect(
			'Choose site action',
			[
				{ value: 'list', label: 'List sites', hint: 'Show sites and mapped domains' },
				{ value: 'delete', label: 'Delete site', hint: 'Remove site mapping metadata' },
			],
			'Site command cancelled',
		)

		if (action === 'list') {
			await listSites(undefined, options)
			return
		}

		await deleteSiteWithSelection(undefined, options)
	}),
)

addXrpcAuthOptions(
	siteCommand
		.command('delete [handle]')
		.description('Delete a site from wisp metadata and unmap its domains')
		.option('-s, --site <rkey>', 'Site rkey')
		.option('-y, --yes', 'Skip delete confirmation'),
).action(
	withExit(async (handle: string | undefined, options) => {
		await deleteSiteWithSelection(handle, options)
	}),
)

const privateCommand = program
	.command('private')
	.description('Manage private sites (never published to your PDS)')
	.enablePositionalOptions()

addXrpcAuthOptions(
	privateCommand
		.command('deploy [handle]')
		.description('Upload a directory as a private site')
		.option('-p, --path <dir>', 'Directory to upload', '.')
		.option('-n, --name <name>', 'Display name for the private site')
		.option('-e, --expiry <minutes>', 'Minutes until the site expires. Omit for the server default, 0 to never expire'),
).action(
	withExit(async (handle: string | undefined, options) => {
		const { agent } = await authenticateForXrpc(handle, options)
		await privateDeploy(agent, options)
	}),
)

addXrpcAuthOptions(privateCommand.command('list [handle]').description('List your private sites')).action(
	withExit(async (handle: string | undefined, options) => {
		const { agent } = await authenticateForXrpc(handle, options)
		await privateList(agent, options)
	}),
)

addXrpcAuthOptions(
	privateCommand.command('delete <siteId> [handle]').description('Delete a private site and all of its share links'),
).action(
	withExit(async (siteId: string, handle: string | undefined, options) => {
		const { agent } = await authenticateForXrpc(handle, options)
		await privateDelete(agent, siteId, options)
	}),
)

addXrpcAuthOptions(
	privateCommand
		.command('share <siteId> [handle]')
		.description('Create a shareable link for a private site')
		.option('-l, --label <label>', 'Label to identify this link')
		.option('-e, --expiry <minutes>', 'Minutes until the link expires. Omit for the server default, 0 for none')
		.option(
			'-t, --to <did>',
			'Restrict the link to one account. They sign in to open it; the link alone grants nothing',
		),
).action(
	withExit(async (siteId: string, handle: string | undefined, options) => {
		const { agent } = await authenticateForXrpc(handle, options)
		await privateShare(agent, siteId, options)
	}),
)

addXrpcAuthOptions(
	privateCommand.command('shares <siteId> [handle]').description('List share links for a private site'),
).action(
	withExit(async (siteId: string, handle: string | undefined, options) => {
		const { agent } = await authenticateForXrpc(handle, options)
		await privateShares(agent, siteId, options)
	}),
)

addXrpcAuthOptions(
	privateCommand.command('revoke <siteId> <shareId> [handle]').description('Revoke a share link immediately'),
).action(
	withExit(async (siteId: string, shareId: string, handle: string | undefined, options) => {
		const { agent } = await authenticateForXrpc(handle, options)
		await privateRevoke(agent, siteId, shareId, options)
	}),
)

// Login command
program
	.command('login <handle>')
	.description('Authenticate and store credentials for use from any directory')
	.option('--db <path>', 'Account database path')
	.option('--password <password>', 'Log in with an app password instead of OAuth (or set WISPCTL_APP_PASSWORD)')
	.action(
		withExit(async (handle: string, options) => {
			const appPassword = options.password ?? process.env.WISPCTL_APP_PASSWORD
			if (appPassword !== undefined) {
				const authSpinner = createSpinner('Authenticating...').start()
				const result = await loginWithAppPassword(handle, appPassword, {
					dbPath: options.db,
					onStatus: bindAuthStatusToSpinner(authSpinner),
				})
				authSpinner.succeed(`Authenticated as ${result.handle}`)
				if (result.stored) {
					console.log(pc.dim('  App password saved to the system keychain'))
				} else {
					console.log(
						pc.yellow(`  App password not saved: ${result.keychainDetail ?? 'no OS credential store available.'}`),
					)
					console.log(pc.dim('  Use --password or WISPCTL_APP_PASSWORD for future commands.'))
				}
				return
			}

			const authSpinner = createSpinner('Authenticating...').start()
			const { did } = await authenticateOAuth(handle, {
				dbPath: options.db,
				onStatus: bindAuthStatusToSpinner(authSpinner),
				forceReauth: true,
			})
			authSpinner.succeed(`Authenticated as ${did}`)
		}),
	)

// Logout command
program
	.command('logout [handle]')
	.description('Unlink the current directory, or forget an account everywhere')
	.option('--db <path>', 'Account database path')
	.option('--all', 'Forget every stored account and credential')
	.action(
		withExit(async (handle: string | undefined, options) => {
			if (options.all) {
				await clearSessions(options.db)
				return
			}
			if (handle) {
				const account = await logoutAccount(handle, options.db)
				if (account) {
					console.log(`Forgot ${account.handle ?? account.did} and removed its stored credentials`)
				} else {
					console.log(`No stored account for ${handle}`)
				}
				return
			}
			await clearDirSession(options.db)
		}),
	)

// Accounts command
const accountsCommand = program.command('accounts').description('Manage stored accounts')

accountsCommand
	.command('list', { isDefault: true })
	.description('List stored accounts')
	.option('--db <path>', 'Account database path')
	.option('--json', 'Output raw JSON')
	.action(
		withExit(async (options) => {
			const accounts = await listStoredAccounts(options.db)

			if (options.json) {
				console.log(JSON.stringify(accounts, null, 2))
				return
			}

			if (accounts.length === 0) {
				console.log('No stored accounts. Run `wispctl login <handle>` to add one.')
				return
			}

			console.log()
			for (const account of accounts) {
				const tags = [
					account.method === 'app-password' ? 'app password' : 'oauth',
					account.hasCredential ? undefined : pc.yellow('no credential'),
					account.isDefault ? pc.green('default') : undefined,
					account.isCurrentDir ? pc.cyan('this directory') : undefined,
				].filter(Boolean)
				console.log(`  ${pc.bold(account.handle ?? account.did)}  ${pc.dim(`(${tags.join(', ')})`)}`)
				// Only worth a second line when it is not just the title again.
				if (account.handle) {
					console.log(pc.dim(`    ${account.did}`))
				}
				if (account.dirs.length > 0) {
					console.log(pc.dim(`    ${account.dirs.length} linked director${account.dirs.length === 1 ? 'y' : 'ies'}`))
				}
			}
			console.log()
		}),
	)

accountsCommand
	.command('use <handle>')
	.description('Set the account used in directories with no linked account')
	.option('--db <path>', 'Account database path')
	.action(
		withExit(async (handle: string, options) => {
			const account = await setDefaultAccount(handle, options.db)
			console.log(`Default account set to ${account.handle ?? account.did}`)
		}),
	)

accountsCommand
	.command('remove <handle>')
	.description('Forget an account and remove its stored credentials')
	.option('--db <path>', 'Account database path')
	.action(
		withExit(async (handle: string, options) => {
			const account = await logoutAccount(handle, options.db)
			if (account) {
				console.log(`Forgot ${account.handle ?? account.did} and removed its stored credentials`)
			} else {
				console.log(`No stored account for ${handle}`)
			}
		}),
	)

// Set WISPCTL_NO_PROGRESS from --quiet flag before any command runs
program.hook('preAction', () => {
	if (program.opts().quiet) {
		process.env.WISPCTL_NO_PROGRESS = '1'
	}
})

program.parse()
