import { LOADER_JS_POSTFIX_UNCACHED } from './constants'
import type { Middleware, MiddlewareContext } from './createMiddleware'
import type { RouteNode } from './router/Route'
import type { RouteInfoCompiled } from './server/createRoutesManifest'
import type { LoaderProps } from './types'
import { getPathFromLoaderPath } from './utils/cleanUrl'
import { isResponse } from './utils/isResponse'
import { getManifest } from './vite/getManifest'
import { resolveAPIEndpoint, resolveResponse } from './vite/resolveResponse'
import type { RouteInfo } from './vite/types'

export type RequestHandlers = {
  handlePage?: (props: RequestHandlerProps) => Promise<any>
  handleLoader?: (props: RequestHandlerProps) => Promise<any>
  handleAPI?: (props: RequestHandlerProps) => Promise<any>
  handleStaticFile?: (path: string) => Promise<Response | null>
  loadMiddleware?: (route: RouteNode) => Promise<any>
}

type RequestHandlerProps<RouteExtraProps extends object = {}> = {
  request: Request
  route: RouteInfo<string> & RouteExtraProps
  url: URL
  loaderProps?: LoaderProps
}

type RequestHandlerResponse = null | string | Response

const debugRouter = process.env.ONE_DEBUG_ROUTER

// ensure handler results are always a proper Response so middleware
// can safely use response.body / response.headers / new Response(response.body, ...)
function ensureResponse(value: any): Response {
  // use isResponse (duck-type check) instead of instanceof — the Response
  // constructor may differ across module realms (e.g. API handler vs middleware)
  if (isResponse(value)) return value
  if (typeof value === 'string') {
    return new Response(value, {
      headers: { 'Content-Type': 'text/html' },
    })
  }
  if (value && typeof value === 'object') {
    return Response.json(value)
  }
  return new Response(value)
}

export async function runMiddlewares(
  handlers: RequestHandlers,
  request: Request,
  route: RouteInfo,
  getResponse: () => Promise<Response>
): Promise<Response> {
  const middlewares = route.middlewares

  if (!middlewares?.length) {
    return await getResponse()
  }
  if (!handlers.loadMiddleware) {
    throw new Error(`No middleware handler configured`)
  }

  if (debugRouter) {
    console.info(`[one] 🔗 middleware chain (${middlewares.length}) for ${route.page}`)
  }

  const context: MiddlewareContext = {}

  async function dispatch(index: number): Promise<Response> {
    const middlewareModule = middlewares![index]

    // no more middlewares, finish
    if (!middlewareModule) {
      if (debugRouter) {
        console.info(`[one] ✓ middleware chain complete`)
      }
      return ensureResponse(await getResponse())
    }

    if (debugRouter) {
      console.info(`[one]   → middleware[${index}]: ${middlewareModule.contextKey}`)
    }

    const exported = (await handlers.loadMiddleware!(middlewareModule))?.default as
      | Middleware
      | undefined

    if (!exported) {
      throw new Error(
        `No valid export found in middleware: ${middlewareModule.contextKey}`
      )
    }

    // go to next middleware
    const next = async () => {
      return dispatch(index + 1)
    }

    // run middlewares, if response returned, exit early
    const response = await exported({ request, next, context })

    if (response) {
      if (debugRouter) {
        console.info(
          `[one]   ← middleware[${index}] returned early (status: ${response.status})`
        )
      }
      return response
    }

    // If the middleware returns null/void, keep going
    return dispatch(index + 1)
  }

  // Start with the first middleware (index 0).
  return dispatch(0)
}

export async function resolveAPIRoute(
  handlers: RequestHandlers,
  request: Request,
  url: URL,
  route: RouteInfoCompiled
) {
  const { pathname } = url
  const params = getRouteParams(pathname, route)

  if (debugRouter) {
    console.info(`[one] 📡 API ${request.method} ${pathname} → ${route.file}`, params)
  }

  return await runMiddlewares(handlers, request, route, async () => {
    try {
      return resolveAPIEndpoint(
        () =>
          handlers.handleAPI!({
            request,
            route,
            url,
            loaderProps: {
              path: pathname,
              search: url.search,
              subdomain: getSubdomain(url),
              params,
            },
          }),
        request,
        params || {}
      )
    } catch (err) {
      if (isResponse(err)) {
        return err
      }

      if (process.env.NODE_ENV === 'development') {
        console.error(`\n [one] Error importing API route at ${pathname}:

          ${err}

          If this is an import error, you can likely fix this by adding this dependency to
          the "optimizeDeps.include" array in your vite.config.ts.
        `)
      }

      throw err
    }
  })
}

export async function resolveLoaderRoute(
  handlers: RequestHandlers,
  request: Request,
  url: URL,
  route: RouteInfoCompiled
) {
  if (debugRouter) {
    console.info(`[one] 📦 loader ${url.pathname} → ${route.file}`)
  }

  const isNativeRequest =
    url.searchParams.get('platform') === 'ios' ||
    url.searchParams.get('platform') === 'android'

  const response = await runMiddlewares(handlers, request, route, async () => {
    return await resolveResponse(async () => {
      const headers = new Headers()
      headers.set('Content-Type', 'text/javascript')

      try {
        const loaderResponse = await handlers.handleLoader!({
          request,
          route,
          url,
          loaderProps: {
            path: url.pathname,
            search: url.search,
            subdomain: getSubdomain(url),
            request: route.type === 'ssr' ? request : undefined,
            params: getLoaderParams(url, route),
          },
        })

        // native needs CJS format for eval()
        const body =
          isNativeRequest && loaderResponse ? toCjsLoader(loaderResponse) : loaderResponse

        return new Response(body, {
          headers,
        })
      } catch (err) {
        // allow throwing a response in a loader
        if (isResponse(err)) {
          return err
        }

        if ((err as any)?.code !== 'ERR_MODULE_NOT_FOUND') {
          console.error(`Error running loader: ${err}`)
        }

        throw err
      }
    })
  })

  // transform redirect responses into js modules so the client can detect
  // and handle them during client-side navigation (instead of the browser
  // silently following the 302 and trying to parse HTML as javascript)
  if (response.status >= 300 && response.status < 400) {
    const location = response.headers.get('location')
    if (location) {
      const redirectUrl = new URL(location, url.origin)
      const redirectPath = redirectUrl.pathname + redirectUrl.search + redirectUrl.hash
      const data = `{__oneRedirect:${JSON.stringify(redirectPath)},__oneRedirectStatus:${response.status}}`
      const body = isNativeRequest
        ? `exports.loader=function(){return ${data}}`
        : `export function loader(){return${data}}`
      return new Response(body, {
        headers: { 'Content-Type': 'text/javascript' },
      })
    }
  }

  // transform auth error responses (401/403) into js modules so the client
  // gets a clean error signal instead of a parse failure
  if (response.status === 401 || response.status === 403) {
    const data = `{__oneError:${response.status},__oneErrorMessage:${JSON.stringify(response.statusText || 'Unauthorized')}}`
    const body = isNativeRequest
      ? `exports.loader=function(){return ${data}}`
      : `export function loader(){return${data}}`
    return new Response(body, {
      headers: { 'Content-Type': 'text/javascript' },
    })
  }

  return response
}

/**
 * convert an ESM loader response to CJS for native eval().
 * extracts the JSON data from `export function loader() { return {...} }`
 * and wraps it as `exports.loader = function() { return {...} }`
 */
function toCjsLoader(esmCode: string): string {
  // already CJS (dev plugin pre-converts for native)
  if (esmCode.startsWith('exports.')) {
    return esmCode
  }
  // match: export function loader() { return DATA }
  const match = esmCode.match(
    /export\s+function\s+loader\s*\(\)\s*\{\s*return\s+([\s\S]+)\s*\}/
  )
  if (match) {
    return `exports.loader=function(){return ${match[1]}}`
  }
  // fallback: wrap the whole thing
  return `exports.loader=function(){return {}}`
}

export async function resolvePageRoute(
  handlers: RequestHandlers,
  request: Request,
  url: URL,
  route: RouteInfoCompiled
) {
  const { pathname, search } = url

  if (debugRouter) {
    console.info(`[one] 📄 page ${pathname} → ${route.file} (${route.type})`)
  }

  const loaderProps = {
    path: pathname,
    search: search,
    subdomain: getSubdomain(url),
    request: route.type === 'ssr' ? request : undefined,
    params: getLoaderParams(url, route),
  }

  // flatten the async chain for SSR: skip runMiddlewares wrapper when no middlewares
  if (!route.middlewares?.length) {
    return resolveResponse(() => {
      return handlers.handlePage!({ request, route, url, loaderProps })
    })
  }

  return resolveResponse(async () => {
    return await runMiddlewares(handlers, request, route, async () => {
      return await handlers.handlePage!({ request, route, url, loaderProps })
    })
  })
}

// weakmap cache to avoid re-parsing the same request URL
const _urlCache = new WeakMap<Request, URL>()

export function getURLfromRequestURL(request: Request) {
  let url = _urlCache.get(request)
  if (url) return url
  const urlString = request.url || ''
  url = new URL(
    urlString || '',
    request.headers.get('host') ? `http://${request.headers.get('host')}` : ''
  )
  _urlCache.set(request, url)
  return url
}

export function getSubdomain(url: URL): string | undefined {
  const host = url.hostname
  // skip for IP addresses and localhost
  if (!host || host === 'localhost' || /^\d+\.\d+\.\d+\.\d+$/.test(host)) {
    return undefined
  }
  const parts = host.split('.')
  // need at least 3 parts for a subdomain (sub.example.com)
  if (parts.length < 3) {
    return undefined
  }
  // return everything before the last two parts (domain.tld)
  return parts.slice(0, -2).join('.')
}

function compileRouteRegex(route: RouteInfo): RouteInfoCompiled {
  return {
    ...route,
    compiledRegex: new RegExp(route.namedRegex),
  }
}

export function compileManifest(manifest: {
  pageRoutes: RouteInfo[]
  apiRoutes: RouteInfo[]
}): {
  pageRoutes: RouteInfoCompiled[]
  apiRoutes: RouteInfoCompiled[]
} {
  return {
    pageRoutes: manifest.pageRoutes.map(compileRouteRegex),
    apiRoutes: manifest.apiRoutes.map(compileRouteRegex),
  }
}

// in dev mode we do it more simply:
export function createHandleRequest(
  handlers: RequestHandlers,
  { routerRoot, ignoredRouteFiles }: { routerRoot: string; ignoredRouteFiles?: string[] }
) {
  const manifest = getManifest({ routerRoot, ignoredRouteFiles })
  if (!manifest) {
    throw new Error(`No routes manifest`)
  }
  const compiledManifest = compileManifest(manifest)

  return {
    manifest,
    handler: async function handleRequest(
      request: Request
    ): Promise<RequestHandlerResponse> {
      const url = getURLfromRequestURL(request)
      const { pathname, search } = url

      // skip paths handled by vite internals or react native dev middleware
      if (
        pathname === '/__vxrnhmr' ||
        pathname.startsWith('/@vite/') ||
        pathname.startsWith('/@fs/') ||
        pathname.startsWith('/@id/') ||
        pathname.startsWith('/node_modules/') ||
        pathname.startsWith('/debugger-frontend') ||
        pathname.startsWith('/inspector')
      ) {
        return null
      }

      // check if path looks like a static file (extension 2-4 chars like .js, .png, .jpeg)
      // excludes loader paths which end with _vxrn_loader.js
      const looksLikeStaticFile =
        !pathname.endsWith(LOADER_JS_POSTFIX_UNCACHED) &&
        /\.[a-zA-Z0-9]{2,4}$/.test(pathname)

      if (handlers.handleAPI) {
        const apiRoute = compiledManifest.apiRoutes.find((route) => {
          return route.compiledRegex.test(pathname)
        })
        if (apiRoute) {
          if (debugRouter) {
            console.info(`[one] ⚡ ${pathname} → matched API route: ${apiRoute.page}`)
          }
          return await resolveAPIRoute(handlers, request, url, apiRoute)
        }
      }

      if (request.method !== 'GET') {
        return null
      }

      if (handlers.handleLoader) {
        const isClientRequestingNewRoute = pathname.endsWith(LOADER_JS_POSTFIX_UNCACHED)

        if (isClientRequestingNewRoute) {
          const platformParam = url.searchParams.get('platform')
          const isNativePlatform =
            platformParam === 'ios' ||
            platformParam === 'android' ||
            platformParam === 'native'

          // for native requests, try serving the pre-built .native.js static file first
          // (SSG/SPA routes generate standalone CJS loaders at build time)
          if (isNativePlatform && handlers.handleStaticFile) {
            const nativeLoaderPath = pathname.replace(/\.js$/, '.native.js')
            const staticResponse = await handlers.handleStaticFile(nativeLoaderPath)
            if (staticResponse) {
              return staticResponse
            }
          }

          const originalUrl = getPathFromLoaderPath(pathname)

          for (const route of compiledManifest.pageRoutes) {
            if (route.file === '') {
              // ignore not found route
              continue
            }

            const finalUrl = new URL(originalUrl, url.origin)
            finalUrl.search = url.search

            if (!route.compiledRegex.test(finalUrl.pathname)) {
              continue
            }

            // route is known to export no loader → return empty module without
            // importing the page bundle. on workerd/cloudflare, evaluating a
            // no-loader SSG page's server bundle can crash when it pulls in
            // RN/Tamagui modules that aren't compatible with the workers runtime.
            if (route.hasLoader === false) {
              const emptyBody = isNativePlatform
                ? 'exports.loader=function(){return undefined}'
                : 'export function loader() { return undefined }'
              return new Response(emptyBody, {
                headers: { 'Content-Type': 'text/javascript' },
              })
            }

            const cleanedRequest = new Request(finalUrl, request)
            return resolveLoaderRoute(handlers, cleanedRequest, finalUrl, route)
          }

          // no matching route - return empty module so client handles gracefully
          const emptyBody = isNativePlatform
            ? 'exports.loader=function(){return{}}'
            : 'export {}'
          return new Response(emptyBody, {
            headers: { 'Content-Type': 'text/javascript' },
          })
        }
      }

      if (handlers.handlePage) {
        for (const route of compiledManifest.pageRoutes) {
          if (!route.compiledRegex.test(pathname)) {
            continue
          }

          // for static-looking paths, skip dynamic routes (with route params)
          // this prevents /favicon.ico from matching [slug] routes
          // but allows explicit routes and not-found handlers to match
          const isDynamicRoute = Object.keys(route.routeKeys).length > 0
          const isNotFoundRoute = route.page.endsWith('/+not-found')
          if (looksLikeStaticFile && isDynamicRoute && !isNotFoundRoute) {
            if (debugRouter) {
              console.info(
                `[one] ⚡ ${pathname} → skipping dynamic route ${route.page} for static-looking path`
              )
            }
            continue
          }

          // static-looking probes (sourcemaps, .well-known, favicons) that
          // only match the auto-generated placeholder +not-found (route.file
          // is '' — no user-defined +not-found page exists) should get a bare
          // 404 rather than a full SSR render. browser devtools & crawlers
          // want a status code, not an HTML shell, and rendering the layout
          // tree for every probe is wasteful.
          if (looksLikeStaticFile && route.file === '') {
            if (debugRouter) {
              console.info(
                `[one] ⚡ ${pathname} → 404 for probe path (no +not-found defined)`
              )
            }
            return new Response(null, {
              status: 404,
              headers: { 'Content-Type': 'text/plain' },
            })
          }

          if (debugRouter) {
            console.info(
              `[one] ⚡ ${pathname} → matched page route: ${route.page} (${route.type})`
            )
          }

          return resolvePageRoute(handlers, request, url, route)
        }
      }

      return null
    },
  }
}

export function getLoaderParams(
  url: URL,
  config: { compiledRegex: RegExp; routeKeys: Record<string, string> }
) {
  const params: Record<string, string> = {}
  const match = config.compiledRegex.exec(url.pathname)
  if (match?.groups) {
    for (const [key, value] of Object.entries(match.groups)) {
      const namedKey = config.routeKeys[key]
      params[namedKey] = value as string
    }
  }
  return params
}

// Add this helper function
function getRouteParams(pathname: string, route: RouteInfo<string>) {
  const regex = new RegExp(route.namedRegex)
  const match = regex.exec(pathname)
  if (!match) return {}
  return Object.fromEntries(
    Object.entries(route.routeKeys).map(([key, value]) => {
      return [value, (match.groups?.[key] || '') as string]
    })
  )
}
