import { createRequire } from 'node:module'
import { cpus } from 'node:os'
import Path, { join, relative, resolve } from 'node:path'
import { resolvePath } from '@vxrn/resolve'
import FSExtra from 'fs-extra'
import MicroMatch from 'micromatch'
import type { OutputAsset, RolldownOutput } from 'rolldown'
import { type InlineConfig, mergeConfig, build as viteBuild } from 'vite'
import {
  type ClientManifestEntry,
  fillOptions,
  getOptimizeDeps,
  rollupRemoveUnusedImportsPlugin,
  build as vxrnBuild,
} from 'vxrn'

import * as constants from '../constants'
import { setServerGlobals } from '../server/setServerGlobals'
import { getPathnameFromFilePath } from '../utils/getPathnameFromFilePath'
import { getRouterRootFromOneOptions } from '../utils/getRouterRootFromOneOptions'
import { isRolldown } from '../utils/isRolldown'
import { toAbsolute } from '../utils/toAbsolute'
import { buildVercelOutputDirectory } from '../vercel/build/buildVercelOutputDirectory'
import { getManifest } from '../vite/getManifest'
import { loadUserOneOptions } from '../vite/loadConfig'
import { runWithAsyncLocalContext } from '../vite/one-server-only'
import type { DeployConfig, DeployTarget, One, RouteInfo } from '../vite/types'
import { buildPage, printBuildTimings } from './buildPage'
import { checkNodeVersion } from './checkNodeVersion'
import { getWorkerPool, terminateWorkerPool } from './workerPool'
import { generateSitemap, type RouteSitemapData } from './generateSitemap'
import { labelProcess } from './label-process'
import { pLimit } from '../utils/pLimit'
import { getCriticalCSSOutputPaths } from '../vite/plugins/criticalCSSPlugin'

const { ensureDir, writeJSON } = FSExtra

function normalizeDeploy(
  deploy?: DeployTarget | DeployConfig
): { target: DeployTarget; url?: string } | undefined {
  if (!deploy) return undefined
  if (typeof deploy === 'string') return { target: deploy }
  return deploy
}

type JsonPrimitive = string | number | boolean | null
type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue }

const GENERATED_CLOUDFLARE_WRANGLER_RULES = [
  { type: 'ESModule', globs: ['./server/**/*.js'], fallthrough: true },
  { type: 'ESModule', globs: ['./api/**/*.js'], fallthrough: true },
  { type: 'ESModule', globs: ['./middlewares/**/*.js'], fallthrough: true },
  { type: 'ESModule', globs: ['./assets/**/*.js'], fallthrough: true },
]

function isPlainObject(value: unknown): value is Record<string, JsonValue> {
  return !!value && typeof value === 'object' && !Array.isArray(value)
}

function mergeJsonObjects(
  base: Record<string, JsonValue>,
  overrides: Record<string, JsonValue>
): Record<string, JsonValue> {
  const merged: Record<string, JsonValue> = { ...base }

  for (const [key, value] of Object.entries(overrides)) {
    const baseValue = merged[key]
    if (isPlainObject(baseValue) && isPlainObject(value)) {
      merged[key] = mergeJsonObjects(baseValue, value)
    } else {
      merged[key] = value
    }
  }

  return merged
}

function dedupeJsonValues<T extends JsonValue>(values: T[]): T[] {
  const seen = new Set<string>()
  return values.filter((value) => {
    const key = JSON.stringify(value)
    if (seen.has(key)) return false
    seen.add(key)
    return true
  })
}

function mergeCloudflareCompatibilityFlags(flags: unknown): string[] {
  const userFlags = Array.isArray(flags)
    ? flags.filter((flag): flag is string => typeof flag === 'string')
    : []

  return dedupeJsonValues<string>(['nodejs_compat', ...userFlags])
}

function mergeCloudflareRules(rules: unknown): JsonValue[] {
  const userRules = Array.isArray(rules)
    ? rules.filter((rule): rule is JsonValue => isPlainObject(rule))
    : []

  return dedupeJsonValues<JsonValue>([
    ...GENERATED_CLOUDFLARE_WRANGLER_RULES,
    ...userRules,
  ])
}

// minimal JSONC parser: strips line/block comments (string-aware) and trailing
// commas, then runs JSON.parse. sufficient for small hand-written config files.
function parseJsonc(text: string): unknown {
  let out = ''
  let i = 0
  let inString = false
  let quote = ''
  while (i < text.length) {
    const ch = text[i]
    const next = text[i + 1]
    if (inString) {
      if (ch === '\\') {
        out += ch + (next ?? '')
        i += 2
        continue
      }
      if (ch === quote) inString = false
      out += ch
      i++
      continue
    }
    if (ch === '"' || ch === "'") {
      inString = true
      quote = ch
      out += ch
      i++
      continue
    }
    if (ch === '/' && next === '/') {
      while (i < text.length && text[i] !== '\n') i++
      continue
    }
    if (ch === '/' && next === '*') {
      i += 2
      while (i < text.length - 1 && !(text[i] === '*' && text[i + 1] === '/')) i++
      i += 2
      continue
    }
    out += ch
    i++
  }
  return JSON.parse(out.replace(/,(\s*[}\]])/g, '$1'))
}

async function loadUserWranglerConfig(
  root: string
): Promise<{ path: string; config: Record<string, JsonValue> } | null> {
  const candidateRoots = [...new Set([root, process.cwd()])]

  for (const candidateRoot of candidateRoots) {
    for (const fileName of ['wrangler.jsonc', 'wrangler.json']) {
      const configPath = join(candidateRoot, fileName)
      if (!(await FSExtra.pathExists(configPath))) {
        continue
      }

      const contents = await FSExtra.readFile(configPath, 'utf-8')
      let parsed: unknown
      try {
        parsed = parseJsonc(contents)
      } catch (err) {
        throw new Error(
          `Failed to parse ${relative(process.cwd(), configPath)}: ${(err as Error).message}`
        )
      }

      if (!isPlainObject(parsed)) {
        throw new Error(
          `Expected ${relative(process.cwd(), configPath)} to contain a top-level JSON object`
        )
      }

      return {
        path: configPath,
        config: parsed,
      }
    }
  }

  return null
}

function createCloudflareWranglerConfig(
  projectName: string,
  userConfig?: Record<string, JsonValue>
): Record<string, JsonValue> {
  const generatedConfig: Record<string, JsonValue> = {
    name: projectName,
    main: 'worker.js',
    compatibility_date: '2024-12-05',
    compatibility_flags: ['nodejs_compat'],
    find_additional_modules: true,
    rules: GENERATED_CLOUDFLARE_WRANGLER_RULES,
    assets: {
      directory: 'client',
      binding: 'ASSETS',
      run_worker_first: true,
    },
  }

  const mergedConfig = userConfig
    ? mergeJsonObjects(generatedConfig, userConfig)
    : generatedConfig

  mergedConfig.main = 'worker.js'
  mergedConfig.find_additional_modules = true
  mergedConfig.compatibility_flags = mergeCloudflareCompatibilityFlags(
    mergedConfig.compatibility_flags
  )
  mergedConfig.rules = mergeCloudflareRules(mergedConfig.rules)
  mergedConfig.assets = {
    ...(isPlainObject(mergedConfig.assets) ? mergedConfig.assets : {}),
    directory: 'client',
    binding: 'ASSETS',
    run_worker_first: true,
  }

  return mergedConfig
}

// reads package.json name, strips npm scope prefix for use as cloudflare worker name
async function getCloudflareProjectName(root: string): Promise<string> {
  try {
    const pkg = JSON.parse(await FSExtra.readFile(join(root, 'package.json'), 'utf-8'))
    if (pkg.name) {
      return pkg.name.replace(/^@[^/]+\//, '')
    }
  } catch {}
  return 'one-app'
}

// concurrency limit for parallel page builds
// can be overridden with ONE_BUILD_CONCURRENCY env var
// default based on CPU count for I/O parallelism benefits
// ensure worker threads inherit the same CACHE_KEY as the main process
process.env.ONE_CACHE_KEY = constants.CACHE_KEY

const BUILD_CONCURRENCY = process.env.ONE_BUILD_CONCURRENCY
  ? Math.max(1, parseInt(process.env.ONE_BUILD_CONCURRENCY, 10))
  : Math.max(1, Math.min(cpus().length, 8))

// worker threads enabled by default, can be disabled via config or env var
function shouldUseWorkers(oneOptions?: { build?: { workers?: boolean } }) {
  // env var takes precedence (ONE_BUILD_WORKERS=0 to disable, =1 to force enable)
  if (process.env.ONE_BUILD_WORKERS === '0') return false
  if (process.env.ONE_BUILD_WORKERS === '1') return true
  // then check config option (defaults to true)
  return oneOptions?.build?.workers !== false
}

process.on('uncaughtException', (err) => {
  console.error(err?.message || err)
})

const HOOK_KEYS = [
  'resolveId',
  'load',
  'transform',
  'renderChunk',
  'generateBundle',
  'writeBundle',
  'buildStart',
  'buildEnd',
  'moduleParsed',
]

// vite defines non-configurable getters on plugin hook objects during a build.
// when the same plugins are reused in a second build (eg api routes), the new
// vite instance can't redefine those properties and throws. this clones the
// hook objects so each build gets its own references.
function clonePluginHooks(config: InlineConfig): InlineConfig {
  if (!config.plugins) return config
  return {
    ...config,
    plugins: config.plugins.map((p: any) => {
      if (!p || typeof p !== 'object') return p
      const cloned = { ...p }
      for (const key of HOOK_KEYS) {
        if (cloned[key] && typeof cloned[key] === 'object' && 'handler' in cloned[key]) {
          cloned[key] = { ...cloned[key] }
        }
      }
      return cloned
    }),
  }
}

export async function build(args: {
  step?: string
  only?: string
  platform?: 'ios' | 'web' | 'android'
  skipEnv?: boolean
}) {
  process.env.IS_VXRN_CLI = 'true'

  // set NODE_ENV, do before loading vite.config (see loadConfigFromFile)
  if (!process.env.NODE_ENV) {
    process.env.NODE_ENV = 'production'
  } else if (process.env.NODE_ENV !== 'production') {
    console.warn(
      `\n ⚠️  Warning: NODE_ENV is set to "${process.env.NODE_ENV}" (builds default to "production")\n`
    )
  }

  labelProcess('build')
  checkNodeVersion()
  setServerGlobals()

  const { oneOptions, config: viteLoadedConfig } = await loadUserOneOptions('build')
  const routerRoot = getRouterRootFromOneOptions(oneOptions)

  // Set defaultRenderMode env var so getManifest knows the correct route types
  if (oneOptions.web?.defaultRenderMode) {
    process.env.ONE_DEFAULT_RENDER_MODE = oneOptions.web.defaultRenderMode
  }

  const deployConfig = normalizeDeploy(oneOptions.web?.deploy)

  // auto-detect ONE_SERVER_URL from deploy config when not explicitly set
  if (!process.env.ONE_SERVER_URL && deployConfig) {
    const url =
      deployConfig.url ??
      (deployConfig.target === 'cloudflare'
        ? `https://${await getCloudflareProjectName(process.cwd())}.workers.dev`
        : undefined)

    if (url) {
      process.env.ONE_SERVER_URL = url
      console.info(`\n ☁️ ONE_SERVER_URL: ${url}\n`)
    }
  }

  // respect vite's build.outDir config, default to 'dist'
  const outDir = viteLoadedConfig?.config?.build?.outDir ?? 'dist'

  const manifest = getManifest({
    routerRoot,
    ignoredRouteFiles: oneOptions.router?.ignoredRouteFiles,
  })!

  const serverOutputFormat =
    oneOptions.build?.server === false
      ? 'esm'
      : (oneOptions.build?.server?.outputFormat ?? 'esm')

  const buildStartTime = performance.now()

  const vxrnOutput = await vxrnBuild(
    {
      skipEnv: args.skipEnv ?? oneOptions.skipEnv,
      server: oneOptions.server,
      build: {
        analyze: true,
        server:
          oneOptions.build?.server === false
            ? false
            : {
                outputFormat: serverOutputFormat,
              },
      },
    },
    args
  )

  const bundleTime = performance.now() - buildStartTime
  console.info(`\n ⏱️ vite bundle: ${(bundleTime / 1000).toFixed(2)}s\n`)

  if (!vxrnOutput || args.platform !== 'web') {
    return
  }

  const options = await fillOptions(vxrnOutput.options, { mode: 'prod' })

  const { optimizeDeps } = getOptimizeDeps('build')
  const { rolldownOptions: _rolldownOptions, ...optimizeDepsNoRolldown } = optimizeDeps

  // clone plugin hooks so vite's wrapHookObject doesn't fail on reuse across builds
  // (vite defines non-configurable getters on hook objects during the first build)
  const clonedWebBuildConfig = clonePluginHooks(vxrnOutput.webBuildConfig)

  const apiBuildConfig = mergeConfig(
    // feels like this should build off the *server* build config not web
    clonedWebBuildConfig,
    {
      configFile: false,
      appType: 'custom',
      optimizeDeps: optimizeDepsNoRolldown,
      environments: {
        client: {
          optimizeDeps: { rolldownOptions: _rolldownOptions },
        },
      },
    } satisfies InlineConfig
  )

  async function buildCustomRoutes(subFolder: string, routes: RouteInfo<string>[]) {
    const input = routes.reduce((entries, { page, file }) => {
      entries[page.slice(1) + '.js'] = join(routerRoot, file)
      return entries
    }, {}) as Record<string, string>

    // TODO this is specific to API but used for middelwares too now
    const outputFormat = oneOptions?.build?.api?.outputFormat ?? serverOutputFormat
    const treeshake = oneOptions?.build?.api?.treeshake

    const mergedConfig = mergeConfig(apiBuildConfig, {
      appType: 'custom',
      configFile: false,

      // plugins: [
      //   nodeExternals({
      //     exclude: optimizeDeps.include,
      //   }) as any,
      // ],

      define: vxrnOutput!.processEnvDefines,

      ssr: {
        noExternal: true,
        external: ['react', 'react-dom'],
        optimizeDeps: optimizeDepsNoRolldown,
      },

      environments: {
        ssr: {
          optimizeDeps: { rolldownOptions: _rolldownOptions },
        },
      },

      build: {
        ssr: true,
        emptyOutDir: false,
        outDir: `${outDir}/${subFolder}`,
        copyPublicDir: false,
        minify: false,
        rolldownOptions: {
          treeshake: treeshake ?? {
            moduleSideEffects: false,
          },

          plugins: [
            // otherwise rollup is leaving commonjs-only top level imports...
            outputFormat === 'esm' ? rollupRemoveUnusedImportsPlugin : null,
          ].filter(Boolean),

          // too many issues
          // treeshake: {
          //   moduleSideEffects: false,
          // },
          // prevents it from shaking out the exports
          preserveEntrySignatures: 'strict',
          input: input,
          external: [],
          output: {
            entryFileNames: '[name]',
            exports: 'auto',
            ...(outputFormat === 'esm'
              ? {
                  format: 'esm',
                  esModule: true,
                }
              : {
                  format: 'cjs',
                  // Preserve folder structure and use .cjs extension
                  entryFileNames: (chunkInfo) => {
                    const name = chunkInfo.name.replace(/\.js$/, '.cjs')
                    return name
                  },
                  chunkFileNames: (chunkInfo) => {
                    const dir = Path.dirname(chunkInfo.name)
                    const name = Path.basename(
                      chunkInfo.name,
                      Path.extname(chunkInfo.name)
                    )
                    return Path.join(dir, `${name}-[hash].cjs`)
                  },
                  assetFileNames: (assetInfo) => {
                    const name = assetInfo.name ?? ''
                    const dir = Path.dirname(name)
                    const baseName = Path.basename(name, Path.extname(name))
                    const ext = Path.extname(name)
                    return Path.join(dir, `${baseName}-[hash]${ext}`)
                  },
                }),
          },
        },
      },
    } satisfies InlineConfig)

    const userApiBuildConf = oneOptions.build?.api?.config

    const finalApiBuildConf = userApiBuildConf
      ? mergeConfig(mergedConfig, userApiBuildConf)
      : mergedConfig

    const output = await viteBuild(
      // allow user merging api build config
      finalApiBuildConf
    )

    return output as RolldownOutput
  }

  // build api routes and middlewares in parallel
  const builtMiddlewares: Record<string, string> = {}

  const apiPromise = manifest.apiRoutes.length
    ? (console.info(`\n 🔨 build api routes\n`),
      buildCustomRoutes('api', manifest.apiRoutes))
    : Promise.resolve(null)

  const middlewarePromise = manifest.middlewareRoutes.length
    ? (console.info(`\n 🔨 build middlewares\n`),
      buildCustomRoutes('middlewares', manifest.middlewareRoutes))
    : Promise.resolve(null)

  const [apiOutput, middlewareBuildInfo] = await Promise.all([
    apiPromise,
    middlewarePromise,
  ])

  if (middlewareBuildInfo) {
    for (const middleware of manifest.middlewareRoutes) {
      const absoluteRoot = resolve(process.cwd(), options.root)
      const fullPath = join(absoluteRoot, routerRoot, middleware.file)
      const outChunks = middlewareBuildInfo.output.filter((x) => x.type === 'chunk')
      const chunk = outChunks.find((x) => x.facadeModuleId === fullPath)
      if (!chunk) throw new Error(`internal err finding middleware`)
      builtMiddlewares[middleware.file] = join(outDir, 'middlewares', chunk.fileName)
    }
  }

  // for the require Sitemap in getRoutes
  globalThis['require'] = createRequire(join(import.meta.url, '..'))

  const assets: OutputAsset[] = []

  const builtRoutes: One.RouteBuildInfo[] = []
  const sitemapData: RouteSitemapData[] = []

  // caches for expensive operations
  const collectImportsCache = new Map<string, string[]>()
  const cssFileContentsCache = new Map<string, string>()

  // css files with .inline.css extension — should be inlined as <style>
  const criticalCSSOutputPaths = getCriticalCSSOutputPaths(vxrnOutput.clientManifest)

  // concurrency limiter for parallel page builds
  const limit = pLimit(BUILD_CONCURRENCY)

  // initialize worker pool if enabled (default: true)
  const useWorkers = shouldUseWorkers(oneOptions)
  const workerPool = useWorkers ? getWorkerPool(BUILD_CONCURRENCY) : null
  if (workerPool) {
    // strip non-cloneable values so workers skip re-loading vite config
    const serializableOptions = JSON.parse(
      JSON.stringify(oneOptions, (_key, value) =>
        typeof value === 'function' ? undefined : value
      )
    )
    await workerPool.initialize(serializableOptions)
  }

  const staticStartTime = performance.now()
  const modeLabel = useWorkers
    ? `workers: ${workerPool?.size}`
    : `concurrency: ${BUILD_CONCURRENCY}`
  console.info(`\n 🔨 build static routes (${modeLabel})\n`)

  const staticDir = join(`${outDir}/static`)
  const clientDir = join(`${outDir}/client`)
  await ensureDir(staticDir)

  if (!vxrnOutput.serverOutput) {
    throw new Error(`No server output`)
  }

  // build a direct mapping from source file path to client chunk info
  // this is more reliable than manifest.json which can have ambiguous keys
  const clientChunksBySource = new Map<string, { fileName: string; imports: string[] }>()
  if (vxrnOutput.clientOutput) {
    for (const chunk of vxrnOutput.clientOutput) {
      if (chunk.type === 'chunk' && chunk.facadeModuleId) {
        clientChunksBySource.set(chunk.facadeModuleId, {
          fileName: chunk.fileName,
          imports: chunk.imports || [],
        })
      }
    }
  }

  const outputEntries = [...vxrnOutput.serverOutput.entries()]

  // build a map of layout contextKey -> server output fileName
  // this is used to run layout loaders in production
  const layoutServerPaths = new Map<string, string>()
  for (const [, output] of outputEntries) {
    if (output.type === 'asset') continue
    const id = output.facadeModuleId || ''
    const file = Path.basename(id)
    // layout files start with _layout
    if (file.startsWith('_layout') && id.includes(`/${routerRoot}/`)) {
      // contextKey format is "./_layout.tsx" or "./subdir/_layout.tsx"
      const relativePath = relative(process.cwd(), id).replace(`${routerRoot}/`, '')
      const contextKey = `./${relativePath}`
      layoutServerPaths.set(contextKey, output.fileName)
    }
  }

  // build a map for O(1) route lookups instead of O(n) find per route
  const routeByPath = new Map<string, RouteInfo<string>>()
  for (const route of manifest.pageRoutes) {
    if (route.file) {
      const routePath = `${routerRoot}${route.file.slice(1)}`
      routeByPath.set(routePath, route)
    }
  }

  // collect assets from output
  for (const [, output] of outputEntries) {
    if (output.type === 'asset') {
      assets.push(output)
    }
  }

  // build a map from module ID to server chunk for route matching
  // when experimentalMinChunkSize merges chunks, facadeModuleId only reflects
  // one module. we check ALL moduleIds in each chunk to find routes.
  const moduleIdToServerChunk = new Map<string, string>()
  for (const [, output] of outputEntries) {
    if (output.type === 'asset') continue
    const moduleIds =
      output.moduleIds || (output.facadeModuleId ? [output.facadeModuleId] : [])
    for (const moduleId of moduleIds) {
      moduleIdToServerChunk.set(moduleId, output.fileName)
    }
  }

  // iterate over routes (not chunks) to ensure all SSG routes are processed
  // even when experimentalMinChunkSize merges their chunks
  for (const foundRoute of manifest.pageRoutes) {
    if (!foundRoute.file) {
      continue
    }

    // resolve the full module path for this route
    const routeModulePath = join(
      resolve(process.cwd(), options.root),
      routerRoot,
      foundRoute.file.slice(2)
    )

    // find the server chunk containing this route
    const serverFileName = moduleIdToServerChunk.get(routeModulePath)
    if (!serverFileName) {
      // SPA routes may not have server chunks - that's expected
      if (foundRoute.type === 'spa') {
        continue
      }
      console.warn(`[one] No server chunk found for route: ${foundRoute.file}`)
      continue
    }

    const onlyBuild = vxrnOutput.buildArgs?.only
    if (onlyBuild) {
      const relativeId = foundRoute.file.slice(1)
      if (!MicroMatch.contains(relativeId, onlyBuild)) {
        continue
      }
    }

    // look up client chunk directly by source file path (from rollup output)
    const clientChunk = clientChunksBySource.get(routeModulePath)

    // also look up in manifest for additional info (css, nested imports, etc)
    const manifestKey = `${routerRoot}${foundRoute.file.slice(1)}`
    const clientManifestEntry = vxrnOutput.clientManifest[manifestKey]

    // SPA and SSG routes may not have client chunks - that's expected
    if (!clientChunk && foundRoute.type !== 'spa' && foundRoute.type !== 'ssg') {
      console.warn(`No client chunk found for route: ${routeModulePath}`)
      continue
    }

    foundRoute.loaderServerPath = serverFileName

    // relativeId is used for logging and path generation
    // foundRoute.file starts with "./" but getPathnameFromFilePath expects "/" prefix
    const relativeId = foundRoute.file.replace(/^\.\//, '/')

    // attach layout server paths for running layout loaders in production
    if (foundRoute.layouts) {
      for (const layout of foundRoute.layouts) {
        const serverPath = layoutServerPaths.get(layout.contextKey)
        if (serverPath) {
          layout.loaderServerPath = serverPath
        }
      }
    }

    function collectImports(
      entry: ClientManifestEntry,
      { type = 'js' }: { type?: 'js' | 'css' } = {}
    ): string[] {
      const { imports = [], css } = entry
      // use entry.file as cache key (unique per manifest entry)
      const cacheKey = `${entry.file || imports.join(',')}:${type}`
      const cached = collectImportsCache.get(cacheKey)
      if (cached) return cached

      const result = [
        ...new Set(
          [
            ...(type === 'js' ? imports : css || []),
            ...imports.flatMap((name) => {
              const found = vxrnOutput!.clientManifest[name]
              if (!found) {
                console.warn(`No found imports`, name, vxrnOutput!.clientManifest)
              }
              return collectImports(found, { type })
            }),
          ]
            .flat()
            .filter((x) => x && (type === 'css' || x.endsWith('.js')))
            .map((x) =>
              type === 'css' ? x : x.startsWith('assets/') ? x : `assets/${x.slice(1)}`
            )
        ),
      ]
      collectImportsCache.set(cacheKey, result)
      return result
    }

    const entryImports = collectImports(clientManifestEntry || {})

    // TODO isn't this getting all layouts not just the ones for this route?
    const layoutEntries =
      foundRoute.layouts?.flatMap((layout) => {
        const clientKey = `${routerRoot}${layout.contextKey.slice(1)}`
        const found = vxrnOutput.clientManifest[clientKey]
        return found ? found : []
      }) ?? []

    const layoutImports = layoutEntries.flatMap((entry) => {
      return [entry.file, ...collectImports(entry)]
    })

    // create mapping of route keys to bundle paths for hydration preloading
    const routePreloads: Record<string, string> = {}

    // add root layout
    const rootLayoutKey = `${routerRoot}/_layout.tsx`
    const rootLayoutEntry = vxrnOutput.clientManifest[rootLayoutKey]
    if (rootLayoutEntry) {
      routePreloads[`/${rootLayoutKey}`] = `/${rootLayoutEntry.file}`
    }

    // add all layouts for this route
    if (foundRoute.layouts) {
      for (const layout of foundRoute.layouts) {
        const clientKey = `${routerRoot}${layout.contextKey.slice(1)}`
        const entry = vxrnOutput.clientManifest[clientKey]
        if (entry) {
          routePreloads[`/${clientKey}`] = `/${entry.file}`
        }
      }
    }

    // add the page itself using the direct chunk lookup (more reliable than manifest)
    if (clientChunk) {
      const routeKey = `/${routerRoot}${foundRoute.file.slice(1)}`
      routePreloads[routeKey] = `/${clientChunk.fileName}`
    } else if (clientManifestEntry) {
      // fallback to manifest if no chunk (shouldn't happen normally)
      const routeKey = `/${routerRoot}${foundRoute.file.slice(1)}`
      routePreloads[routeKey] = `/${clientManifestEntry.file}`
    }

    const preloadSetupFilePreloads = (() => {
      if (!oneOptions.setupFile) return []

      // Get the client setup file path
      const clientSetupFile =
        typeof oneOptions.setupFile === 'string'
          ? oneOptions.setupFile
          : oneOptions.setupFile.client

      if (!clientSetupFile) return []

      const needle = clientSetupFile.replace(/^\.\//, '')
      for (const file in vxrnOutput.clientManifest) {
        if (file === needle) {
          const entry = vxrnOutput.clientManifest[file]
          return [
            entry.file as string,
            // getting 404s for preloading the imports as well?
            // ...(entry.imports as string[])
          ]
        }
      }

      return []
    })()

    // All preloads combined (original behavior)
    const allPreloads = [
      ...new Set([
        ...preloadSetupFilePreloads,
        // add the route entry js (like ./app/index.ts) - prefer direct chunk lookup
        ...(clientChunk
          ? [clientChunk.fileName]
          : clientManifestEntry
            ? [clientManifestEntry.file]
            : []),
        // add the virtual entry
        vxrnOutput.clientManifest['virtual:one-entry'].file,
        ...entryImports,
        ...layoutImports,
      ]),
    ].map((path) => `/${path}`)

    // Check experimental script loading mode
    const scriptLoadingMode = oneOptions.web?.experimental_scriptLoading

    // Modes that need separated critical/deferred preloads
    const useDeferredLoading = scriptLoadingMode === 'defer-non-critical'
    const useAggressiveLCP = scriptLoadingMode === 'after-lcp-aggressive'
    const needsSeparatedPreloads = useDeferredLoading || useAggressiveLCP

    // Critical: scripts that must execute immediately (entry points, layouts)
    const criticalPreloads = needsSeparatedPreloads
      ? [
          ...new Set([
            ...preloadSetupFilePreloads,
            // add the virtual entry (framework bootstrap)
            vxrnOutput.clientManifest['virtual:one-entry'].file,
            // add the route entry js (like ./app/index.ts) - prefer direct chunk lookup
            ...(clientChunk
              ? [clientChunk.fileName]
              : clientManifestEntry
                ? [clientManifestEntry.file]
                : []),
            // add layout files (but not their deep imports)
            ...layoutEntries.map((entry) => entry.file),
          ]),
        ].map((path) => `/${path}`)
      : undefined

    // Non-critical: component imports, utilities - will be modulepreload hints only
    const deferredPreloads = needsSeparatedPreloads
      ? [
          ...new Set([
            ...entryImports,
            ...layoutEntries.flatMap((entry) => collectImports(entry)),
          ]),
        ]
          .filter((path) => !criticalPreloads!.includes(`/${path}`))
          .map((path) => `/${path}`)
      : undefined

    // Use all preloads when not using deferred loading
    const preloads = needsSeparatedPreloads
      ? [...criticalPreloads!, ...deferredPreloads!]
      : allPreloads

    const allEntries = [clientManifestEntry, ...layoutEntries].filter(Boolean)

    // layout css (from layout entries) - should load before scripts to prevent FOUC
    const layoutCSS = [
      ...new Set(
        layoutEntries
          .flatMap((entry) => collectImports(entry, { type: 'css' }))
          .map((path) => `/${path}`)
      ),
    ]

    // all css including page entry and root-level css
    const allCSS = [
      ...new Set([
        ...layoutCSS,
        // css from page entry
        ...(clientManifestEntry
          ? collectImports(clientManifestEntry, { type: 'css' }).map((path) => `/${path}`)
          : []),
        // root-level css (handles cssCodeSplit: false)
        ...Object.entries(vxrnOutput.clientManifest)
          .filter(([key]) => key.endsWith('.css'))
          .map(([, entry]) => `/${(entry as ClientManifestEntry).file}`),
      ]),
    ]

    // check if any css needs inlining (inlineLayoutCSS option or .inline.css imports)
    const hasCriticalCSS = allCSS.some((p) => criticalCSSOutputPaths.has(p))
    const needsCSSContents = oneOptions.web?.inlineLayoutCSS || hasCriticalCSS

    // read css file contents for inlining (with caching)
    let allCSSContents: string[] | undefined
    if (needsCSSContents) {
      allCSSContents = await Promise.all(
        allCSS.map(async (cssPath) => {
          // only read contents for css that should be inlined:
          // - all css when inlineLayoutCSS is enabled
          // - only .inline.css otherwise
          if (!oneOptions.web?.inlineLayoutCSS && !criticalCSSOutputPaths.has(cssPath)) {
            return ''
          }

          // check cache first
          const cached = cssFileContentsCache.get(cssPath)
          if (cached !== undefined) return cached

          const filePath = join(clientDir, cssPath)
          try {
            const content = await FSExtra.readFile(filePath, 'utf-8')
            cssFileContentsCache.set(cssPath, content)
            return content
          } catch (err) {
            console.warn(`[one] Warning: Could not read CSS file ${filePath}`)
            cssFileContentsCache.set(cssPath, '')
            return ''
          }
        })
      )
    }

    if (process.env.DEBUG) {
      console.info('[one] building routes', {
        foundRoute,
        layoutEntries,
        allEntries,
        allCSS,
      })
    }

    const serverJsPath = join(`${outDir}/server`, serverFileName)

    let exported
    try {
      exported = await import(toAbsolute(serverJsPath))
    } catch (err) {
      console.error(`Error importing page (original error)`, err)
      // err cause not showing in vite or something
      throw new Error(`Error importing page: ${serverJsPath}`, {
        cause: err,
      })
    }

    // record whether this route exports a loader. the worker checks this and
    // skips importing the page bundle for loader requests on routes with no
    // loader — otherwise workerd may crash evaluating RN/Tamagui deps.
    foundRoute.hasLoader = typeof exported.loader === 'function'

    const isDynamic = !!Object.keys(foundRoute.routeKeys).length

    if (
      foundRoute.type === 'ssg' &&
      isDynamic &&
      !foundRoute.page.includes('+not-found') &&
      !foundRoute.page.includes('_sitemap') &&
      !exported.generateStaticParams
    ) {
      throw new Error(`[one] Error: Missing generateStaticParams

  Route ${foundRoute.page} of type ${foundRoute.type} must export generateStaticParams so build can complete.

  See docs on generateStaticParams:
    https://onestack.dev/docs/routing-exports#generatestaticparams

`)
    }

    const paramsList = ((await exported.generateStaticParams?.()) ?? [{}]) as Record<
      string,
      string
    >[]

    console.info(`\n [build] page ${relativeId} (with ${paramsList.length} routes)\n`)

    if (process.env.DEBUG) {
      console.info(`paramsList`, JSON.stringify(paramsList, null, 2))
    }

    // Get route-level sitemap export if present
    const routeSitemapExport = exported.sitemap as One.RouteSitemap | undefined

    // Determine if after-lcp script loading should be used for this route
    // Only applies to SSG pages (SPA pages need JS to render anything)
    const isAfterLCPMode =
      scriptLoadingMode === 'after-lcp' || scriptLoadingMode === 'after-lcp-aggressive'
    const useAfterLCP = foundRoute.type === 'ssg' && isAfterLCPMode
    const useAfterLCPAggressive =
      foundRoute.type === 'ssg' && scriptLoadingMode === 'after-lcp-aggressive'

    // determine if this route can be built in parallel
    // routes that use sitemap exports or have side effects should be sequential
    const shouldCollectSitemap =
      foundRoute.type !== 'api' &&
      foundRoute.type !== 'layout' &&
      !foundRoute.isNotFound &&
      !foundRoute.page.includes('+not-found') &&
      !foundRoute.page.includes('_sitemap')

    // build pages in parallel with concurrency limit (or workers if enabled)
    const pageBuilds = paramsList.map((params) => {
      const path = getPathnameFromFilePath(relativeId, params, foundRoute.type === 'ssg')

      // use worker pool for true multicore parallelism if enabled
      if (workerPool) {
        console.info(`  ↦ route ${path}`)
        return workerPool
          .buildPage({
            serverEntry: vxrnOutput.serverEntry,
            path,
            relativeId,
            params,
            foundRoute,
            clientManifestEntry,
            staticDir,
            clientDir,
            builtMiddlewares,
            serverJsPath,
            preloads,
            allCSS,
            layoutCSS,
            routePreloads,
            allCSSContents,
            criticalPreloads,
            deferredPreloads,
            useAfterLCP,
            useAfterLCPAggressive,
          })
          .then((built) => ({ built, path }))
          .catch((err) => {
            console.warn(`  ⚠ skipping page ${path}: ${err.message}`)
            return null
          })
      }

      // fallback to pLimit for async parallelism
      return limit(async () => {
        console.info(`  ↦ route ${path}`)

        try {
          const built = await runWithAsyncLocalContext(async () => {
            return await buildPage(
              vxrnOutput.serverEntry,
              path,
              relativeId,
              params,
              foundRoute,
              clientManifestEntry,
              staticDir,
              clientDir,
              builtMiddlewares,
              serverJsPath,
              preloads,
              allCSS,
              layoutCSS,
              routePreloads,
              allCSSContents,
              criticalPreloads,
              deferredPreloads,
              useAfterLCP,
              useAfterLCPAggressive
            )
          })

          return { built, path }
        } catch (err: any) {
          console.warn(`  ⚠ skipping page ${path}: ${err.message}`)
          return null
        }
      })
    })

    const results = (await Promise.all(pageBuilds)).filter(Boolean) as {
      built: any
      path: string
    }[]

    for (const { built, path } of results) {
      builtRoutes.push(built)

      // Collect sitemap data for page routes (exclude API, not-found, layouts)
      if (shouldCollectSitemap) {
        sitemapData.push({
          path,
          routeExport: routeSitemapExport,
        })
      }
    }
  }

  // terminate worker pool if used
  if (workerPool) {
    await terminateWorkerPool()
  }

  const staticTime = performance.now() - staticStartTime
  console.info(
    `\n ⏱️ static routes: ${(staticTime / 1000).toFixed(2)}s (${builtRoutes.length} pages)\n`
  )
  printBuildTimings()

  // once done building static we can move it to client dir:
  await moveAllFiles(staticDir, clientDir)
  await FSExtra.rm(staticDir, { force: true, recursive: true })

  // write out the static paths (pathname => html) for the server
  const routeMap: Record<string, string> = {}
  const routeToBuildInfo: Record<string, Omit<One.RouteBuildInfo, 'loaderData'>> = {}
  const pathToRoute: Record<string, string> = {}
  const preloads: Record<string, boolean> = {}
  const cssPreloads: Record<string, boolean> = {}
  const loaders: Record<string, boolean> = {}

  for (const route of builtRoutes) {
    if (!route.cleanPath.includes('*')) {
      routeMap[route.cleanPath] = route.htmlPath
    }
    const {
      // dont include loaderData it can be huge
      loaderData: _loaderData,
      ...rest
    } = route

    routeToBuildInfo[route.routeFile] = rest
    for (const p of getCleanPaths([route.path, route.cleanPath])) {
      pathToRoute[p] = route.routeFile
    }
    preloads[route.preloadPath] = true
    cssPreloads[route.cssPreloadPath] = true
    loaders[route.loaderPath] = true
  }

  function createBuildManifestRoute(route: RouteInfo) {
    // remove the full layouts (they're huge with all children), but keep minimal info
    // needed for running layout loaders in production
    const { layouts, ...built } = route

    // keep simplified layout info for loader execution
    if (layouts?.length) {
      ;(built as any).layouts = layouts.map((layout) => ({
        contextKey: layout.contextKey,
        loaderServerPath: (layout as any).loaderServerPath,
      }))
    }

    // swap out for the built middleware path
    const buildInfo = builtRoutes.find((x) => x.routeFile === route.file)
    if (built.middlewares && buildInfo?.middlewares) {
      for (const [index, mw] of built.middlewares.entries()) {
        mw.contextKey = buildInfo.middlewares[index]
      }
    }

    if (buildInfo) {
      built.loaderPath = buildInfo.loaderPath
    }

    return built
  }

  const buildInfoForWriting: One.BuildInfo = {
    outDir,
    oneOptions,
    routeToBuildInfo,
    pathToRoute,
    manifest: {
      pageRoutes: manifest.pageRoutes.map(createBuildManifestRoute),
      apiRoutes: manifest.apiRoutes.map(createBuildManifestRoute),
      allRoutes: manifest.allRoutes.map(createBuildManifestRoute),
    },
    routeMap,
    constants: JSON.parse(JSON.stringify({ ...constants })) as any,
    preloads,
    cssPreloads,
    loaders,
    useRolldown: await isRolldown(),
  }

  await writeJSON(toAbsolute(`${outDir}/buildInfo.json`), buildInfoForWriting)

  // emit version.json for skew protection polling
  await FSExtra.writeFile(
    join(clientDir, 'version.json'),
    JSON.stringify({ version: constants.CACHE_KEY })
  )
  console.info(`\n 🛡 skew protection: emitted version.json\n`)

  // Generate sitemap.xml if enabled
  const sitemapConfig = oneOptions.web?.sitemap
  if (sitemapConfig) {
    const sitemapOptions: One.SitemapOptions =
      typeof sitemapConfig === 'boolean' ? {} : sitemapConfig

    const sitemapXml = generateSitemap(sitemapData, sitemapOptions)
    const sitemapPath = join(clientDir, 'sitemap.xml')
    await FSExtra.writeFile(sitemapPath, sitemapXml)
    console.info(`\n 📄 generated sitemap.xml (${sitemapData.length} URLs)\n`)
  }

  const postBuildLogs: string[] = []

  const platform = deployConfig?.target

  if (platform) {
    postBuildLogs.push(`[one.build] platform ${platform}`)
  }

  switch (platform) {
    case 'vercel': {
      // Check for cleanUrls in vercel.json - required for SSG direct URL access
      const vercelJsonPath = join(options.root, 'vercel.json')
      if (FSExtra.existsSync(vercelJsonPath)) {
        try {
          const vercelConfig = JSON.parse(FSExtra.readFileSync(vercelJsonPath, 'utf-8'))
          if (!vercelConfig.cleanUrls) {
            console.warn(`\n ⚠️  Warning: Your vercel.json is missing "cleanUrls": true`)
            console.warn(`    Without this, direct navigation to SSG pages will 404.`)
            console.warn(`    Add "cleanUrls": true to your vercel.json to fix this.\n`)
          }
        } catch {
          // ignore parse errors
        }
      }

      await buildVercelOutputDirectory({
        apiOutput,
        buildInfoForWriting,
        clientDir,
        oneOptionsRoot: options.root,
        postBuildLogs,
      })

      break
    }

    case 'cloudflare': {
      // Generate lazy import functions - modules load on-demand, not all upfront
      // Uses find_additional_modules in wrangler config to keep modules separate
      const pageRouteMap: string[] = []
      const apiRouteMap: string[] = []
      const middlewareRouteMap: string[] = []

      // Generate lazy imports for SSR/SSG page server bundles
      for (const [routeFile, info] of Object.entries(
        buildInfoForWriting.routeToBuildInfo
      )) {
        if (info.serverJsPath) {
          const importPath =
            './' + info.serverJsPath.replace(new RegExp(`^${outDir}/`), '')
          pageRouteMap.push(`  '${routeFile}': () => import('${importPath}')`)
        }
      }

      // Generate lazy imports for API routes
      for (const route of buildInfoForWriting.manifest.apiRoutes) {
        if (route.file) {
          // API files are built to dist/api/
          // route.page is like "/api/hello", files are at "dist/api/api/hello.js"
          // both vite and rolldown-vite replace brackets with underscores in output filenames
          const apiFileName = route.page.slice(1).replace(/\[/g, '_').replace(/\]/g, '_')
          const importPath = `./api/${apiFileName}.js`
          apiRouteMap.push(`  '${route.page}': () => import('${importPath}')`)
        }
      }

      // Generate lazy imports for middlewares
      // The key must match the contextKey used to look up the middleware (e.g., "dist/middlewares/_middleware.js")
      for (const [, builtPath] of Object.entries(builtMiddlewares)) {
        const importPath = './' + builtPath.replace(new RegExp(`^${outDir}/`), '')
        middlewareRouteMap.push(`  '${builtPath}': () => import('${importPath}')`)
      }

      const workerSrcPath = join(options.root, outDir, '_worker-src.js')
      const workerCode = `// Polyfill MessageChannel for React SSR (not available in Cloudflare Workers by default)
if (typeof MessageChannel === 'undefined') {
  globalThis.MessageChannel = class MessageChannel {
    constructor() {
      this.port1 = { postMessage: () => {}, onmessage: null, close: () => {} }
      this.port2 = { postMessage: () => {}, onmessage: null, close: () => {} }
    }
  }
}

import { serve, setFetchStaticHtml } from 'one/serve-worker'

// Lazy import map - modules load on-demand when route is matched
const lazyRoutes = {
  serverEntry: () => import('./server/_virtual_one-entry.js'),
  pages: {
${pageRouteMap.join(',\n')}
  },
  api: {
${apiRouteMap.join(',\n')}
  },
  middlewares: {
${middlewareRouteMap.join(',\n')}
  }
}

const buildInfo = ${JSON.stringify(buildInfoForWriting)}

let server

export default {
  async fetch(request, env, ctx) {
    if (!server) {
      server = await serve(buildInfo, lazyRoutes)
    }

    // set up static HTML fetcher for this request (uses ASSETS binding)
    if (env.ASSETS) {
      setFetchStaticHtml(async (path) => {
        try {
          const url = new URL(request.url)
          url.pathname = path
          const assetResponse = await env.ASSETS.fetch(new Request(url))
          if (assetResponse && assetResponse.ok) {
            return await assetResponse.text()
          }
        } catch (e) {
          // asset not found
        }
        return null
      })
    }

    try {
      const response = await server.fetch(request)

      // no route matched or 404 → try static assets
      if (!response || response.status === 404) {
        if (env.ASSETS) {
          try {
            const assetResponse = await env.ASSETS.fetch(request)
            if (assetResponse && assetResponse.status !== 404) {
              return assetResponse
            }
          } catch (e) {
            // asset not found, continue with original response
          }
        }
      }

      return response
    } finally {
      setFetchStaticHtml(null)
    }
  }
}
`
      await FSExtra.writeFile(workerSrcPath, workerCode)

      // Bundle the worker using Vite/esbuild
      // Cloudflare Workers with nodejs_compat supports Node.js built-ins
      console.info('\n [cloudflare] Bundling worker...')
      await viteBuild({
        root: options.root,
        mode: 'production',
        logLevel: 'warn',
        build: {
          outDir,
          emptyOutDir: false,
          // Use SSR mode with node target for proper Node.js module resolution
          ssr: workerSrcPath,
          rolldownOptions: {
            external: [
              // React Native dev tools - not needed in production
              '@react-native/dev-middleware',
              '@react-native/debugger-shell',
              'metro',
              'metro-core',
              'metro-runtime',
              // Native modules that can't run in workers
              /\.node$/,
            ],
            output: {
              entryFileNames: 'worker.js',
              format: 'es',
              // Keep dynamic imports separate for lazy loading
              inlineDynamicImports: false,
            },
          },
          minify: true,
          target: 'esnext',
        },
        define: {
          'process.env.NODE_ENV': JSON.stringify('production'),
          'process.env.VITE_ENVIRONMENT': JSON.stringify('ssr'),
        },
        resolve: {
          conditions: ['workerd', 'worker', 'node', 'module', 'default'],
          alias: [
            // rolldown can't parse react-native's Flow syntax; alias to react-native-web for ssr
            {
              find: /^react-native\/Libraries\/.*/,
              replacement: resolvePath('@vxrn/vite-plugin-metro/empty', options.root),
            },
            {
              find: 'react-native/package.json',
              replacement: resolvePath('react-native-web/package.json', options.root),
            },
            {
              find: 'react-native',
              replacement: resolvePath('react-native-web', options.root),
            },
            {
              find: 'react-native-safe-area-context',
              replacement: resolvePath('@vxrn/safe-area', options.root),
            },
          ],
        },
        ssr: {
          target: 'node',
          noExternal: true,
        },
      })

      // Clean up temp file
      await FSExtra.remove(workerSrcPath)

      // Use jsonc for wrangler config (recommended for new projects)
      // Use assets with run_worker_first so all requests go through worker (enables middleware on SSG pages)
      const projectName = await getCloudflareProjectName(options.root)
      const userWranglerConfig = await loadUserWranglerConfig(options.root)
      const wranglerConfig = createCloudflareWranglerConfig(
        projectName,
        userWranglerConfig?.config
      )

      if (userWranglerConfig) {
        console.info(
          ` [cloudflare] Merging ${relative(options.root, userWranglerConfig.path)} into ${outDir}/wrangler.jsonc`
        )
      }

      await FSExtra.writeFile(
        join(options.root, outDir, 'wrangler.jsonc'),
        `${JSON.stringify(wranglerConfig, null, 2)}\n`
      )

      postBuildLogs.push(`Cloudflare worker bundled at ${outDir}/worker.js`)
      postBuildLogs.push(`To deploy: cd ${outDir} && wrangler deploy`)

      break
    }
  }

  // security scan for leaked secrets in client bundles
  const securityScanOption = oneOptions.build?.securityScan
  // default to 'warn', normalize all forms
  const securityScanLevel: 'warn' | 'error' | null =
    securityScanOption === false
      ? null
      : securityScanOption === true || securityScanOption === undefined
        ? 'warn'
        : typeof securityScanOption === 'string'
          ? securityScanOption
          : (securityScanOption.level ?? 'warn')

  const securitySafePatterns =
    typeof securityScanOption === 'object' && securityScanOption !== null
      ? securityScanOption.safePatterns
      : undefined

  if (securityScanLevel) {
    const { runSecurityScan } = await import('./securityScan')
    const passed = await runSecurityScan(
      clientDir,
      securityScanLevel,
      securitySafePatterns
    )
    if (!passed) {
      process.exit(1)
    }
  }

  if (postBuildLogs.length) {
    console.info(`\n\n`)
    postBuildLogs.forEach((log) => {
      console.info(`  · ${log}`)
    })
  }

  console.info(`\n 💛 build complete\n`)
}

const TRAILING_INDEX_REGEX = /\/index(\.(web))?/
function getCleanPaths(possiblePaths: Array<string>) {
  return Array.from(
    new Set(
      Array.from(new Set(possiblePaths)).flatMap((p) => {
        const paths = [p]

        if (p.match(TRAILING_INDEX_REGEX)) {
          const pathWithTrailingIndexRemoved = p.replace(TRAILING_INDEX_REGEX, '')
          paths.push(pathWithTrailingIndexRemoved)
          paths.push(pathWithTrailingIndexRemoved + '/')
        }

        return paths
      })
    )
  )
}

async function moveAllFiles(src: string, dest: string) {
  try {
    await FSExtra.copy(src, dest, { overwrite: true, errorOnExist: false })
  } catch (err) {
    console.error('Error moving files:', err)
  }
}
