import { crossSerializeStream, getCrossReferenceHeader } from 'seroval'
import { invariant } from '../invariant'
import {
  createInlineCssPlaceholderAsset,
  createInlineCssStyleAsset,
  getStylesheetHref,
} from '../manifest'
import { decodePath } from '../utils'
import { createLRUCache } from '../lru-cache'
import { rootRouteId } from '../root'
import { _getRenderedMatches } from '../load-client'
import minifiedTsrBootStrapScript from './tsrScript?script-string'
import { GLOBAL_TSR, TSR_SCRIPT_BARRIER_ID } from './constants'
import { dehydrateSsrMatchId } from './ssr-match-id'
import { defaultSerovalPlugins } from './serializer/seroval-plugins'
import { makeSsrSerovalPlugin } from './serializer/transformer'
import type { LRUCache } from '../lru-cache'
import type { DehydratedMatch, DehydratedRouter } from './types'
import type { AnySerializationAdapter } from './serializer/transformer'
import type { AnyRouter, ServerSsr } from '../router'
import type { AnyRouteMatch } from '../Matches'
import type {
  Manifest,
  ManifestRoute,
  ManifestRouteAssets,
  RouterManagedTag,
  ServerManifest,
} from '../manifest'

const SCOPE_ID = 'tsr'

const TSR_PREFIX = GLOBAL_TSR + '.router='
const P_PREFIX = GLOBAL_TSR + '.p(()=>'
const P_SUFFIX = ')'

export function dehydrateMatch(match: AnyRouteMatch): DehydratedMatch {
  const dehydratedMatch: DehydratedMatch = {
    i: dehydrateSsrMatchId(match.id),
    u: match.updatedAt,
    s: match.status,
  }

  const properties = [
    ['__beforeLoadContext', 'b'],
    ['loaderData', 'l'],
    ['error', 'e'],
    ['ssr', 'ssr'],
  ] as const

  for (const [key, shorthand] of properties) {
    if (match[key] !== undefined) {
      dehydratedMatch[shorthand] = match[key]
    }
  }
  if (match._notFound) {
    dehydratedMatch.g = true
  }
  return dehydratedMatch
}

const INITIAL_SCRIPTS = [
  getCrossReferenceHeader(SCOPE_ID),
  minifiedTsrBootStrapScript,
]

class ScriptBuffer {
  private injectScript: ((script: string) => void) | undefined
  private _queue: Array<string>
  private _scriptBarrierLifted = false
  private _cleanedUp = false
  private _microtaskVersion = 0
  private _pendingMicrotaskVersion = 0

  constructor(injectScript: (script: string) => void) {
    this.injectScript = injectScript
    // Copy INITIAL_SCRIPTS to avoid mutating the shared array
    this._queue = INITIAL_SCRIPTS.slice()
  }

  enqueue(script: string) {
    if (this._cleanedUp) return
    this._queue.push(script)
    if (this._scriptBarrierLifted) {
      this.scheduleInjectBufferedScripts()
    }
  }

  liftBarrier() {
    if (this._scriptBarrierLifted || this._cleanedUp) return
    this._scriptBarrierLifted = true
    if (this._queue.length > 0) {
      this.scheduleInjectBufferedScripts()
    }
  }

  scheduleInjectBufferedScripts() {
    if (this._pendingMicrotaskVersion !== 0) return
    const pendingVersion = ++this._microtaskVersion
    this._pendingMicrotaskVersion = pendingVersion
    queueMicrotask(() => {
      if (this._pendingMicrotaskVersion !== pendingVersion) return
      this._pendingMicrotaskVersion = 0
      this.injectBufferedScripts()
    })
  }

  clearPendingMicrotask() {
    if (this._pendingMicrotaskVersion === 0) return
    this._pendingMicrotaskVersion = 0
    this._microtaskVersion++
  }

  /**
   * Flushes any pending scripts synchronously.
   * Call this before signaling serialization finished to ensure all scripts are injected.
   *
   * IMPORTANT: Only injects if the barrier has been lifted. Before the barrier is lifted,
   * scripts should remain in the queue so takeBufferedScripts() can retrieve them
   */
  flush() {
    if (!this._scriptBarrierLifted) return
    if (this._cleanedUp) return
    this.clearPendingMicrotask()
    this.injectBufferedScripts()
  }

  takeAll() {
    return this.takeScripts(this._queue.length)
  }

  takeScripts(count: number) {
    if (count <= 0) return undefined
    const bufferedScripts = this._queue.splice(0, count)
    if (bufferedScripts.length === 0) {
      return undefined
    }
    // Optimization: if only one script, avoid join
    if (bufferedScripts.length === 1) {
      return bufferedScripts[0] + ';document.currentScript.remove()'
    }
    // Append cleanup script and join - avoid push() to not mutate then iterate
    return bufferedScripts.join(';') + ';document.currentScript.remove()'
  }

  hasPending() {
    return this._queue.length > 0
  }

  injectBufferedScripts() {
    if (this._cleanedUp) return
    // Early return if queue is empty (avoids unnecessary takeAll() call)
    if (this._queue.length === 0) return
    const scriptsToInject = this.takeAll()
    if (scriptsToInject) {
      this.injectScript?.(scriptsToInject)
    }
  }

  cleanup() {
    this._cleanedUp = true
    this.clearPendingMicrotask()
    this._queue = []
    this.injectScript = undefined
  }
}

const isProd = process.env.NODE_ENV === 'production'

type FilteredRoutes = Manifest['routes']

type PreparedMatchedManifestRoutes = {
  routes: FilteredRoutes
  hasStrippedRoutes: boolean
  inlineCssHrefs?: Array<string>
  inlineCss?: string
}

type ManifestLRU = LRUCache<string, PreparedMatchedManifestRoutes>

const MANIFEST_CACHE_SIZE = 100
const manifestCaches = new WeakMap<ServerManifest, ManifestLRU>()

function getManifestCache(manifest: ServerManifest): ManifestLRU {
  const cache = manifestCaches.get(manifest)
  if (cache) return cache
  const newCache = createLRUCache<string, PreparedMatchedManifestRoutes>(
    MANIFEST_CACHE_SIZE,
  )
  manifestCaches.set(manifest, newCache)
  return newCache
}

function getInlineCssForPreparedRoutes(
  manifest: ServerManifest,
  preparedRoutes: PreparedMatchedManifestRoutes,
) {
  if (preparedRoutes.inlineCss !== undefined) return preparedRoutes.inlineCss

  const styles = manifest.inlineCss?.styles
  const hrefs = preparedRoutes.inlineCssHrefs
  if (!styles || !hrefs?.length) return undefined

  let css = ''
  for (const href of hrefs) {
    css += styles[href]!
  }

  preparedRoutes.inlineCss = css
  return css
}

function getInlineCssAssetForPreparedRoutes(
  manifest: ServerManifest,
  preparedRoutes: PreparedMatchedManifestRoutes,
) {
  const css = getInlineCssForPreparedRoutes(manifest, preparedRoutes)

  return css === undefined ? undefined : createInlineCssStyleAsset(css)
}

function getMatchedRoutesCacheKey(matches: Array<AnyRouteMatch>) {
  let cacheKey = ''
  for (let i = 0; i < matches.length; i++) {
    cacheKey += (i === 0 ? '' : '\0') + matches[i]!.routeId
  }
  return cacheKey
}

function getPreparedMatchedManifestRoutes(
  manifest: ServerManifest,
  matches: Array<AnyRouteMatch>,
  cacheKey: string,
) {
  if (isProd) {
    const cached = getManifestCache(manifest).get(cacheKey)
    if (cached) {
      return cached
    }
  }

  const preparedRoutes = prepareMatchedManifestRoutes(manifest, matches)

  if (isProd) {
    getManifestCache(manifest).set(cacheKey, preparedRoutes)
  }

  return preparedRoutes
}

function prepareMatchedManifestRoutes(
  manifest: ServerManifest,
  matches: Array<AnyRouteMatch>,
): PreparedMatchedManifestRoutes {
  const inlineStyles = manifest.inlineCss?.styles
  const routes: FilteredRoutes = {}

  if (!inlineStyles) {
    for (const match of matches) {
      const route = manifest.routes[match.routeId]
      if (route) {
        routes[match.routeId] = route
      }
    }
    return { routes, hasStrippedRoutes: false }
  }

  const inlineCssHrefs: Array<string> = []
  const seenInlineCssHrefs = new Set<string>()
  let hasStrippedRoutes = false

  for (const match of matches) {
    const routeId = match.routeId
    const route = manifest.routes[routeId]
    if (!route) {
      continue
    }

    const nextRoute = stripInlinedStylesheetAssetsFromRoute(
      inlineStyles,
      route,
      inlineCssHrefs,
      seenInlineCssHrefs,
    )

    if (nextRoute !== route) {
      hasStrippedRoutes = true
    }
    routes[routeId] = nextRoute
  }

  return {
    routes,
    hasStrippedRoutes,
    ...(inlineCssHrefs.length ? { inlineCssHrefs } : {}),
  }
}

function stripInlinedStylesheetAssetsFromRoute(
  inlineStyles: Record<string, string>,
  route: ManifestRoute,
  inlineCssHrefs: Array<string>,
  seenInlineCssHrefs: Set<string>,
): ManifestRoute {
  const css = route.css
  if (!css) {
    return route
  }

  if (css.length === 0) {
    const nextRoute = { ...route }
    delete nextRoute.css
    return nextRoute
  }

  let cssLinks: typeof css | undefined
  for (let i = 0; i < css.length; i++) {
    const link = css[i]!
    const href = getStylesheetHref(link)
    if (inlineStyles[href] === undefined) {
      if (cssLinks) {
        cssLinks.push(link)
      }
      continue
    }

    if (!seenInlineCssHrefs.has(href)) {
      seenInlineCssHrefs.add(href)
      inlineCssHrefs.push(href)
    }

    if (!cssLinks) {
      cssLinks = css.slice(0, i)
    }
  }

  if (!cssLinks) {
    return route
  }

  if (cssLinks.length > 0) {
    return { ...route, css: cssLinks }
  }

  const nextRoute = { ...route }
  delete nextRoute.css
  return nextRoute
}

function hasRouteAssets(route: ManifestRoute) {
  return !!route.scripts?.length || !!route.css?.length
}

function hasRequestAssets(assets: ManifestRouteAssets | undefined) {
  return !!assets && (!!assets.preloads?.length || hasRouteAssets(assets))
}

function mergeRequestAssetsIntoRootRoute(
  rootRoute: ManifestRoute | undefined,
  requestAssets: ManifestRouteAssets | undefined,
): ManifestRoute {
  const preloads = requestAssets?.preloads?.length
    ? [...requestAssets.preloads, ...(rootRoute?.preloads ?? [])]
    : rootRoute?.preloads
  const scripts = requestAssets?.scripts?.length
    ? [...requestAssets.scripts, ...(rootRoute?.scripts ?? [])]
    : rootRoute?.scripts
  const cssLinks = requestAssets?.css?.length
    ? [...requestAssets.css, ...(rootRoute?.css ?? [])]
    : rootRoute?.css

  return {
    ...(rootRoute ?? {}),
    ...(preloads?.length ? { preloads } : {}),
    ...(scripts?.length ? { scripts } : {}),
    ...(cssLinks?.length ? { css: cssLinks } : {}),
  }
}

export function attachRouterServerSsrUtils({
  router,
  manifest,
  getRequestAssets,
}: {
  router: AnyRouter
  manifest: ServerManifest | undefined
  getRequestAssets?: () => ManifestRouteAssets | undefined
}) {
  router.ssr = {
    get manifest() {
      if (!manifest) return manifest

      const requestAssets = getRequestAssets?.()
      const matches = _getRenderedMatches(router.stores.matches.get())
      const hasAssets = hasRequestAssets(requestAssets)

      if (!hasAssets && !manifest.inlineCss) {
        return manifest
      }

      let inlineCssAsset: Manifest['inlineStyle'] | undefined
      let routes = manifest.routes
      if (manifest.inlineCss) {
        const cacheKey = getMatchedRoutesCacheKey(matches)
        const preparedManifest = getPreparedMatchedManifestRoutes(
          manifest,
          matches,
          cacheKey,
        )
        inlineCssAsset = getInlineCssAssetForPreparedRoutes(
          manifest,
          preparedManifest,
        )
        if (preparedManifest.hasStrippedRoutes) {
          routes = { ...manifest.routes, ...preparedManifest.routes }
        }
      }

      if (!hasAssets) {
        return {
          ...(manifest.scriptFormat
            ? { scriptFormat: manifest.scriptFormat }
            : {}),
          ...(inlineCssAsset ? { inlineStyle: inlineCssAsset } : {}),
          routes,
        }
      }

      const rootRoute = routes[rootRouteId]

      // Merge request-scoped assets into root route without mutating cached manifest
      return {
        ...(manifest.scriptFormat
          ? { scriptFormat: manifest.scriptFormat }
          : {}),
        ...(inlineCssAsset ? { inlineStyle: inlineCssAsset } : {}),
        routes: {
          ...routes,
          [rootRouteId]: mergeRequestAssetsIntoRootRoute(
            rootRoute,
            requestAssets,
          ),
        },
      }
    },
  }
  let _dehydrated = false
  let _serializationFinished = false
  let streamFastPathReserved = false
  const renderFinishedListeners: Array<() => void> = []
  const injectedHtmlListeners: Array<() => void> = []
  const serializationFinishedListeners: Array<() => void> = []
  const cleanupListeners: Array<() => void> = []
  let cleanupStarted = false
  let injectedHtmlBuffer = ''

  const callListeners = (listeners: Array<() => void>, errorPrefix: string) => {
    const snapshot = listeners.slice()
    for (const l of snapshot) {
      try {
        l()
      } catch (err) {
        console.error(`${errorPrefix}:`, err)
      }
    }
  }

  const removeListener = (
    listeners: Array<() => void>,
    listener: () => void,
  ) => {
    const index = listeners.indexOf(listener)
    if (index >= 0) listeners.splice(index, 1)
  }

  const scriptBuffer = new ScriptBuffer((script) => {
    serverSsr.injectScript(script)
  })

  const serverSsr: ServerSsr = {
    injectHtml: (html: string) => {
      if (!html || cleanupStarted) return
      // Buffer the HTML so it can be retrieved via takeBufferedHtml()
      injectedHtmlBuffer += html
      callListeners(injectedHtmlListeners, 'SSR injected HTML listener error')
    },
    injectScript: (script: string) => {
      if (!script || cleanupStarted) return
      const html = `<script${router.options.ssr?.nonce ? ` nonce='${router.options.ssr.nonce}'` : ''}>${script}</script>`
      serverSsr.injectHtml(html)
    },
    dehydrate: async (opts?: { requestAssets?: ManifestRouteAssets }) => {
      if (_dehydrated) {
        if (process.env.NODE_ENV !== 'production') {
          throw new Error('Invariant failed: router is already dehydrated!')
        }

        invariant()
      }
      let matchesToDehydrate = _getRenderedMatches(router.stores.matches.get())
      const isShell = router.isShell()
      if (isShell) {
        // In SPA mode we only want to dehydrate the root match
        matchesToDehydrate = matchesToDehydrate.slice(0, 1)
      }
      const matches = matchesToDehydrate.map(dehydrateMatch)

      let manifestToDehydrate: Manifest | undefined = undefined
      // Only currently matched routes are dehydrated. Other route assets are
      // loaded through dynamic imports when those routes become active.
      if (manifest) {
        const cacheKey = getMatchedRoutesCacheKey(matchesToDehydrate)
        const preparedManifest = getPreparedMatchedManifestRoutes(
          manifest,
          matchesToDehydrate,
          cacheKey,
        )

        manifestToDehydrate = {
          ...(manifest.scriptFormat
            ? { scriptFormat: manifest.scriptFormat }
            : {}),
          ...(preparedManifest.inlineCssHrefs
            ? { inlineStyle: createInlineCssPlaceholderAsset() }
            : {}),
          routes: preparedManifest.routes,
        }

        // Merge request-scoped assets into root route (without mutating cached manifest)
        const requestAssets = opts?.requestAssets
        if (hasRequestAssets(requestAssets)) {
          const existingRoot = manifestToDehydrate.routes[rootRouteId]
          manifestToDehydrate.routes = {
            ...manifestToDehydrate.routes,
            [rootRouteId]: mergeRequestAssetsIntoRootRoute(
              existingRoot,
              requestAssets,
            ),
          }
        }
      }
      const dehydratedRouter: DehydratedRouter = {
        manifest: manifestToDehydrate,
        matches,
      }
      const dehydratedData = await router.options.dehydrate?.()
      if (cleanupStarted) {
        return
      }
      if (dehydratedData) {
        dehydratedRouter.dehydratedData = dehydratedData
      }
      _dehydrated = true

      const trackPlugins = { didRun: false }
      const serializationAdapters = router.options.serializationAdapters as
        | Array<AnySerializationAdapter>
        | undefined
      const plugins = serializationAdapters
        ? serializationAdapters
            .map((t) => makeSsrSerovalPlugin(t, trackPlugins))
            .concat(defaultSerovalPlugins)
        : defaultSerovalPlugins

      let serializationCompleteSignaled = false
      const signalSerializationComplete = () => {
        if (serializationCompleteSignaled || cleanupStarted) return
        serializationCompleteSignaled = true
        _serializationFinished = true

        const listeners = serializationFinishedListeners.slice()
        serializationFinishedListeners.length = 0

        for (const l of listeners) {
          try {
            l()
          } catch (err) {
            console.error('Serialization listener error:', err)
          }
        }
      }

      const finishScriptSerialization = () => {
        if (serializationCompleteSignaled || cleanupStarted) return
        scriptBuffer.enqueue(GLOBAL_TSR + '.e()')
        // Must synchronously notify injected HTML listeners before signaling
        // completion; otherwise the held </body> tail could flush ahead of the
        // end script.
        scriptBuffer.flush()
        signalSerializationComplete()
      }

      crossSerializeStream(dehydratedRouter, {
        refs: new Map(),
        plugins,
        onSerialize: (data, initial) => {
          let serialized = initial ? TSR_PREFIX + data : data
          if (trackPlugins.didRun) {
            serialized = P_PREFIX + serialized + P_SUFFIX
          }
          scriptBuffer.enqueue(serialized)
        },
        onError: (err: unknown) => {
          console.error('Serialization error:', err)
          if (err && (err as any).stack) {
            console.error((err as any).stack)
          }
          finishScriptSerialization()
        },
        scopeId: SCOPE_ID,
        onDone: () => {
          finishScriptSerialization()
        },
      })
    },
    isDehydrated() {
      return _dehydrated
    },
    isSerializationFinished() {
      return _serializationFinished
    },
    reserveStreamFastPath() {
      if (
        !cleanupStarted &&
        _serializationFinished &&
        !streamFastPathReserved &&
        renderFinishedListeners.length === 0 &&
        !injectedHtmlBuffer &&
        !scriptBuffer.hasPending()
      ) {
        streamFastPathReserved = true
        return true
      }
      return false
    },
    onInjectedHtml: (listener) => {
      if (cleanupStarted) return () => {}
      injectedHtmlListeners.push(listener)
      return () => removeListener(injectedHtmlListeners, listener)
    },
    onRenderFinished: (listener) => {
      if (cleanupStarted || streamFastPathReserved) return
      renderFinishedListeners.push(listener)
    },
    onSerializationFinished: (listener) => {
      if (cleanupStarted) return () => {}
      if (_serializationFinished && !cleanupStarted) {
        try {
          listener()
        } catch (err) {
          console.error('Serialization listener error:', err)
        }
        return () => {}
      }
      serializationFinishedListeners.push(listener)
      return () => removeListener(serializationFinishedListeners, listener)
    },
    onCleanup: (listener) => {
      if (cleanupStarted) return
      cleanupListeners.push(listener)
    },
    setRenderFinished: () => {
      if (cleanupStarted) return
      scriptBuffer.liftBarrier()
      const listeners = renderFinishedListeners.slice()
      renderFinishedListeners.length = 0
      for (const l of listeners) {
        try {
          l()
        } catch (err) {
          console.error('Error in render finished listener:', err)
        }
      }
      if (_serializationFinished) {
        scriptBuffer.flush()
      }
    },
    takeBufferedScripts() {
      const scripts = scriptBuffer.takeAll()
      if (!scripts) return undefined
      const serverBufferedScript: RouterManagedTag = {
        tag: 'script',
        attrs: {
          nonce: router.options.ssr?.nonce,
          className: '$tsr',
          id: TSR_SCRIPT_BARRIER_ID,
        },
        children: scripts,
      }
      return serverBufferedScript
    },
    liftScriptBarrier() {
      scriptBuffer.liftBarrier()
    },
    takeBufferedHtml() {
      if (!injectedHtmlBuffer) {
        return undefined
      }
      const buffered = injectedHtmlBuffer
      injectedHtmlBuffer = ''
      return buffered
    },
    cleanup() {
      // Guard against multiple/reentrant cleanup calls. A listener could call
      // cleanup() again indirectly; snapshot + clear before invoking so each
      // listener runs exactly once and reentry is a no-op.
      if (cleanupStarted) return
      cleanupStarted = true
      const listeners = cleanupListeners.slice()
      cleanupListeners.length = 0
      for (const l of listeners) {
        try {
          l()
        } catch (err) {
          console.error('Error in SSR cleanup listener:', err)
        }
      }
      renderFinishedListeners.length = 0
      injectedHtmlListeners.length = 0
      serializationFinishedListeners.length = 0
      injectedHtmlBuffer = ''
      scriptBuffer.cleanup()
      router.serverSsr = undefined
    },
  }

  router.serverSsr = serverSsr
  for (const listener of router.serverSsrLifecycle?.onServerSsrAttach ?? []) {
    try {
      listener(serverSsr)
    } catch (err) {
      console.error('SSR attach listener error:', err)
    }
  }
}

/**
 * Get the origin for the request.
 *
 * SECURITY: We intentionally do NOT trust the Origin header for determining
 * the router's origin. The Origin header can be spoofed by attackers, which
 * could lead to SSRF-like vulnerabilities where redirects are constructed
 * using a malicious origin (CVE-2024-34351).
 *
 * Instead, we derive the origin from request.url, which is typically set by
 * the server infrastructure (not client-controlled headers).
 *
 * For applications behind proxies that need to trust forwarded headers,
 * use the router's `origin` option to explicitly configure a trusted origin.
 */
export function getOrigin(request: Request) {
  try {
    return new URL(request.url).origin
  } catch {}
  return 'http://localhost'
}

// server and browser can decode/encode characters differently in paths and search params.
// Server generally strictly follows the WHATWG URL Standard, while browsers may differ for legacy reasons.
// for example, in paths "|" is not encoded on the server but is encoded on chromium (and not on firefox) while "대" is encoded on both sides.
// Another anomaly is that in Node new URLSearchParams and new URL also decode/encode characters differently.
// new URLSearchParams() encodes "|" while new URL() does not, and in this instance
// chromium treats search params differently than paths, i.e. "|" is not encoded in search params.
export function getNormalizedURL(url: string | URL, base?: string | URL) {
  // ensure backslashes are encoded correctly in the URL
  if (typeof url === 'string') url = url.replace('\\', '%5C')

  const rawUrl = new URL(url, base)
  const { path: decodedPathname, handledProtocolRelativeURL } = decodePath(
    rawUrl.pathname,
  )
  const searchParams = new URLSearchParams(rawUrl.search)
  const normalizedHref =
    decodedPathname +
    (searchParams.size > 0 ? '?' : '') +
    searchParams.toString() +
    rawUrl.hash

  return {
    url: new URL(normalizedHref, rawUrl.origin),
    handledProtocolRelativeURL,
  }
}
