/**
 * This file exports things that will be used to modify the forked code in `getStateFromPath.ts`.
 *
 * The purpose of keeping things in this separated file is to keep changes to the copied code as little as possible, making merging upstream updates easier.
 */

import escape_ from 'escape-string-regexp'
import { matchGroupName, stripGroupSegmentsFromPath } from '../router/matchers'
import type { InitialRouteConfig, ParsedRoute, RouteConfig } from './getStateFromPath'

export type AdditionalRouteConfig = {
  type: 'static' | 'dynamic' | 'layout'
  userReadableName: string
  isIndex: boolean
  isInitial?: boolean
  hasChildren: boolean
  expandedRouteNames: string[]
  parts: string[]
  staticPartCount: number
}

interface UrlWithReactNavigationConcessions {
  path: string
  nonstandardPathname: string
  hash: string
  pathWithoutGroups: string
}

export function getUrlWithReactNavigationConcessions(
  path: string,
  baseUrl: string | undefined = process.env.EXPO_BASE_URL
): UrlWithReactNavigationConcessions {
  const pathWithoutGroups = stripGroupSegmentsFromPath(stripBaseUrl(path, baseUrl))

  let pathname = ''
  let hash = ''
  try {
    // parse pathname and hash without URL constructor (Hermes doesn't support file: base URLs)
    const hashIndex = path.indexOf('#')
    const queryIndex = path.indexOf('?')
    if (hashIndex >= 0) {
      hash = path.slice(hashIndex)
    }
    // pathname ends before query or hash, whichever comes first
    let end = path.length
    if (queryIndex >= 0) end = Math.min(end, queryIndex)
    if (hashIndex >= 0) end = Math.min(end, hashIndex)
    pathname = path.slice(0, end)

    // strip origin from absolute URLs (e.g. https://acme.com/path -> /path)
    if (pathname.startsWith('http://') || pathname.startsWith('https://')) {
      const originEnd = pathname.indexOf('/', pathname.indexOf('//') + 2)
      if (originEnd >= 0) {
        pathname = pathname.slice(originEnd)
      }
    }
  } catch {
    // Do nothing with invalid URLs.
  }

  const withoutBaseUrl = stripBaseUrl(pathname, baseUrl)
  return {
    path,
    // Make sure there is a trailing slash
    // The slashes are at the end, not the beginning
    nonstandardPathname: withoutBaseUrl.replace(/^\/+/g, '').replace(/\/+$/g, '') + '/',
    hash,
    pathWithoutGroups,
  }
}

export function matchForEmptyPath(configs: RouteConfig[]) {
  // We need to add special handling of empty path so navigation to empty path also works
  // When handling empty path, we should only look at the root level config

  // NOTE: We only care about matching leaf nodes.
  const leafNodes = configs
    .filter((config) => !config.hasChildren)
    .map((value) => {
      return {
        ...value,
        // Collapse all levels of group segments before testing.
        // This enables `app/(one)/(two)/index.js` to be matched.
        path: stripGroupSegmentsFromPath(value.path),
      }
    })

  const match =
    leafNodes.find(
      (config) =>
        // NOTE: Test leaf node index routes that either don't have a regex or match an empty string.
        config.path === '' && (!config.regex || config.regex.test(''))
    ) ??
    leafNodes.find(
      (config) =>
        // NOTE: Test leaf node dynamic routes that match an empty string.
        config.path.startsWith(':') && config.regex!.test('')
    ) ??
    // NOTE: Test leaf node deep dynamic routes that match a slash.
    // This should be done last to enable dynamic routes having a higher priority.
    leafNodes.find((config) => config.path.startsWith('*') && config.regex!.test('/'))

  return match
}

export function appendIsInitial(initialRoutes: InitialRouteConfig[]) {
  const resolvedInitialPatterns = initialRoutes.map((route) =>
    joinPaths(...route.parentScreens, route.initialRouteName)
  )

  return (config: RouteConfig) => {
    // TODO: Probably a safer way to do this
    // Mark initial routes to give them potential priority over other routes that match.
    config.isInitial = resolvedInitialPatterns.includes(config.routeNames.join('/'))
    return config
  }
}

const joinPaths = (...paths: string[]): string =>
  ([] as string[])
    .concat(...paths.map((p) => p.split('/')))
    .filter(Boolean)
    .join('/')

export function getRouteConfigSorter(previousSegments: string[] = []) {
  return function sortConfigs(a: RouteConfig, b: RouteConfig) {
    // Sort config so that:
    // - the most exhaustive ones are always at the beginning
    // - patterns with wildcard are always at the end

    // If 2 patterns are same, move the one with less route names up
    // This is an error state, so it's only useful for consistent error messages
    if (a.pattern === b.pattern) {
      return b.routeNames.join('>').localeCompare(a.routeNames.join('>'))
    }

    /*
     * If one of the patterns starts with the other, it is earlier in the config sorting.
     * However, configs are a mix of route configs and layout configs
     * e.g There will be a config for `/(group)`, but maybe there isn't a `/(group)/index.tsx`
     *
     * This is because you can navigate to a directory and its navigator will determine the route
     * These routes should be later in the config sorting, as their patterns are very open
     * and will prevent routes from being matched
     *
     * Therefore before we compare segment parts, we force these layout configs later in the sorting
     *
     * NOTE: Is this a feature we want? I'm unsure if this is a gimmick or a feature.
     */
    if (a.pattern.startsWith(b.pattern) && !b.isIndex) {
      return -1
    }

    if (b.pattern.startsWith(a.pattern) && !a.isIndex) {
      return 1
    }

    /*
     * Static routes should always be higher than dynamic and layout routes.
     */
    if (a.type === 'static' && b.type !== 'static') {
      return -1
    }
    if (a.type !== 'static' && b.type === 'static') {
      return 1
    }

    /*
     * If the routes have any static segments, the one with the most static segments should be higher
     */
    if (a.staticPartCount !== b.staticPartCount) {
      return b.staticPartCount - a.staticPartCount
    }

    /*
     * If both are static/dynamic or a layout file, then we check group similarity
     */
    const similarToPreviousA = previousSegments.filter((value, index) => {
      return (
        value === a.expandedRouteNames[index] &&
        value.startsWith('(') &&
        value.endsWith(')')
      )
    })

    const similarToPreviousB = previousSegments.filter((value, index) => {
      return (
        value === b.expandedRouteNames[index] &&
        value.startsWith('(') &&
        value.endsWith(')')
      )
    })

    if (
      (similarToPreviousA.length > 0 || similarToPreviousB.length > 0) &&
      similarToPreviousA.length !== similarToPreviousB.length
    ) {
      // One matches more than the other, so pick the one that matches more
      return similarToPreviousB.length - similarToPreviousA.length
    }

    /*
     * If there is not difference in similarity, then each non-group segment is compared against each other
     */
    for (let i = 0; i < Math.max(a.parts.length, b.parts.length); i++) {
      // if b is longer, b get higher priority
      if (a.parts[i] == null) {
        return 1
      }
      // if a is longer, a get higher priority
      if (b.parts[i] == null) {
        return -1
      }

      const aWildCard = a.parts[i].startsWith('*')
      const bWildCard = b.parts[i].startsWith('*')
      // if both are wildcard we compare next component
      if (aWildCard && bWildCard) {
        const aNotFound = a.parts[i].match(/^[*]not-found$/)
        const bNotFound = b.parts[i].match(/^[*]not-found$/)

        if (aNotFound && bNotFound) {
          continue
        }
        if (aNotFound) {
          return 1
        }
        if (bNotFound) {
          return -1
        }
        continue
      }
      // if only a is wild card, b get higher priority
      if (aWildCard) {
        return 1
      }
      // if only b is wild card, a get higher priority
      if (bWildCard) {
        return -1
      }

      const aSlug = a.parts[i].startsWith(':')
      const bSlug = b.parts[i].startsWith(':')
      // if both are wildcard we compare next component
      if (aSlug && bSlug) {
        const aNotFound = a.parts[i].match(/^[*]not-found$/)
        const bNotFound = b.parts[i].match(/^[*]not-found$/)

        if (aNotFound && bNotFound) {
          continue
        }
        if (aNotFound) {
          return 1
        }
        if (bNotFound) {
          return -1
        }

        continue
      }
      // if only a is wild card, b get higher priority
      if (aSlug) {
        return 1
      }
      // if only b is wild card, a get higher priority
      if (bSlug) {
        return -1
      }
    }

    /*
     * Both configs are identical in specificity and segments count/type
     * Try and sort by initial instead.
     *
     * TODO: We don't differentiate between the default initialRoute and group specific default routes
     *
     * const unstable_settings = {
     *   "group": {
     *     initialRouteName: "article"
     *  }
     * }
     *
     * "article" will be ranked higher because its an initialRoute for a group - even if not your not currently in
     * that group. The current work around is to ways provide initialRouteName for all groups
     */
    if (a.isInitial && !b.isInitial) {
      return -1
    }
    if (!a.isInitial && b.isInitial) {
      return 1
    }

    return b.parts.length - a.parts.length
  }
}

export function formatRegexPattern(it: string): string {
  // Allow spaces in file path names.
  it = it.replace(/ /g, '%20')

  if (it.startsWith(':')) {
    // TODO: Remove unused match group
    // exclude +not-found from being matched by dynamic segments
    return `(((?!\\+not-found(?:/|$))[^/]+\\/)${it.endsWith('?') ? '?' : ''})`
  }

  if (it.startsWith('*')) {
    return `((.*\\/)${it.endsWith('?') ? '?' : ''})`
  }

  // Strip groups from the matcher
  if (matchGroupName(it) != null) {
    // Groups are optional segments
    // this enables us to match `/bar` and `/(foo)/bar` for the same route
    // NOTE(EvanBacon): Ignore this match in the regex to avoid capturing the group
    return `(?:${escape(it)}\\/)?`
  }

  return escape_(it) + `\\/`
}

export function decodeURIComponentSafe(str: string) {
  try {
    return decodeURIComponent(str)
  } catch {
    return str
  }
}

/**
 * In One, the params are available at all levels of the routing config
 */
export function populateParams(routes?: ParsedRoute[], params?: Record<string, any>) {
  if (!routes || !params || Object.keys(params).length === 0) return

  for (const route of routes) {
    Object.assign(route, { params })
  }

  return routes
}

export function createConfigItemAdditionalProperties(
  screen: string,
  pattern: string,
  routeNames: string[],
  config: Record<string, any> = {}
): Omit<AdditionalRouteConfig, 'isInitial'> {
  const parts: string[] = []
  let isDynamic = false
  let staticPartCount = 0
  const isIndex = screen === 'index' || screen.endsWith('/index')

  for (const part of pattern.split('/')) {
    if (part) {
      // If any part is dynamic, then the route is dynamic
      const isDynamicPart =
        part.startsWith(':') || part.startsWith('*') || part.includes('*not-found')

      isDynamic ||= isDynamicPart

      if (!matchGroupName(part)) {
        parts.push(part)

        if (!isDynamicPart) {
          staticPartCount++
        }
      }
    }
  }

  const hasChildren = config.screens ? !!Object.keys(config.screens)?.length : false
  const type = hasChildren ? 'layout' : isDynamic ? 'dynamic' : 'static'

  if (isIndex) {
    parts.push('index')
    staticPartCount++
  }

  return {
    type,
    isIndex,
    hasChildren,
    parts,
    staticPartCount,
    userReadableName: [...routeNames.slice(0, -1), config.path || screen].join('/'),
    expandedRouteNames: routeNames.flatMap((name) => {
      return name.split('/')
    }),
  }
}

export function parseQueryParamsExtended(
  path: string,
  route: ParsedRoute,
  parseConfig?: Record<string, (value: string) => any>,
  hash?: string
) {
  const queryIndex = path.indexOf('?')
  const hashIndex = path.indexOf('#')
  // only parse between ? and # to avoid hash leaking into search params
  const queryEnd = hashIndex >= 0 && hashIndex > queryIndex ? hashIndex : undefined
  const searchParams = new URLSearchParams(
    queryIndex >= 0 ? path.slice(queryIndex + 1, queryEnd) : ''
  )
  const params: Record<string, string | string[]> = Object.create(null)

  if (hash) {
    params['#'] = hash.slice(1)
  }

  for (const name of searchParams.keys()) {
    if (route.params?.[name]) {
      if (process.env.NODE_ENV !== 'production') {
        console.warn(
          `Route '/${route.name}' with param '${name}' was specified both in the path and as a param, removing from path`
        )
      }
    } else {
      // biome-ignore lint/suspicious/noPrototypeBuiltins: forked code from react-navigation
      const values = parseConfig?.hasOwnProperty(name)
        ? searchParams.getAll(name).map((value) => parseConfig[name](value))
        : searchParams.getAll(name)

      // searchParams.getAll returns an array.
      // if we only have a single value, and its not an array param, we need to extract the value
      params[name] = values.length === 1 ? values[0] : values
    }
  }

  return Object.keys(params).length ? params : undefined
}

export function stripBaseUrl(
  path: string,
  baseUrl: string | undefined = process.env.EXPO_BASE_URL
) {
  if (process.env.NODE_ENV !== 'development') {
    if (baseUrl) {
      return path
        .replace(/^\/+/g, '/')
        .replace(new RegExp(`^\\/?${escape(baseUrl)}`, 'g'), '')
    }
  }
  return path
}
