/**
 * Note: this entire module is exported as an interface router.*
 * We need to treat exports as an API and not change them, maybe not
 * the best decision.
 */

import {
  type NavigationContainerRefWithCurrent,
  type NavigationState,
  StackActions,
} from '@react-navigation/native'
import {
  type ComponentType,
  Fragment,
  startTransition,
  useDeferredValue,
  useSyncExternalStore,
} from 'react'
import { Platform } from 'react-native'
import { devtoolsRegistry } from '../devtools/registry'
import type { OneRouter } from '../interfaces/router'
import { resolveHref } from '../link/href'
import { openExternalURL } from '../link/openExternalURL'
import { resolve } from '../link/path'
import { checkBlocker } from '../useBlocker'
import { assertIsReady } from '../utils/assertIsReady'
import { getLoaderPath, getPreloadCSSPath, getPreloadPath } from '../utils/cleanUrl'
import { dynamicImport } from '../utils/dynamicImport'
import { isVersionStale } from '../skewProtection'
import { shouldLinkExternally } from '../utils/url'
import {
  ParamValidationError,
  RouteValidationError,
  validateParams as runValidateParams,
} from '../validateParams'
import type { One } from '../vite/types'
import {
  extractParamsFromState,
  extractPathnameFromHref,
  extractSearchFromHref,
  findRouteNodeFromState,
  findAllRouteNodesFromState,
} from './findRouteNode'
import type { UrlObject } from './getNormalizedStatePath'
import { getRouteInfo } from './getRouteInfo'
import { getRoutes } from './getRoutes'
import { setLastAction } from './lastAction'
import { getLinking, resetLinking, setupLinking } from './linkingConfig'
import type { RouteNode } from './Route'
import { sortRoutes } from './sortRoutes'
import { getQualifiedRouteComponent } from './useScreens'
import { preloadRouteModules } from './useViteRoutes'
import { getNavigateAction } from './utils/getNavigateAction'
import { setClientMatches } from '../useMatches'
import type { RouteMatch } from '../useMatches'
import {
  findInterceptRoute,
  setNavigationType,
  updateURLWithoutNavigation,
  storeInterceptState,
} from './interceptRoutes'
import { setSlotState } from '../views/Navigator'
import {
  clearNotFoundState,
  findNearestNotFoundRoute,
  setNotFoundState,
} from '../notFoundState'

// Module-scoped variables
export let routeNode: RouteNode | null = null
export let rootComponent: ComponentType

// Global registry for protected routes
// Key: contextKey (e.g., '/protected-test'), Value: Set of protected route names
const protectedRouteRegistry = new Map<string, Set<string>>()

/**
 * Register protected routes for a navigator context.
 * Called by navigators when their protectedScreens changes.
 */
export function registerProtectedRoutes(
  contextKey: string,
  protectedScreens: Set<string>
) {
  if (protectedScreens.size === 0) {
    protectedRouteRegistry.delete(contextKey)
  } else {
    protectedRouteRegistry.set(contextKey, protectedScreens)
  }
}

/**
 * Unregister protected routes for a navigator context.
 * Called when a navigator unmounts.
 */
export function unregisterProtectedRoutes(contextKey: string) {
  protectedRouteRegistry.delete(contextKey)
}

/**
 * Check if a route path is protected and should be blocked.
 * Returns true if the route is protected.
 */
export function isRouteProtected(href: string): boolean {
  // Normalize the href (remove leading/trailing slashes)
  const normalizedHref = href.replace(/^\/+|\/+$/g, '')

  // Check each navigator context to see if this route is protected
  for (const [contextKey, protectedScreens] of protectedRouteRegistry) {
    const normalizedContextKey = contextKey.replace(/^\/+|\/+$/g, '')

    // Check if this href is under this context
    if (normalizedHref.startsWith(normalizedContextKey)) {
      // Get the route name relative to this context
      const relativePath = normalizedHref
        .slice(normalizedContextKey.length)
        .replace(/^\//, '')
      const routeName = relativePath.split('/')[0] || 'index'

      // normalize /index suffix so e.g. "otp/[flow]" matches "otp/[flow]/index"
      const normalizedRouteName = routeName.replace(/\/index$/, '')
      if (protectedScreens.has(routeName) || protectedScreens.has(normalizedRouteName)) {
        return true
      }
    }
  }

  return false
}

export let hasAttemptedToHideSplash = false
export let initialState: OneRouter.ResultState | undefined
export let rootState: OneRouter.ResultState | undefined
// the original pathname from the initial page load, used by late-mounting
// navigators to determine the correct initial route even after React Navigation's
// linking has pushed a different URL during unmount/remount cycles
export let initialPathname: string | undefined

let nextState: OneRouter.ResultState | undefined
export let routeInfo: UrlObject | undefined
let splashScreenAnimationFrame: number | undefined

// we always set it
export let navigationRef: OneRouter.NavigationRef = null as any

const rootStateSubscribers = new Set<OneRouter.RootStateListener>()
const loadingStateSubscribers = new Set<OneRouter.LoadingStateListener>()
const storeSubscribers = new Set<() => void>()

// current matches for useMatches hook (cached on client)
let currentMatches: RouteMatch[] = []

// Validation state tracking
export type ValidationState = {
  status: 'idle' | 'validating' | 'error' | 'valid'
  error?: Error
  lastValidatedHref?: string
}

let validationState: ValidationState = { status: 'idle' }
const validationStateSubscribers = new Set<(state: ValidationState) => void>()

export function subscribeToValidationState(subscriber: (state: ValidationState) => void) {
  validationStateSubscribers.add(subscriber)
  return () => validationStateSubscribers.delete(subscriber)
}

export function setValidationState(state: ValidationState) {
  validationState = state
  for (const subscriber of validationStateSubscribers) {
    subscriber(state)
  }
  // Dispatch event for devtools
  if (
    process.env.TAMAGUI_TARGET !== 'native' &&
    state.status === 'error' &&
    state.error
  ) {
    window.dispatchEvent(
      new CustomEvent('one-validation-error', {
        detail: {
          error: {
            message: state.error.message,
            name: state.error.name,
            stack: state.error.stack,
          },
          href: state.lastValidatedHref,
          timestamp: Date.now(),
        },
      })
    )
  }
}

export function getValidationState(): ValidationState {
  return validationState
}

export function useValidationState() {
  return useSyncExternalStore(
    subscribeToValidationState,
    getValidationState,
    getValidationState
  )
}

// cache route tree + root component across SSR requests (they don't change)
let cachedRouteNode: RouteNode | null = null
let cachedRootComponent: ComponentType | null = null
let cachedContext: One.RouteContext | null = null

// Initialize function
export function initialize(
  context: One.RouteContext,
  ref: NavigationContainerRefWithCurrent<ReactNavigation.RootParamList>,
  initialLocation?: URL
) {
  cleanUpState()

  // cache route tree - only rebuild when context changes (never in production)
  if (context !== cachedContext || !cachedRouteNode) {
    cachedRouteNode = getRoutes(context, {
      ignoreEntryPoints: true,
      platform: Platform.OS,
    })
    cachedRootComponent = cachedRouteNode
      ? getQualifiedRouteComponent(cachedRouteNode)
      : Fragment
    cachedContext = context
  }

  routeNode = cachedRouteNode
  rootComponent = cachedRootComponent || Fragment

  if (!routeNode && process.env.NODE_ENV === 'production') {
    throw new Error('No routes found')
  }

  if (process.env.ONE_DEBUG_ROUTER && routeNode) {
    const formatRouteTree = (node: RouteNode, indent = '', isLast = true): string => {
      const prefix = indent + (isLast ? '└─ ' : '├─ ')
      const childIndent = indent + (isLast ? '   ' : '│  ')

      const dynamicBadge = node.dynamic
        ? ` [${node.dynamic.map((d) => d.name).join(', ')}]`
        : ''
      const typeBadge = node.type !== 'layout' ? ` (${node.type})` : ''
      const slotsBadge = node.slots?.size
        ? ` {@${Array.from(node.slots.keys()).join(', @')}}`
        : ''
      const routeName = node.route || '/'

      let line = `${prefix}${routeName}${dynamicBadge}${typeBadge}${slotsBadge}`

      const visibleChildren = node.children.filter((child) => !child.internal)
      for (let i = 0; i < visibleChildren.length; i++) {
        const child = visibleChildren[i]
        const childIsLast = i === visibleChildren.length - 1
        line += '\n' + formatRouteTree(child, childIndent, childIsLast)
      }

      return line
    }

    console.info(`[one] 📍 Route structure:\n${formatRouteTree(routeNode)}`)

    // Log slot details
    if (routeNode.slots?.size) {
      console.info(`[one] 📦 Slots on root layout:`)
      for (const [slotName, slotConfig] of routeNode.slots) {
        console.info(`  @${slotName}:`, {
          defaultRoute: slotConfig.defaultRoute?.route,
          interceptRoutes: slotConfig.interceptRoutes.map((r) => ({
            route: r.route,
            intercept: r.intercept,
          })),
        })
      }
    }
  }

  navigationRef = ref as unknown as OneRouter.NavigationRef
  setupLinkingAndRouteInfo(initialLocation)
  subscribeToNavigationChanges()
}

function cleanUpState() {
  initialState = undefined
  initialPathname = undefined
  rootState = undefined
  nextState = undefined
  routeInfo = undefined
  resetLinking()
  rootStateSubscribers.clear()
  storeSubscribers.clear()
}

function setupLinkingAndRouteInfo(initialLocation?: URL) {
  initialState = setupLinking(routeNode, initialLocation)

  // capture the original pathname before React Navigation's linking can modify it
  initialPathname =
    initialLocation?.pathname ??
    (typeof window !== 'undefined' ? window.location.pathname : undefined)

  if (initialState) {
    rootState = initialState
    routeInfo = getRouteInfo(initialState)
  } else {
    routeInfo = {
      unstable_globalHref: '',
      pathname: '',
      isIndex: false,
      params: {},
      segments: [],
    }
  }
}

/**
 * called by NavigationContainer's onStateChange callback
 * uses onStateChange instead of addListener('state') because onStateChange
 * always provides the full resolved state, avoiding partial state issues
 * that can cause stale usePathname/useSegments values
 */
export function handleNavigationContainerStateChange(
  navState: Readonly<NavigationState> | undefined
) {
  if (!navState) return

  // @ts-expect-error
  let state = { ...navState } as OneRouter.ResultState

  if (state.key) {
    if (hashes[state.key]) {
      state.hash = hashes[state.key]
      delete hashes[state.key]
    }
  }

  if (!hasAttemptedToHideSplash) {
    hasAttemptedToHideSplash = true
    splashScreenAnimationFrame = requestAnimationFrame(() => {
      // SplashScreen._internal_maybeHideAsync?.();
    })
  }

  if (nextOptions) {
    state = { ...state, linkOptions: nextOptions }
    nextOptions = null
  }

  let shouldUpdateSubscribers = nextState === state
  nextState = undefined

  if (state && state !== rootState) {
    updateState(state, undefined)
    shouldUpdateSubscribers = true
  }

  if (shouldUpdateSubscribers) {
    startTransition(() => {
      for (const subscriber of rootStateSubscribers) {
        subscriber(state)
      }
    })
  }
}

function subscribeToNavigationChanges() {
  startTransition(() => {
    updateSnapshot()
    for (const subscriber of storeSubscribers) {
      subscriber()
    }
  })
}

// Navigation functions
export function navigate(url: OneRouter.Href, options?: OneRouter.LinkToOptions) {
  return linkTo(resolveHref(url), 'NAVIGATE', options)
}

export function push(url: OneRouter.Href, options?: OneRouter.LinkToOptions) {
  return linkTo(resolveHref(url), 'PUSH', options)
}

export function dismiss(count?: number) {
  if (process.env.ONE_DEBUG_ROUTER) {
    console.info(`[one] 🔙 dismiss${count ? ` (${count})` : ''}`)
  }
  navigationRef?.dispatch(StackActions.pop(count))
}

export function replace(url: OneRouter.Href, options?: OneRouter.LinkToOptions) {
  return linkTo(resolveHref(url), 'REPLACE', options)
}

export function setParams(params: OneRouter.InpurRouteParamsGeneric = {}) {
  assertIsReady(navigationRef)
  return navigationRef?.current?.setParams(
    // @ts-expect-error
    params
  )
}

export function dismissAll() {
  if (process.env.ONE_DEBUG_ROUTER) {
    console.info(`[one] 🔙 dismissAll`)
  }
  navigationRef?.dispatch(StackActions.popToTop())
}

export function goBack() {
  if (process.env.ONE_DEBUG_ROUTER) {
    console.info(`[one] 🔙 goBack`)
  }
  assertIsReady(navigationRef)
  navigationRef?.current?.goBack()
}

export function canGoBack(): boolean {
  if (!navigationRef.isReady()) {
    return false
  }
  return navigationRef?.current?.canGoBack() ?? false
}

export function canDismiss(): boolean {
  let state = rootState

  while (state) {
    if (state.type === 'stack' && state.routes.length > 1) {
      return true
    }
    if (state.index === undefined) {
      return false
    }
    state = state.routes?.[state.index]?.state as any
  }

  return false
}

export function getSortedRoutes() {
  if (!routeNode) {
    throw new Error('No routes')
  }
  return routeNode.children.filter((route) => !route.internal).sort(sortRoutes)
}

export function updateState(state: OneRouter.ResultState, nextStateParam = state) {
  rootState = state
  nextState = nextStateParam

  const nextRouteInfo = getRouteInfo(state)

  if (!deepEqual(routeInfo, nextRouteInfo)) {
    if (process.env.ONE_DEBUG_ROUTER) {
      const from = routeInfo?.pathname || '(initial)'
      const to = nextRouteInfo.pathname
      const params = Object.keys(nextRouteInfo.params || {}).length
        ? nextRouteInfo.params
        : undefined
      console.info(`[one] 🧭 ${from} → ${to}`, params ? { params } : '')
    }
    routeInfo = nextRouteInfo

    // On native, update client matches when route changes
    // This enables useMatches to work for initial route and navigation
    // Loader data will be undefined initially (fetched by useLoader)
    if (process.env.TAMAGUI_TARGET === 'native') {
      const params = extractParamsFromState(state)
      const newMatches = buildNativeMatches(state, nextRouteInfo.pathname, params)
      currentMatches = newMatches
      setClientMatches(newMatches)
    }
  }

  // Expose devtools API in development
  if (process.env.NODE_ENV === 'development' && typeof window !== 'undefined') {
    // Use registry to avoid circular deps - useLoader registers its function there
    ;(window as any).__oneDevtools = {
      routeInfo: nextRouteInfo,
      rootState: state,
      routeNode,
      getRoutes: () => routeNode?.children || [],
      getLoaderTimingHistory: () => devtoolsRegistry.getLoaderTimingHistory?.() ?? [],
      getPreloadHistory,
    }
    // Dispatch event for devtools panels to listen
    if (process.env.TAMAGUI_TARGET !== 'native') {
      window.dispatchEvent(new CustomEvent('one-route-change', { detail: nextRouteInfo }))
    }
  }
}

// Subscription functions
export function subscribeToRootState(subscriber: OneRouter.RootStateListener) {
  rootStateSubscribers.add(subscriber)
  return () => {
    rootStateSubscribers.delete(subscriber)
  }
}

export function subscribeToStore(subscriber: () => void) {
  storeSubscribers.add(subscriber)
  return () => {
    storeSubscribers.delete(subscriber)
  }
}

export function subscribeToLoadingState(subscriber: OneRouter.LoadingStateListener) {
  loadingStateSubscribers.add(subscriber)
  return () => {
    loadingStateSubscribers.delete(subscriber)
  }
}

export function setLoadingState(state: OneRouter.LoadingState) {
  startTransition(() => {
    for (const listener of loadingStateSubscribers) {
      listener(state)
    }
  })
}

// Snapshot function

let currentSnapshot: ReturnType<typeof getSnapshot> | null = null

function updateSnapshot() {
  currentSnapshot = getSnapshot()
}

export function snapshot() {
  return currentSnapshot!
}

function getSnapshot() {
  return {
    linkTo,
    routeNode,
    rootComponent,
    linking: getLinking(),
    hasAttemptedToHideSplash,
    initialState,
    rootState,
    nextState,
    routeInfo,
    splashScreenAnimationFrame,
    navigationRef,
    rootStateSubscribers,
    storeSubscribers,
  }
}

export function rootStateSnapshot() {
  return rootState!
}

export function routeInfoSnapshot() {
  return routeInfo!
}

// Hook functions
export function useOneRouter() {
  const state = useSyncExternalStore(subscribeToStore, snapshot, snapshot)
  // useDeferredValue makes the transition concurrent, preventing main thread blocking
  return useDeferredValue(state)
}

function syncStoreRootState() {
  if (!navigationRef) {
    throw new Error(`No navigationRef, possible duplicate One dep`)
  }
  if (navigationRef.isReady()) {
    const currentState = navigationRef.getRootState() as unknown as OneRouter.ResultState
    if (rootState !== currentState) {
      // when a parent layout conditionally renders (e.g. auth gate), getRootState()
      // can return incomplete/wrong state before all navigators mount. don't
      // overwrite routeInfo with wrong pathname while initial state is still valid.
      if (initialState && routeInfo?.pathname) {
        const nextRouteInfo = getRouteInfo(currentState)
        if (nextRouteInfo.pathname !== routeInfo.pathname) {
          // pathname would change — skip to preserve initial URL truth
          rootState = currentState
          return
        }
      }
      updateState(currentState)
    }
  }
}

export function useStoreRootState() {
  syncStoreRootState()
  const state = useSyncExternalStore(
    subscribeToRootState,
    rootStateSnapshot,
    rootStateSnapshot
  )
  return useDeferredValue(state)
}

export function useStoreRouteInfo() {
  syncStoreRootState()
  const state = useSyncExternalStore(
    subscribeToRootState,
    routeInfoSnapshot,
    routeInfoSnapshot
  )
  // note: we intentionally don't use useDeferredValue here because it can cause
  // layout flash when conditional rendering depends on pathname. the deferred value
  // delays the parent layout's update while nested layouts have already unmounted.
  return state
}

// Cleanup function
export function cleanup() {
  if (splashScreenAnimationFrame) {
    cancelAnimationFrame(splashScreenAnimationFrame)
  }
}

export const preloadingLoader: Record<string, Promise<any> | undefined> = {}

// inlined to ensure tree shakes away in prod
// dev mode preload - fetches just the loader directly without production preload bundles
async function doPreloadDev(href: string): Promise<any> {
  if (process.env.NODE_ENV === 'development') {
    const startTime = performance.now()
    const normalizedPath = normalizeLoaderPath(href)

    try {
      const loaderJSUrl = getLoaderPath(href, true)

      const moduleLoadStart = performance.now()
      const modulePromise = dynamicImport(loaderJSUrl)
      if (!modulePromise) {
        return null
      }
      const module = await modulePromise.catch(() => null)
      const moduleLoadTime = performance.now() - moduleLoadStart

      if (!module?.loader) {
        return null
      }

      const executionStart = performance.now()
      const result = await module.loader()
      const executionTime = performance.now() - executionStart
      const totalTime = performance.now() - startTime

      // detect server redirect signal from loader response
      if (result?.__oneRedirect) {
        return result
      }

      // record timing for devtools
      devtoolsRegistry.recordLoaderTiming?.({
        path: normalizedPath,
        startTime,
        moduleLoadTime,
        executionTime,
        totalTime,
        source: 'preload',
      })

      return result ?? null
    } catch (err) {
      const totalTime = performance.now() - startTime

      // record error timing for devtools
      devtoolsRegistry.recordLoaderTiming?.({
        path: normalizedPath,
        startTime,
        totalTime,
        error: err instanceof Error ? err.message : String(err),
        source: 'preload',
      })

      // graceful fail - loader will be fetched when component mounts
      if (process.env.ONE_DEBUG_ROUTER) {
        console.warn(`[one] dev preload failed for ${href}:`, err)
      }
      return null
    }
  }
}

async function doPreload(href: string) {
  const preloadPath = getPreloadPath(href)
  const loaderPath = getLoaderPath(href)
  const cssPreloadPath = getPreloadCSSPath(href)

  recordPreloadStart(href)

  try {
    const [_preload, cssPreloadModule, loader] = await Promise.all([
      dynamicImport(preloadPath)?.catch((err) => {
        recordPreloadError(href, err instanceof Error ? err.message : String(err))
        return null
      }),
      dynamicImport(cssPreloadPath)?.catch(() => null) ?? Promise.resolve(null),
      dynamicImport(loaderPath)?.catch(() => null) ?? Promise.resolve(null),
      preloadRouteModules(href),
    ])

    // Store the CSS inject function for later use on navigation
    const hasCss = !!cssPreloadModule?.injectCSS
    if (hasCss) {
      cssInjectFunctions[href] = cssPreloadModule.injectCSS
    }

    const hasLoader = !!loader?.loader
    if (!hasLoader) {
      recordPreloadComplete(href, false, hasCss)
      return null
    }

    const result = await loader.loader()

    // detect server redirect signal from loader response
    if (result?.__oneRedirect) {
      return result
    }

    recordPreloadComplete(href, true, hasCss)
    return result ?? null
  } catch (err) {
    const errorMessage = err instanceof Error ? err.message : String(err)
    console.error(`[one] preload error for ${href}:`, err)
    recordPreloadError(href, errorMessage)
    return null
  }
}

// Store resolved preload data separately from promises
export const preloadedLoaderData: Record<string, any> = {}

// Store CSS inject functions for calling on navigation
const cssInjectFunctions: Record<string, (() => Promise<void[]>) | undefined> = {}

// Preload status tracking for devtools
export type PreloadStatus = 'pending' | 'loading' | 'loaded' | 'error'
export type PreloadEntry = {
  href: string
  status: PreloadStatus
  startTime: number
  endTime?: number
  error?: string
  hasLoader: boolean
  hasCss: boolean
}

const preloadHistory: PreloadEntry[] = []
const MAX_PRELOAD_HISTORY = 30

// Preload tracking functions - only do work in development for devtools
function recordPreloadStart(href: string) {
  if (process.env.NODE_ENV !== 'development') return

  const existing = preloadHistory.find((p) => p.href === href)
  if (existing) {
    existing.status = 'loading'
    existing.startTime = performance.now()
    return
  }
  preloadHistory.unshift({
    href,
    status: 'loading',
    startTime: performance.now(),
    hasLoader: false,
    hasCss: false,
  })
  if (preloadHistory.length > MAX_PRELOAD_HISTORY) {
    preloadHistory.pop()
  }
  dispatchPreloadEvent()
}

function recordPreloadComplete(href: string, hasLoader: boolean, hasCss: boolean) {
  if (process.env.NODE_ENV !== 'development') return

  const entry = preloadHistory.find((p) => p.href === href)
  if (entry) {
    entry.status = 'loaded'
    entry.endTime = performance.now()
    entry.hasLoader = hasLoader
    entry.hasCss = hasCss
  }
  dispatchPreloadEvent()
}

function recordPreloadError(href: string, error: string) {
  if (process.env.NODE_ENV !== 'development') return

  const entry = preloadHistory.find((p) => p.href === href)
  if (entry) {
    entry.status = 'error'
    entry.endTime = performance.now()
    entry.error = error
  }
  dispatchPreloadEvent()
}

function dispatchPreloadEvent() {
  if (process.env.TAMAGUI_TARGET !== 'native') {
    window.dispatchEvent(new CustomEvent('one-preload-update'))
  }
}

export function getPreloadHistory(): PreloadEntry[] {
  return preloadHistory
}

export function preloadRoute(href: string, injectCSS = false): Promise<any> | undefined {
  if (process.env.TAMAGUI_TARGET !== 'native') {
    // in dev mode, use a simpler preload that just fetches the loader directly
    // this avoids issues with production-only preload paths while still ensuring
    // loader data is available before navigation completes
    if (process.env.NODE_ENV === 'development') {
      // normalize the path to match what useLoader uses for cache keys
      const normalizedHref = normalizeLoaderPath(href)
      if (!preloadingLoader[normalizedHref]) {
        preloadingLoader[normalizedHref] = doPreloadDev(href).then((data) => {
          preloadedLoaderData[normalizedHref] = data
          return data
        })
      }
      return preloadingLoader[normalizedHref]
    }

    if (!preloadingLoader[href]) {
      preloadingLoader[href] = doPreload(href).then((data) => {
        // Store the resolved data for synchronous access
        preloadedLoaderData[href] = data
        return data
      })
    }

    if (injectCSS) {
      // Wait for preload to populate cssInjectFunctions, then inject CSS (max 800ms)
      return preloadingLoader[href]?.then(async (data) => {
        const inject = cssInjectFunctions[href]
        if (inject) {
          await Promise.race([inject(), new Promise((r) => setTimeout(r, 800))])
        }
        return data
      })
    }

    return preloadingLoader[href]
  }
}

// normalize path to match what useLoader uses for currentPath
function normalizeLoaderPath(href: string): string {
  // remove search params and hash, normalize trailing slashes and /index
  const url = new URL(href, 'http://example.com')
  return url.pathname.replace(/\/index$/, '').replace(/\/$/, '') || '/'
}

/**
 * Build matches array for client-side navigation.
 * Preserves layout matches (cached from SSR) and updates page match with fresh data.
 *
 * Strategy: Since layouts don't re-run on client navigation, we keep all layout matches
 * from the current cached matches and only update the page match.
 */
function buildClientMatches(
  href: string,
  matchingNode: RouteNode | null,
  params: Record<string, string | string[]>,
  loaderData: unknown
): RouteMatch[] {
  const pathname = extractPathnameFromHref(href)
  const routeId = matchingNode?.contextKey || pathname

  // preserve all layout matches (those with _layout in routeId) from current state
  // since layout loaders don't re-run on client navigation
  const layoutMatches = currentMatches.filter((m) => m.routeId.includes('_layout'))

  // create the new page match with fresh loader data
  const pageMatch: RouteMatch = {
    routeId,
    pathname,
    params,
    loaderData,
  }

  // return layouts + new page match
  return [...layoutMatches, pageMatch]
}

/**
 * Build all matches for native, including layouts and page.
 * Unlike web which preserves SSR-hydrated layouts, native builds fresh
 * since there's no SSR context to hydrate from.
 */
function buildNativeMatches(
  state: OneRouter.ResultState,
  pathname: string,
  params: Record<string, string | string[]>
): RouteMatch[] {
  const allNodes = findAllRouteNodesFromState(state, routeNode)
  return allNodes.map((node) => ({
    routeId: node.contextKey || pathname,
    pathname,
    params,
    loaderData: undefined, // loader data is fetched async by useLoader on native
  }))
}

/**
 * Initialize client matches from server context during hydration.
 * Called from createApp when hydrating.
 */
export function initClientMatches(matches: RouteMatch[]) {
  currentMatches = matches
  setClientMatches(matches)
}

export async function linkTo(
  href: string,
  event?: string,
  options?: OneRouter.LinkToOptions
) {
  if (process.env.ONE_DEBUG_ROUTER) {
    console.info(`[one] 🔗 ${event || 'NAVIGATE'} ${href}`)
  }

  // Mark this as a soft navigation (client-side Link click)
  // This enables intercepting routes to activate
  setNavigationType('soft')

  // clear any active not-found state on new navigation
  clearNotFoundState()

  if (href[0] === '#') {
    // this is just linking to a section of the current page on web
    return
  }

  if (shouldLinkExternally(href)) {
    openExternalURL(href)
    return
  }

  // if a new version was detected via polling, force full page navigation
  if (isVersionStale()) {
    window.location.href = href
    return
  }

  // Check if any blocker wants to block this navigation (web only)
  if (checkBlocker(href, event === 'REPLACE' ? 'replace' : 'push')) {
    return
  }

  // Check if the route is protected and should be blocked
  if (isRouteProtected(href)) {
    return
  }

  // Check for intercepting routes (parallel routes with @slot)
  // This enables modal patterns where soft nav shows modal, hard nav shows full page
  // Pass root node - findInterceptRoute will traverse to find all layouts with slots along the current path
  const currentLayoutNode = routeNode
  const currentPath = routeInfo?.pathname || '/'

  const interceptResult = findInterceptRoute(href, currentLayoutNode, currentPath)

  if (interceptResult) {
    // Found an intercept route! Render in slot instead of full navigation
    const { interceptRoute, slotName, layoutContextKey, params } = interceptResult

    // Create scoped slot key to prevent duplicate modals across layouts
    const scopedSlotKey = `${layoutContextKey}:${slotName}`

    // Store intercept state for forward navigation restoration
    storeInterceptState(scopedSlotKey, interceptRoute, params)

    // Update URL to show the target path (not the intercept route path)
    updateURLWithoutNavigation(href)

    // Activate the slot to render the intercept route (using scoped key)
    setSlotState(scopedSlotKey, {
      activeRouteKey: interceptRoute.contextKey,
      activeRouteNode: interceptRoute,
      params,
      isIntercepted: true,
    })

    return
  }

  assertIsReady(navigationRef)
  const current = navigationRef.current

  if (current == null) {
    throw new Error(
      "Couldn't find a navigation object. Is your component inside NavigationContainer?"
    )
  }

  const linking = getLinking()

  if (!linking) {
    throw new Error('Attempted to link to route when no routes are present')
  }

  setLastAction()

  if (href === '..' || href === '../') {
    current.goBack()
    return
  }

  if (href.startsWith('.')) {
    // Resolve base path by merging the current segments with the params
    let base =
      routeInfo?.segments
        ?.map((segment) => {
          if (!segment.startsWith('[')) return segment

          if (segment.startsWith('[...')) {
            segment = segment.slice(4, -1)
            const params = routeInfo?.params?.[segment]
            if (Array.isArray(params)) {
              return params.join('/')
            }
            return params?.split(',')?.join('/') ?? ''
          }
          segment = segment.slice(1, -1)
          return routeInfo?.params?.[segment]
        })
        .filter(Boolean)
        .join('/') ?? '/'

    if (!routeInfo?.isIndex) {
      base += '/..'
    }

    href = resolve(base, href)
  }

  const state = linking.getStateFromPath!(href, linking.config)

  if (!state || state.routes.length === 0) {
    console.error(
      'Could not generate a valid navigation state for the given path: ' + href
    )
    console.error(`linking.config`, linking.config)
    console.error(`routes`, getSortedRoutes())
    return
  }

  setLoadingState('loading')

  const normalizedPreloadPath = normalizeLoaderPath(href)

  // sync fast path: skip async preload when data is already cached
  // (e.g., from hover/viewport/intent prefetching via PreloadLinks)
  if (!(normalizedPreloadPath in preloadedLoaderData) && !(href in preloadedLoaderData)) {
    await preloadRoute(href, true)
  } else if (process.env.NODE_ENV !== 'development') {
    // cached data in prod: still inject CSS (non-blocking so we don't delay nav)
    const inject = cssInjectFunctions[href]
    if (inject) {
      inject().catch(() => {})
    }
  }

  // detect loader redirect signal before proceeding with navigation
  // this handles the case where a server loader returned redirect() during
  // a client-side navigation — we navigate to the redirect target instead
  const preloadResult = preloadedLoaderData[normalizedPreloadPath]
  if (preloadResult?.__oneRedirect) {
    const redirectTarget = preloadResult.__oneRedirect
    // clean up so subsequent navigations don't see stale redirect
    delete preloadedLoaderData[normalizedPreloadPath]
    delete preloadingLoader[normalizedPreloadPath]
    // also clean the non-normalized key (used in prod)
    delete preloadedLoaderData[href]
    delete preloadingLoader[href]
    setLoadingState('loaded')
    linkTo(redirectTarget, 'REPLACE')
    return
  }

  // detect loader 404 signal before proceeding with navigation
  // (e.g., SSG route where the slug wasn't in generateStaticParams)
  if (preloadResult?.__oneError === 404) {
    delete preloadedLoaderData[normalizedPreloadPath]
    delete preloadingLoader[normalizedPreloadPath]
    delete preloadedLoaderData[href]
    delete preloadingLoader[href]
    setLoadingState('loaded')
    // render 404 inline at current URL instead of navigating
    const notFoundRoute = findNearestNotFoundRoute(href, routeNode)
    setNotFoundState({
      notFoundPath: preloadResult.__oneNotFoundPath || '/+not-found',
      notFoundRouteNode: notFoundRoute || undefined,
      originalPath: href,
    })
    return
  }

  // Run async route validation before navigation
  const matchingRouteNode = findRouteNodeFromState(state, routeNode)
  if (matchingRouteNode?.loadRoute) {
    setValidationState({ status: 'validating', lastValidatedHref: href })

    try {
      const loadedRoute = matchingRouteNode.loadRoute()
      const params = extractParamsFromState(state)
      const search = extractSearchFromHref(href)
      const pathname = extractPathnameFromHref(href)

      // Run validateParams if exported
      if (loadedRoute.validateParams) {
        runValidateParams(loadedRoute.validateParams, params)
      }

      // Run validateRoute if exported
      if (loadedRoute.validateRoute) {
        const validationResult = await loadedRoute.validateRoute({
          params,
          search,
          pathname,
          href,
        })

        // Check for explicit invalid result
        if (validationResult && !validationResult.valid) {
          const error = new RouteValidationError(
            validationResult.error || 'Route validation failed',
            validationResult.details
          )
          setValidationState({ status: 'error', error, lastValidatedHref: href })
          throw error
        }
      }

      setValidationState({ status: 'valid', lastValidatedHref: href })
    } catch (error) {
      // Handle Suspense promises thrown by loadRoute in dev mode
      if (error && typeof (error as any).then === 'function') {
        // Wait for the route to load and skip validation for this navigation
        await (error as Promise<any>).catch(() => {})
        setValidationState({ status: 'valid', lastValidatedHref: href })
      } else if (
        error instanceof ParamValidationError ||
        error instanceof RouteValidationError
      ) {
        setValidationState({ status: 'error', error, lastValidatedHref: href })
        throw error
      } else {
        // Re-throw other errors
        throw error
      }
    }
  }

  // Update client matches for useMatches hook
  // On web: runs after preload so loaderData is available
  // On native: runs without preloaded data (loaders are fetched by useLoader)
  const normalizedPath = normalizeLoaderPath(href)
  const loaderData = preloadedLoaderData[normalizedPath]
  const params = extractParamsFromState(state)
  const newMatches = buildClientMatches(href, matchingRouteNode, params, loaderData)
  currentMatches = newMatches
  setClientMatches(newMatches)

  const currentRootState = navigationRef.getRootState()

  const hash = href.indexOf('#')
  if (currentRootState.key && hash > 0) {
    hashes[currentRootState.key] = href.slice(hash)
  }

  // a bit hacky until can figure out a reliable way to tie it to the state
  nextOptions = options ?? null

  startTransition(() => {
    // compute target at dispatch time to avoid stale state during first render/effects
    const freshRootState = navigationRef.getRootState() as NavigationState
    const action = getNavigateAction(state, freshRootState, event)
    const current = navigationRef.getCurrentRoute()

    // when navigating across route groups (e.g. (public) -> (authed)/beta/signup),
    // the NAVIGATE action can fail to initialize the target group's child navigator
    // with the nested screen. use reset to directly apply the full target state instead.
    const targetName = action.payload?.name
    const isGroupTarget =
      typeof targetName === 'string' &&
      targetName.startsWith('(') &&
      targetName.endsWith(')')
    const hasFreshRootState = freshRootState.type === 'stack'
    const currentFocusedName = freshRootState.routes[freshRootState.index]?.name

    if (isGroupTarget && hasFreshRootState && currentFocusedName !== targetName) {
      // merge the target state onto the existing root state so we preserve
      // other groups' state (back navigation etc.)
      const existingRoutes = freshRootState.routes.filter((r) => r.name !== targetName)
      const targetRoute = {
        ...state.routes[state.routes.length - 1],
        key: `${targetName}-${freshRootState.key}`,
      }
      navigationRef.resetRoot({
        ...freshRootState,
        routes: [...existingRoutes, targetRoute],
        index: existingRoutes.length,
      } as any)
    } else {
      navigationRef.dispatch(action)
    }

    let warningTm
    const interval = setInterval(() => {
      const next = navigationRef.getCurrentRoute()
      if (current !== next) {
        // let the main thread clear at least before running
        setTimeout(() => {
          setLoadingState('loaded')
        })
      }
      clearTimeout(warningTm)
      clearTimeout(interval)
    }, 16)
    if (process.env.NODE_ENV === 'development') {
      warningTm = setTimeout(() => {
        console.warn(`Routing took more than 8 seconds`)
      }, 1000)
    }
  })
}

const hashes: Record<string, string> = {}

let nextOptions: OneRouter.LinkToOptions | null = null

function deepEqual(a: any, b: any) {
  if (a === b) {
    return true
  }

  if (Array.isArray(a) && Array.isArray(b)) {
    if (a.length !== b.length) {
      return false
    }

    for (let i = 0; i < a.length; i++) {
      if (!deepEqual(a[i], b[i])) {
        return false
      }
    }

    return true
  }

  if (typeof a === 'object' && typeof b === 'object') {
    const keysA = Object.keys(a)
    const keysB = Object.keys(b)

    if (keysA.length !== keysB.length) {
      return false
    }

    for (const key of keysA) {
      if (!deepEqual(a[key], b[key])) {
        return false
      }
    }

    return true
  }

  return false
}
