/**
 * Copyright © 2023 Tamagui LLC.
 * Copyright © 2023 650 Industries.
 * Copyright © 2023 Vercel, Inc.
 *
 * This source code is licensed under the MIT license found in the
 * LICENSE file in the root directory of this source tree.
 *
 * Based on https://github.com/vercel/next.js/blob/1df2686bc9964f1a86c444701fa5cbf178669833/packages/next/src/shared/lib/router/utils/route-regex.ts
 */

import { getContextKey, matchGroupName } from '../router/matchers'
import type { RouteNode } from '../router/Route'
import type { One, RouteInfo } from '../vite/types'

// TODO: Share these types across cli, server, router, etc.
export type OneRouterServerManifestV1Route<TRegex = string> = RouteInfo & {
  generated?: boolean
}

export type OneRouterServerManifestV1<TRegex = string> = {
  apiRoutes: OneRouterServerManifestV1Route<TRegex>[]
  middlewareRoutes: OneRouterServerManifestV1Route<TRegex>[]
  pageRoutes: OneRouterServerManifestV1Route<TRegex>[]
  allRoutes: OneRouterServerManifestV1Route<TRegex>[]
}

export interface Group {
  pos: number
  repeat: boolean
  optional: boolean
}

export interface RouteRegex {
  groups: Record<string, Group>
  re: RegExp
}

function isNotFoundRoute(route: RouteNode) {
  return Boolean(route.dynamic && route.dynamic[route.dynamic.length - 1].notFound)
}

// sort flattened routes by URL pattern specificity so more specific routes match first
// compares segment-by-segment: static > [param] > [...catchAll] > +not-found
function sortByPathSpecificity(
  [keyA, nodeA]: [string, RouteNode],
  [keyB, nodeB]: [string, RouteNode]
): number {
  const partsA = keyA.split('/').filter(Boolean)
  const partsB = keyB.split('/').filter(Boolean)

  const minLen = Math.min(partsA.length, partsB.length)
  for (let i = 0; i < minLen; i++) {
    const aIsDynamic = partsA[i].startsWith('[') || partsA[i] === '+not-found'
    const bIsDynamic = partsB[i].startsWith('[') || partsB[i] === '+not-found'

    // static beats dynamic
    if (!aIsDynamic && bIsDynamic) return -1
    if (aIsDynamic && !bIsDynamic) return 1

    // both dynamic: single param beats catch-all/not-found
    if (aIsDynamic && bIsDynamic) {
      const aIsCatchAll = partsA[i].startsWith('[...') || partsA[i] === '+not-found'
      const bIsCatchAll = partsB[i].startsWith('[...') || partsB[i] === '+not-found'
      if (!aIsCatchAll && bIsCatchAll) return -1
      if (aIsCatchAll && !bIsCatchAll) return 1

      // both catch-all: not-found goes last
      if (aIsCatchAll && bIsCatchAll) {
        const aNotFound = partsA[i] === '+not-found'
        const bNotFound = partsB[i] === '+not-found'
        if (aNotFound && !bNotFound) return 1
        if (!aNotFound && bNotFound) return -1
      }
    }
  }

  // not-found routes always last regardless of segment count
  const aNotFound = isNotFoundRoute(nodeA)
  const bNotFound = isNotFoundRoute(nodeB)
  if (aNotFound && !bNotFound) return 1
  if (!aNotFound && bNotFound) return -1

  // more segments = more specific
  if (partsA.length !== partsB.length) {
    return partsB.length - partsA.length
  }

  return 0
}

// Given a nested route tree, return a flattened array of all routes that can be matched.
export function getServerManifest(route: RouteNode): OneRouterServerManifestV1 {
  function getFlatNodes(route: RouteNode, layouts?: RouteNode[]): [string, RouteNode][] {
    if (route.children.length) {
      return route.children.flatMap((child) => {
        return getFlatNodes(child, [...(layouts || []), route])
      })
    }

    // API Routes are handled differently to HTML routes because they have no nested behavior.
    // An HTML route can be different based on parent segments due to layout routes, therefore multiple
    // copies should be rendered. However, an API route is always the same regardless of parent segments.
    let key: string
    if (route.type === 'api') {
      key = getContextKey(route.contextKey).replace(/\/index$/, '') || '/'
    } else {
      const parentSegments = layouts?.flatMap((route) => {
        const key = getContextKey(route.route).replace(/\/index$/, '') || '/'
        if (key === '/' || key.startsWith('/(')) {
          return []
        }
        return [key]
      })

      key =
        (parentSegments ? parentSegments.join('') : '') +
          getContextKey(route.route).replace(/\/index$/, '') || '/'
    }

    return [[key, { ...route, layouts }]]
  }

  // TODO this could be a lot faster if not functional:

  // Remove duplicates from the runtime manifest which expands array syntax.
  // sort by URL pattern specificity so static segments beat dynamic ones at each position
  const flat = getFlatNodes(route).sort(sortByPathSpecificity)

  // warn on having multiple routes with the same path!
  const pathToRoute: Record<string, RouteNode> = {}
  for (const [path, route] of flat) {
    if (pathToRoute[path]) {
      console.warn(`\n[one] ❌ Duplicate routes error`)
      console.warn(
        `  Multiple routes at the same path! One route will always win over the other.`
      )
      console.warn(`    path: ${path}`)
      console.warn(`    first route: ${pathToRoute[path].contextKey}`)
      console.warn(`    second route: ${route.contextKey}\n`)
    }
    pathToRoute[path] = route
  }

  const apiRoutes: OneRouterServerManifestV1Route[] = []
  const middlewareRoutes: OneRouterServerManifestV1Route[] = []
  const pageRoutes: OneRouterServerManifestV1Route[] = []
  const allRoutes: OneRouterServerManifestV1Route[] = []

  const addedMiddlewares: Record<string, boolean> = {}

  for (const [path, node] of flat) {
    if (node.type === 'api') {
      const route = getGeneratedNamedRouteRegex(path, node)
      apiRoutes.push(route)
      allRoutes.push(route)
      continue
    }

    if (node.middlewares?.length) {
      for (const middleware of node.middlewares) {
        if (!addedMiddlewares[middleware.contextKey]) {
          addedMiddlewares[middleware.contextKey] = true
          middlewareRoutes.push(getGeneratedNamedRouteRegex(path, middleware))
        }
      }
    }

    const route = getGeneratedNamedRouteRegex(path, node)
    pageRoutes.push(route)
    allRoutes.push(route)
  }

  return {
    apiRoutes,
    middlewareRoutes,
    pageRoutes,
    allRoutes,
  }
}

function getGeneratedNamedRouteRegex(
  normalizedRoute: string,
  node: RouteNode
): OneRouterServerManifestV1Route {
  return {
    ...getRouteEntry(normalizedRoute, node),
    generated: true,
    isNotFound: isNotFoundRoute(node),
  }
}

function getRouteEntry(
  normalizedRoute: string,
  node: RouteNode
): OneRouterServerManifestV1Route {
  const result = getPathMeta(normalizedRoute)
  return {
    file: node.contextKey,
    page: getContextKey(node.route),
    type: node.type,
    namedRegex: result.namedRegex,
    urlPath: result.urlPath,
    urlCleanPath: result.urlCleanPath,
    routeKeys: result.routeKeys,
    layouts: node.layouts,
    middlewares: node.middlewares,
  }
}

/**
 * Builds a function to generate a minimal routeKey using only a-z and minimal
 * number of characters.
 */
function buildGetSafeRouteKey() {
  let currentCharCode = 96 // Starting one before 'a' to make the increment logic simpler
  let currentLength = 1

  return () => {
    let result = ''
    let incrementNext = true

    // Iterate from right to left to build the key
    for (let i = 0; i < currentLength; i++) {
      if (incrementNext) {
        currentCharCode++
        if (currentCharCode > 122) {
          currentCharCode = 97 // Reset to 'a'
          incrementNext = true // Continue to increment the next character
        } else {
          incrementNext = false
        }
      }
      result = String.fromCharCode(currentCharCode) + result
    }

    // If all characters are 'z', increase the length of the key
    if (incrementNext) {
      currentLength++
      currentCharCode = 96 // This will make the next key start with 'a'
    }

    return result
  }
}

function removeTrailingSlash(route: string): string {
  return route.replace(/\/$/, '') || '/'
}

function getPathMeta(route: string) {
  const segments = removeTrailingSlash(route).slice(1).split('/')
  const getSafeRouteKey = buildGetSafeRouteKey()
  const routeKeys: Record<string, string> = {}

  const urlPathParts: Array<{ content: string; type?: 'group' }> = []

  const routeSegments = segments
    .map((segment, index) => {
      if (segment === '+not-found' && index === segments.length - 1) {
        segment = '[...not-found]'
      }

      if (/^\[.*\]$/.test(segment)) {
        const { name, optional, repeat } = parseParam(segment)
        // replace non-word characters since they can break the named regex
        let cleanedKey = name.replace(/\W/g, '')
        let invalidKey = false

        // check if the key is still invalid and fallback to using a known safe key
        if (cleanedKey.length === 0 || cleanedKey.length > 30) {
          invalidKey = true
        }
        if (!Number.isNaN(Number.parseInt(cleanedKey.slice(0, 1), 10))) {
          invalidKey = true
        }

        // Prevent duplicates after sanitizing the key
        if (cleanedKey in routeKeys) {
          invalidKey = true
        }

        if (invalidKey) {
          cleanedKey = getSafeRouteKey()
        }

        urlPathParts.push({
          content: repeat ? '/*' : `/:${name}${optional ? '?' : ''}`,
        })
        routeKeys[cleanedKey] = name

        return repeat
          ? optional
            ? `(?:/(?<${cleanedKey}>.+?))?`
            : `/(?<${cleanedKey}>.+?)`
          : `/(?<${cleanedKey}>(?!\\+not-found$)[^/]+?)`
      }

      if (insideParensRegex.test(segment)) {
        const groupName = matchGroupName(segment)!
          .split(',')
          .map((group) => group.trim())
          .filter(Boolean)

        urlPathParts.push({ content: `/:${groupName}?`, type: 'group' })

        if (groupName.length > 1) {
          const optionalSegment = `\\((?:${groupName.map(escapeStringRegexp).join('|')})\\)`

          // Make section optional
          return `(?:/${optionalSegment})?`
        }

        // Use simpler regex for single groups
        return `(?:/${escapeStringRegexp(segment)})?`
      }

      urlPathParts.push({ content: `/${segment}` })

      return `/${escapeStringRegexp(segment)}`
    })
    .join('')

  let urlPath = urlPathParts.map((p) => p.content).join('')
  let urlCleanPath = urlPathParts
    .filter((p) => p.type !== 'group')
    .map((p) => p.content)
    .join('')

  // for catch-all routes ending with /*, remove optional ? from params
  // hono doesn't correctly match /:param?/* patterns
  if (urlPath.endsWith('/*')) {
    urlPath = urlPath.replace(/\?(?=\/|\/\*)/g, '')
  }
  if (urlCleanPath.endsWith('/*')) {
    urlCleanPath = urlCleanPath.replace(/\?(?=\/|\/\*)/g, '')
  }

  return {
    namedRegex: `^${routeSegments}(?:/)?$`,
    urlPath: urlPath === '' ? '/' : urlPath,
    urlCleanPath: urlCleanPath === '' ? '/' : urlCleanPath,
    routeKeys,
  }
}

const insideBracketsRegex = /^\[.*\]$/
const insideParensRegex = /^\(.*\)$/
const tripleDotRegex = /^\.\.\./
const replaceRegex = /[|\\{}()[\]^$+*?.-]/g
// based on https://github.com/sindresorhus/escape-string-regexp
const hasRegExpRegex = /[|\\{}()[\]^$+*?.-]/

function escapeStringRegexp(str: string) {
  // see also: https://github.com/lodash/lodash/blob/2da024c3b4f9947a48517639de7560457cd4ec6c/escapeRegExp.js#L23
  if (hasRegExpRegex.test(str)) {
    return str.replace(replaceRegex, '\\$&')
  }
  return str
}

export function parseParam(param: string) {
  let repeat = false
  let optional = false
  let name = param

  // [[param]] = optional, [param] = required
  if (/^\[\[.*\]\]$/.test(name)) {
    optional = true
    name = name.slice(2, -2)
  } else if (insideBracketsRegex.test(name)) {
    name = name.slice(1, -1)
  }

  if (tripleDotRegex.test(name)) {
    repeat = true
    name = name.slice(3)
  }

  return { name, repeat, optional }
}
