import { join } from 'node:path'
import FSExtra from 'fs-extra'
import * as constants from '../constants'
import { LOADER_JS_POSTFIX_UNCACHED } from '../constants'
import type { LoaderProps } from '../types'
import { getLoaderPath, getPreloadCSSPath, getPreloadPath } from '../utils/cleanUrl'
import { isResponse } from '../utils/isResponse'
import { toAbsolute } from '../utils/toAbsolute'
import { replaceLoader } from '../vite/replaceLoader'
import type { One, RouteInfo } from '../vite/types'

const { readFile, outputFile } = FSExtra

// Convert URL path (with forward slashes) to filesystem path for cross-platform compatibility
function urlPathToFilePath(urlPath: string): string {
  // Remove leading slash and split by forward slash (URL separator)
  const parts = urlPath.replace(/^\//, '').split('/')
  return join(...parts)
}

// timing helper for build profiling
const buildTiming = process.env.ONE_BUILD_TIMING === '1'
const timings: Record<string, number[]> = {}
function recordTiming(label: string, ms: number) {
  if (!buildTiming) return
  ;(timings[label] ||= []).push(ms)
}
export function printBuildTimings() {
  if (!buildTiming) return
  console.info('\n📊 Build timing breakdown:')
  for (const [label, times] of Object.entries(timings)) {
    const total = times.reduce((a, b) => a + b, 0)
    const avg = total / times.length
    console.info(
      `  ${label}: ${avg.toFixed(1)}ms avg, ${total.toFixed(0)}ms total (${times.length} calls)`
    )
  }
}

export async function buildPage(
  serverEntry: string,
  path: string,
  relativeId: string,
  params: any,
  foundRoute: RouteInfo<string>,
  clientManifestEntry: any,
  staticDir: string,
  clientDir: string,
  builtMiddlewares: Record<string, string>,
  serverJsPath: string,
  preloads: string[],
  allCSS: string[],
  layoutCSS: string[],
  routePreloads: Record<string, string>,
  allCSSContents?: string[],
  criticalPreloads?: string[],
  deferredPreloads?: string[],
  useAfterLCP?: boolean,
  useAfterLCPAggressive?: boolean
): Promise<One.RouteBuildInfo> {
  let t0 = performance.now()

  const render = await getRender(serverEntry)
  recordTiming('getRender', performance.now() - t0)

  const htmlPath = `${path.endsWith('/') ? `${removeTrailingSlash(path)}/index` : path}.html`
  const clientJsPath = clientManifestEntry
    ? join(clientDir, clientManifestEntry.file)
    : ''
  const htmlOutPath = toAbsolute(join(staticDir, htmlPath))
  const preloadPath = getPreloadPath(path)
  const cssPreloadPath = getPreloadCSSPath(path)

  let loaderPath = ''

  let loaderData = {}

  try {
    // generate preload file with route module registration
    const routeImports: string[] = []
    const routeRegistrations: string[] = []
    let routeIndex = 0

    for (const [routeKey, bundlePath] of Object.entries(routePreloads)) {
      const varName = `_r${routeIndex++}`
      routeImports.push(`import * as ${varName} from "${bundlePath}"`)
      routeRegistrations.push(`registerPreloadedRoute("${routeKey}", ${varName})`)
    }

    // Use window global for registration since ES module exports get tree-shaken
    const registrationCalls = routeRegistrations.map((call) =>
      call.replace('registerPreloadedRoute(', 'window.__oneRegisterPreloadedRoute(')
    )

    const preloadContent = [
      // import all route modules
      ...routeImports,
      // static imports for cache warming (original behavior)
      ...preloads.map((preload) => `import "${preload}"`),
      // register all route modules using window global
      ...registrationCalls,
    ].join('\n')

    t0 = performance.now()
    await FSExtra.writeFile(
      join(clientDir, urlPathToFilePath(preloadPath)),
      preloadContent
    )
    recordTiming('writePreload', performance.now() - t0)

    // Generate CSS preload file with prefetch (on hover) and inject (on navigation) functions
    // Deduplicate CSS URLs to avoid loading the same file multiple times
    const uniqueCSS = [...new Set(allCSS)]
    const cssPreloadContent = `
const CSS_TIMEOUT = 1000
const cssUrls = ${JSON.stringify(uniqueCSS)}

// Global cache for loaded CSS - avoids DOM queries and tracks across navigations
const loaded = (window.__oneLoadedCSS ||= new Set())

// Prefetch CSS without applying - called on link hover
export function prefetchCSS() {
  cssUrls.forEach(href => {
    if (loaded.has(href)) return
    if (document.querySelector(\`link[href="\${href}"]\`)) return
    const link = document.createElement('link')
    link.rel = 'prefetch'
    link.as = 'style'
    link.href = href
    document.head.appendChild(link)
  })
}

// Inject CSS to apply styles - called on actual navigation
export function injectCSS() {
  return Promise.all(cssUrls.map(href => {
    // Skip if already loaded
    if (loaded.has(href)) return Promise.resolve()
    // Remove any prefetch link for this href
    const prefetchLink = document.querySelector(\`link[rel="prefetch"][href="\${href}"]\`)
    if (prefetchLink) prefetchLink.remove()
    // Skip if stylesheet already exists in DOM
    if (document.querySelector(\`link[rel="stylesheet"][href="\${href}"]\`)) {
      loaded.add(href)
      return Promise.resolve()
    }
    return new Promise(resolve => {
      const link = document.createElement('link')
      link.rel = 'stylesheet'
      link.href = href
      const timeoutId = setTimeout(() => {
        console.warn('[one] CSS load timeout:', href)
        loaded.add(href)
        resolve()
      }, CSS_TIMEOUT)
      link.onload = link.onerror = () => {
        clearTimeout(timeoutId)
        loaded.add(href)
        resolve()
      }
      document.head.appendChild(link)
    })
  }))
}

// For backwards compatibility, also prefetch on import
prefetchCSS()
`
    t0 = performance.now()
    await FSExtra.writeFile(
      join(clientDir, urlPathToFilePath(cssPreloadPath)),
      cssPreloadContent
    )
    recordTiming('writeCSSPreload', performance.now() - t0)

    t0 = performance.now()
    const exported = await import(toAbsolute(serverJsPath))
    recordTiming('importServerModule', performance.now() - t0)

    const loaderProps: LoaderProps = { path, params }

    // Build matches array for useMatches() hook
    const matches: One.RouteMatch[] = []

    // Run layout loaders in parallel
    t0 = performance.now()
    if (foundRoute.layouts?.length) {
      const layoutResults = await Promise.all(
        foundRoute.layouts.map(async (layout) => {
          try {
            const layoutServerPath = layout.loaderServerPath
            if (!layoutServerPath) {
              return { contextKey: layout.contextKey, loaderData: undefined }
            }
            // derive server dir from clientDir (e.g. dist/client -> dist/server)
            const serverDir = join(clientDir, '..', 'server')
            const layoutExported = await import(
              toAbsolute(join(serverDir, layoutServerPath))
            )
            const layoutLoaderData = await layoutExported?.loader?.(loaderProps)
            return { contextKey: layout.contextKey, loaderData: layoutLoaderData }
          } catch (err) {
            if (isResponse(err)) {
              throw err
            }
            console.warn(
              `[one] Warning: layout loader failed for ${layout.contextKey}:`,
              err
            )
            return { contextKey: layout.contextKey, loaderData: undefined }
          }
        })
      )

      for (const result of layoutResults) {
        matches.push({
          routeId: result.contextKey,
          pathname: path,
          params: params || {},
          loaderData: result.loaderData,
        })
      }
    }
    recordTiming('layoutLoaders', performance.now() - t0)

    // Run page loader
    t0 = performance.now()
    let loaderRedirectInfo: { path: string; status: number } | null = null

    // skip loader execution for ssr routes - loaders run at request time, not build time
    if (exported.loader && foundRoute.type !== 'ssr') {
      try {
        loaderData = (await exported.loader?.(loaderProps)) ?? null
      } catch (err) {
        // handle thrown responses (e.g., throw redirect('/login'))
        // extract redirect info so we can generate a static redirect loader file
        if (isResponse(err)) {
          loaderRedirectInfo = extractRedirectInfo(err as Response)
        } else {
          throw err
        }
      }

      // handle returned redirect responses (e.g., return redirect('/login'))
      // check both isResponse and constructor name for cross-context compatibility
      if (
        !loaderRedirectInfo &&
        loaderData &&
        (isResponse(loaderData) ||
          loaderData instanceof Response ||
          loaderData?.constructor?.name === 'Response')
      ) {
        loaderRedirectInfo = extractRedirectInfo(loaderData as Response)
        loaderData = {}
      }

      if (clientJsPath) {
        const loaderPartialPath = join(clientDir, urlPathToFilePath(getLoaderPath(path)))

        // uncached native loader path for native prod (metro can't inline cache keys)
        const uncachedNativePath = loaderPartialPath
          .replace(constants.LOADER_JS_POSTFIX, LOADER_JS_POSTFIX_UNCACHED)
          .replace(/\.js$/, '.native.js')

        if (loaderRedirectInfo) {
          // generate a static redirect loader — the client detects __oneRedirect
          // and navigates before the protected page ever renders
          const redirectData = JSON.stringify({
            __oneRedirect: loaderRedirectInfo.path,
            __oneRedirectStatus: loaderRedirectInfo.status,
          })
          await outputFile(
            loaderPartialPath,
            `export function loader(){return ${redirectData}}`
          )
          // native-friendly CJS version
          const nativeCjs = `exports.loader = function(){return ${redirectData}}`
          await outputFile(loaderPartialPath.replace(/\.js$/, '.native.js'), nativeCjs)
          await outputFile(uncachedNativePath, nativeCjs)
          loaderPath = getLoaderPath(path)
          loaderData = {}
        } else {
          const code = await readFile(clientJsPath, 'utf-8')
          const withLoader =
            // super dirty to quickly make ssr loaders work until we have better
            `
if (typeof document === 'undefined') globalThis.document = {}
` +
            replaceLoader({
              code,
              loaderData,
            })
          await outputFile(loaderPartialPath, withLoader)
          // native-friendly CJS version with just the data (no ESM imports)
          const nativeCjs = `exports.loader = function(){return ${JSON.stringify(loaderData)}}`
          await outputFile(loaderPartialPath.replace(/\.js$/, '.native.js'), nativeCjs)
          await outputFile(uncachedNativePath, nativeCjs)
          loaderPath = getLoaderPath(path)
        }
      }
    }
    recordTiming('pageLoader', performance.now() - t0)

    // Add page match
    matches.push({
      routeId: foundRoute.file,
      pathname: path,
      params: params || {},
      loaderData,
    })

    // ssr, we basically skip at build-time and just compile it the js we need
    if (foundRoute.type !== 'ssr') {
      // importing resetState causes issues :/
      globalThis['__vxrnresetState']?.()

      if (foundRoute.type === 'ssg') {
        // Aggressive mode: only modulepreload critical scripts, skip deferred to prevent network saturation
        // Regular after-lcp mode: modulepreload all scripts for parallel downloads, defer execution
        // Default: all scripts load normally
        const renderPreloads = criticalPreloads || preloads
        const renderDeferredPreloads = useAfterLCPAggressive ? [] : deferredPreloads

        t0 = performance.now()
        let html = await render({
          path,
          preloads: renderPreloads,
          deferredPreloads: renderDeferredPreloads,
          loaderProps,
          loaderData,
          css: allCSS,
          cssContents: allCSSContents,
          mode: 'ssg',
          routePreloads,
          matches,
        })
        recordTiming('ssrRender', performance.now() - t0)

        // Apply after-LCP script loading if enabled
        // Load all preloads (not just critical) to ensure good TTI after first paint
        if (useAfterLCP) {
          html = applyAfterLCPScriptLoad(html, preloads)
        }

        t0 = performance.now()
        await outputFile(htmlOutPath, html)
        recordTiming('writeHTML', performance.now() - t0)
      } else if (foundRoute.type === 'spa') {
        // spa-shell: render if any parent layout has ssg/ssr render mode
        const needsSpaShell = foundRoute.layouts?.some(
          (layout) =>
            layout.layoutRenderMode === 'ssg' || layout.layoutRenderMode === 'ssr'
        )

        if (needsSpaShell) {
          // render root layout shell for SPA pages
          globalThis['__vxrnresetState']?.()

          const renderPreloads = criticalPreloads || preloads
          const renderDeferredPreloads = deferredPreloads || []

          // for spa-shell, include layout matches (not page match)
          // matches array at this point has: [layout1, layout2, ..., page]
          // we want just the layouts for spa-shell
          const layoutMatches = matches.slice(0, -1)

          t0 = performance.now()
          let html = await render({
            path,
            preloads: renderPreloads,
            deferredPreloads: renderDeferredPreloads,
            loaderProps,
            // don't pass loaderData for spa-shell - the page loader runs on client
            // passing {} here would make useLoaderState think data is preloaded
            loaderData: undefined,
            css: allCSS,
            cssContents: allCSSContents,
            mode: 'spa-shell',
            routePreloads,
            matches: layoutMatches,
          })
          recordTiming('spaShellRender', performance.now() - t0)

          if (useAfterLCP) {
            html = applyAfterLCPScriptLoad(html, preloads)
          }

          t0 = performance.now()
          await outputFile(htmlOutPath, html)
          recordTiming('writeHTML', performance.now() - t0)
        } else {
          // separate layout css (before scripts) from page css (after scripts)
          const layoutCSSSet = new Set(layoutCSS)

          // render css as either inline <style> (if content provided) or <link>
          function renderCSSTag(file: string, index: number): string {
            const content = allCSSContents?.[index]
            if (content) {
              return `    <style>${content}</style>`
            }
            return `    <link rel="stylesheet" href=${file} />`
          }

          const layoutCssOutput = allCSS
            .map((file, i) => (layoutCSSSet.has(file) ? renderCSSTag(file, i) : ''))
            .filter(Boolean)
            .join('\n')

          const pageCssOutput = allCSS
            .map((file, i) => (!layoutCSSSet.has(file) ? renderCSSTag(file, i) : ''))
            .filter(Boolean)
            .join('\n')

          // Use separated preloads if available
          const criticalScripts = (criticalPreloads || preloads)
            .map((preload) => `   <script type="module" src="${preload}"></script>`)
            .join('\n')

          // skip modulepreload hints for pure SPA pages - the JS module graph
          // handles its own loading, and route preloading happens on hover intent
          // emitting hundreds of modulepreload links saturates connections for no benefit

          await outputFile(
            htmlOutPath,
            `<!DOCTYPE html><html><head>
            ${constants.getSpaHeaderElements({ serverContext: { loaderProps, loaderData } })}
            ${layoutCssOutput}
            ${criticalScripts}
            ${pageCssOutput}
          </head><body></body></html>`
          )
        }
      }
    }
  } catch (err) {
    const errMsg = err instanceof Error ? `${err.message}\n${err.stack}` : `${err}`

    console.error(
      `Error building static page at ${path} with id ${relativeId}:

${errMsg}

loaderData:\n\n${JSON.stringify(loaderData || null, null, 2)}
params:\n\n${JSON.stringify(params || null, null, 2)}`
    )
    console.error(err)
    throw err
  }

  const middlewares = (foundRoute.middlewares || []).map(
    (x) => builtMiddlewares[x.contextKey]
  )

  const cleanPath = path === '/' ? path : removeTrailingSlash(path)

  return {
    type: foundRoute.type,
    css: allCSS,
    layoutCSS,
    cssContents: allCSSContents,
    routeFile: foundRoute.file,
    middlewares,
    cleanPath,
    preloadPath,
    cssPreloadPath,
    loaderPath,
    clientJsPath,
    serverJsPath,
    htmlPath,
    loaderData,
    params,
    path,
    preloads,
    criticalPreloads,
    deferredPreloads,
  }
}

async function getRender(serverEntry: string) {
  try {
    const serverImport = await import(serverEntry)

    const render =
      serverImport.default.render ||
      // for an unknown reason this is necessary
      serverImport.default.default?.render

    if (typeof render !== 'function') {
      throw new Error(`didn't find render function in entry: ${serverEntry}`)
    }

    return render
  } catch (err) {
    console.error(`❌ Error importing the root entry:`)
    console.error(`  This error happened in the built file: ${serverEntry}`)
    // @ts-expect-error
    console.error(err['stack'])
    throw err
  }
}

function removeTrailingSlash(path: string) {
  return path.endsWith('/') ? path.slice(0, path.length - 1) : path
}

// extract redirect target from a Response object (e.g., from redirect())
function extractRedirectInfo(
  response: Response
): { path: string; status: number } | null {
  if (response.status >= 300 && response.status < 400) {
    const location = response.headers.get('location')
    if (location) {
      try {
        const url = new URL(location)
        return {
          path: url.pathname + url.search + url.hash,
          status: response.status,
        }
      } catch {
        // relative URL
        return { path: location, status: response.status }
      }
    }
  }
  return null
}

/**
 * Transforms HTML to delay script execution until after first paint.
 * Keeps modulepreload links so critical scripts download in parallel.
 * Removes async script tags and adds a loader that executes scripts after paint.
 */
function applyAfterLCPScriptLoad(html: string, preloads: string[]): string {
  // Remove all <script type="module" ... async> tags (prevents immediate execution)
  // Keep modulepreload links so critical scripts download in parallel
  html = html.replace(/<script\s+type="module"[^>]*async[^>]*><\/script>/gi, '')

  // Create the loader script
  // Nested setTimeout yields to event loop multiple times, letting browser settle before loading scripts
  const loaderScript = `
<script>
(function() {
  var scripts = ${JSON.stringify(preloads)};
  function loadScripts() {
    scripts.forEach(function(src) {
      var script = document.createElement('script');
      script.type = 'module';
      script.src = src;
      document.head.appendChild(script);
    });
  }
  function waitIdle(n) {
    if (n <= 0) {
      requestAnimationFrame(function() {
        requestAnimationFrame(loadScripts);
      });
      return;
    }
    setTimeout(function() {
      setTimeout(function() {
        waitIdle(n - 1);
      }, 0);
    }, 0);
  }
  waitIdle(5);
})();
</script>`

  // Insert the loader script before </head>
  html = html.replace('</head>', `${loaderScript}</head>`)

  return html
}
