/**
 * Main server entry point for the hosting service
 * Handles routing and request dispatching
 */

import { normalizeSitePath } from '@wispplace/fs-utils'
import { createLogger } from '@wispplace/observability'
import { observabilityErrorHandler, observabilityMiddleware } from '@wispplace/observability/middleware/hono'
import { siteIdFromHostname } from '@wispplace/private-sites'
import { type Context, Hono } from 'hono'
import { cors } from 'hono/cors'
import { type CacheInvalidationHealthSnapshot, getCacheInvalidationHealthSnapshot } from './lib/cache-invalidation'
import { cache } from './lib/cache-manager'
import { getCustomDomain, getCustomDomainByHash, getWispDomain } from './lib/db'
import { serveFromCache, serveFromCacheWithRewrite } from './lib/file-serving'
import { privateNotFound, servePrivateSite } from './lib/private-serving'
import { decodeRequestPathname, extractHeaders, isValidRkey } from './lib/request-utils'
import { siteAnalytics } from './lib/site-analytics'
import { getStorageReadHealthSnapshot, type StorageReadHealthSnapshot } from './lib/storage'
import { isValidAtprotoIdentifier, resolveDid } from './lib/utils'

const logger = createLogger('hosting-service')

function recordPublicSiteResponse(ownerDid: string, siteRkey: string, method: string, response: Response): void {
	siteAnalytics.record(ownerDid, siteRkey, method, response.status, response.headers.get('content-type'))
}

function recordPublicSiteFailure(ownerDid: string, siteRkey: string, method: string): void {
	siteAnalytics.record(ownerDid, siteRkey, method, 500, null)
}

function trackPublicSiteResponse(
	ownerDid: string,
	siteRkey: string,
	method: string,
	responsePromise: Promise<Response>,
): Promise<Response> {
	return responsePromise
		.then((response) => {
			recordPublicSiteResponse(ownerDid, siteRkey, method, response)
			return response
		})
		.then(
			(response) => response,
			(error) => {
				recordPublicSiteFailure(ownerDid, siteRkey, method)
				throw error
			},
		)
}

async function resolveDidCached(identifier: string): Promise<string | null> {
	if (!isValidAtprotoIdentifier(identifier)) return null
	return cache.getOrFetch('handles', identifier, () => resolveDid(identifier), {
		cacheIf: (value) => value !== null,
	})
}

type PublicDomainMapping = {
	did: string
	rkey: string | null
}

const DEFAULT_BASE_HOST = 'wisp.place'
const HOSTNAME_PATTERN =
	/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)*[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/

export function normalizeConfiguredHostname(value: string | undefined, fallback: string): string {
	const configured = value?.trim()
	if (!configured || /[/?#@]/.test(configured)) return fallback

	try {
		const url = new URL(`http://${configured}`)
		if (url.username || url.password || url.pathname !== '/' || url.search || url.hash) return fallback

		const hostname = url.hostname.toLowerCase().replace(/\.$/, '')
		return HOSTNAME_PATTERN.test(hostname) ? hostname : fallback
	} catch {
		return fallback
	}
}

const BASE_HOST = normalizeConfiguredHostname(process.env.BASE_HOST, DEFAULT_BASE_HOST)

// Separate origins keep tenant JavaScript and ambient cookies isolated.
const PRIVATE_HOST = normalizeConfiguredHostname(process.env.PRIVATE_HOST, `priv.${BASE_HOST}`)

async function serveMappedPublicDomain(
	c: Context,
	domain: PublicDomainMapping | null,
	path: string,
	notFoundMessage: string,
): Promise<Response> {
	if (!domain) {
		return c.text(notFoundMessage, 404)
	}

	if (!domain.rkey) {
		return c.text('Domain not mapped to a site', 404)
	}

	if (!isValidRkey(domain.rkey)) {
		return c.text('Invalid site configuration', 500)
	}

	const headers = extractHeaders(c.req.raw.headers)
	return trackPublicSiteResponse(
		domain.did,
		domain.rkey,
		c.req.method,
		serveFromCache(domain.did, domain.rkey, path, c.req.url, headers),
	)
}

const app = new Hono()

// Never allow cross-origin credentialed reads of private content.
app.use(
	'*',
	cors({
		origin: '*',
		allowMethods: ['GET', 'HEAD', 'OPTIONS'],
		allowHeaders: ['Content-Type', 'Authorization'],
		exposeHeaders: [
			'Content-Length',
			'Content-Type',
			'Content-Encoding',
			'Content-Range',
			'Accept-Ranges',
			'Cache-Control',
			'ETag',
		],
		maxAge: 86400, // 24 hours
		credentials: false,
	}),
)

// Add observability middleware
app.use('*', observabilityMiddleware('hosting-service'))

// Error handler
app.onError(observabilityErrorHandler('hosting-service'))

/**
 * Build the constant-time hosting health response without issuing disk or S3 I/O.
 *
 * @param snapshot Cache-invalidation connection state
 * @param configured Whether Redis invalidation is configured
 * @param storageSnapshot Safe public cold-tier read-health state
 * @returns Compact liveness/readiness state
 */
export function getCacheInvalidationHealthResponse(
	snapshot: CacheInvalidationHealthSnapshot,
	configured: boolean,
	storageSnapshot: StorageReadHealthSnapshot = getStorageReadHealthSnapshot(),
) {
	const replayHealthy = snapshot.replayConnected && snapshot.replayState === 'healthy'
	// Fail closed: the live pub/sub subscriber and the replay tail must both be
	// attached before invalidation can be called healthy. Reporting 'ok' with a
	// dead subscriber hides live-path outages behind replay's fallback.
	const subscriberHealthy = snapshot.subscriberConnected
	const storageHealthy = storageSnapshot.status === 'healthy' || storageSnapshot.status === 'not-configured'

	return {
		status: (!configured || (subscriberHealthy && replayHealthy)) && storageHealthy ? 'ok' : 'degraded',
		cacheInvalidation: {
			configured,
			subscriberConnected: snapshot.subscriberConnected,
			replayConnected: snapshot.replayConnected,
			replayState: snapshot.replayState,
			retrying: snapshot.retrying,
			gapCount: snapshot.gapCount,
			lastGapAt: snapshot.lastGapAt,
			lastGapRecoveryAt: snapshot.lastGapRecoveryAt,
			subscriberRecreations: snapshot.subscriberRecreations,
		},
		storage: {
			configured: storageSnapshot.configured,
			status: storageSnapshot.status,
			breaker: storageSnapshot.breaker,
			consecutiveFailures: storageSnapshot.consecutiveFailures,
			circuitRejections: storageSnapshot.circuitRejections,
			lastSuccessAgeMs: storageSnapshot.lastSuccessAgeMs,
			lastErrorKind: storageSnapshot.lastErrorKind,
		},
	}
}

app.get('/health', (c) => {
	const response = getCacheInvalidationHealthResponse(
		getCacheInvalidationHealthSnapshot(),
		Boolean(process.env.REDIS_URL),
	)
	// Fail closed: a degraded invalidation pipeline must surface as an unhealthy
	// probe (HTTP 503), not a 200 that orchestrators read as ready.
	return c.json(response, response.status === 'ok' ? 200 : 503)
})

// Liveness only: the process is up and serving HTTP. Dependency readiness is
// /health, which fails closed whenever invalidation or storage is degraded.
app.get('/live', (c) => c.json({ status: 'ok' }))

type SiteRequest = {
	url: URL
	hostname: string
	normalizedPath: string
	publicPath: string
}

type SiteRequestFailure = 'invalidUrlEncoding' | 'invalidPath'
type SiteRequestParseResult = { kind: 'valid'; request: SiteRequest } | { kind: SiteRequestFailure }

type SharedSitePath = {
	identifier: string
	site: string
	filePath: string
}

type SharedSiteError = 'invalidPathFormat' | 'invalidIdentifier' | 'siteNameRequired' | 'invalidSiteName'
type DnsHashHostname = { hash: string; baseDomain: string }

const SITE_REQUEST_ERROR_MESSAGES = {
	invalidUrlEncoding: 'Invalid URL encoding',
	invalidPath: 'Invalid path',
} satisfies Record<SiteRequestFailure, string>

const SHARED_SITE_ERROR_MESSAGES = {
	invalidPathFormat: 'Invalid path format. Expected: /identifier/sitename/path',
	invalidIdentifier: 'Invalid identifier',
	siteNameRequired: 'Site name required',
	invalidSiteName: 'Invalid site name',
} satisfies Record<SharedSiteError, string>

function parseSiteRequest(requestUrl: string): SiteRequestParseResult {
	const url = new URL(requestUrl)
	const decodedPathname = decodeRequestPathname(url.pathname)
	if (decodedPathname === null) return { kind: 'invalidUrlEncoding' }
	if (!decodedPathname.startsWith('/')) return { kind: 'invalidPath' }

	const rawPath = decodedPathname.slice(1)
	const normalizedPath = normalizeSitePath(rawPath, { allowTrailingSlash: true })
	if (normalizedPath === null) return { kind: 'invalidPath' }

	const publicPath = rawPath.endsWith('/') && normalizedPath ? `${normalizedPath}/` : normalizedPath
	return {
		kind: 'valid',
		request: { url, hostname: url.hostname.toLowerCase().replace(/\.$/, ''), normalizedPath, publicPath },
	}
}

function siteRequestErrorResponse(c: Context, error: SiteRequestFailure): Response {
	return c.text(SITE_REQUEST_ERROR_MESSAGES[error], 400)
}

function parseSharedSitePath(normalizedPath: string): SharedSitePath | null {
	const pathParts = normalizedPath.split('/')
	if (pathParts.length < 2) return null
	return {
		identifier: pathParts[0] ?? '',
		site: pathParts[1] ?? '',
		filePath: pathParts.slice(2).join('/'),
	}
}

function validateSharedSitePath(sitePath: SharedSitePath): SharedSiteError | null {
	if (!isValidAtprotoIdentifier(sitePath.identifier)) return 'invalidIdentifier'
	if (!sitePath.site) return 'siteNameRequired'
	if (!isValidRkey(sitePath.site)) return 'invalidSiteName'
	return null
}

function sharedSiteErrorResponse(c: Context, error: SharedSiteError): Response {
	return c.text(SHARED_SITE_ERROR_MESSAGES[error], 400)
}

function needsSharedSiteRootRedirect(request: SiteRequest, sitePath: SharedSitePath): boolean {
	return !sitePath.filePath && !request.url.pathname.endsWith('/')
}

function serveSharedSiteFile(c: Context, sitePath: SharedSitePath, did: string): Promise<Response> {
	logger.debug(
		`sites.wisp.place request: identifier=${sitePath.identifier}, site=${sitePath.site}, filePath=${sitePath.filePath}`,
	)
	const basePath = `/${sitePath.identifier}/${sitePath.site}/`
	logger.debug(`Serving with basePath: ${basePath}`)
	const headers = extractHeaders(c.req.raw.headers)
	return trackPublicSiteResponse(
		did,
		sitePath.site,
		c.req.method,
		serveFromCacheWithRewrite(did, sitePath.site, sitePath.filePath, basePath, c.req.url, headers),
	)
}

async function serveSharedSite(c: Context, request: SiteRequest): Promise<Response> {
	const sitePath = parseSharedSitePath(request.normalizedPath)
	if (!sitePath) return sharedSiteErrorResponse(c, 'invalidPathFormat')

	const validationError = validateSharedSitePath(sitePath)
	if (validationError) return sharedSiteErrorResponse(c, validationError)

	const did = await resolveDidCached(sitePath.identifier)
	if (!did) return sharedSiteErrorResponse(c, 'invalidIdentifier')
	if (needsSharedSiteRootRedirect(request, sitePath))
		return c.redirect(`${request.url.pathname}/${request.url.search}`, 301)
	return serveSharedSiteFile(c, sitePath, did)
}

function privateSiteResponse(c: Context, request: SiteRequest): Response | Promise<Response> | null {
	const privateSiteId = siteIdFromHostname(request.hostname, PRIVATE_HOST)
	if (privateSiteId !== null) return servePrivateSite(c.req.raw, privateSiteId, request.normalizedPath)
	return request.hostname === PRIVATE_HOST ? privateNotFound() : null
}

function parseDnsHashHostname(hostname: string): DnsHashHostname | null {
	const dnsMatch = hostname.match(/^([a-f0-9]{16})\.dns\.(.+)$/)
	if (!dnsMatch) return null
	return { hash: dnsMatch[1] ?? '', baseDomain: dnsMatch[2] ?? '' }
}

async function serveDnsHashDomain(c: Context, request: SiteRequest): Promise<Response | null> {
	const dnsHostname = parseDnsHashHostname(request.hostname)
	if (!dnsHostname) return null
	if (!dnsHostname.hash) return c.text('Invalid DNS hash', 400)
	if (dnsHostname.baseDomain !== BASE_HOST) return c.text('Invalid base domain', 400)

	const customDomain = await getCustomDomainByHash(dnsHostname.hash)
	return serveMappedPublicDomain(c, customDomain, request.publicPath, 'Custom domain not found or not verified')
}

async function servePublicDomain(c: Context, request: SiteRequest): Promise<Response> {
	const dnsResponse = await serveDnsHashDomain(c, request)
	if (dnsResponse) return dnsResponse

	if (request.hostname.endsWith(`.${BASE_HOST}`)) {
		const domainInfo = await getWispDomain(request.hostname)
		return serveMappedPublicDomain(c, domainInfo, request.publicPath, 'Subdomain not registered')
	}

	const customDomain = await getCustomDomain(request.hostname)
	return serveMappedPublicDomain(c, customDomain, request.publicPath, 'Custom domain not found or not verified')
}

function logSiteRequest(request: SiteRequest): void {
	logger.debug(`Request: hostname=${request.hostname} path=${request.publicPath}`)
}

async function routeSiteRequest(c: Context, request: SiteRequest): Promise<Response> {
	logSiteRequest(request)
	const privateResponse = privateSiteResponse(c, request)
	if (privateResponse) return await privateResponse
	if (request.hostname === `sites.${BASE_HOST}`) return await serveSharedSite(c, request)
	return await servePublicDomain(c, request)
}

// Main site serving route
app.get('/*', (c) => {
	const requestResult = parseSiteRequest(c.req.url)
	if (requestResult.kind !== 'valid') return siteRequestErrorResponse(c, requestResult.kind)
	return routeSiteRequest(c, requestResult.request)
})

export default app
