import { RoutePattern, type PartPattern } from './route-pattern.ts'
import type { ParseParams } from './types/params.ts'
import type { Split, SplitPattern } from './types/split.ts'
import type { Simplify } from './types/utils.ts'
import { unreachable } from './unreachable.ts'

/** Tuple of arguments accepted by `createHref` for a given pattern source. */
export type CreateHrefArgs<source extends string> = _CreateHrefArgs<ParseHrefParams<source>>

// prettier-ignore
type _CreateHrefArgs<params> =
  {} extends params ?
    [params?: Simplify<params & Record<string, unknown>> | null | undefined, searchParams?: SearchParams]
  :
  [params: Simplify<params & Record<string, unknown>>, searchParams?: SearchParams]

type SearchParams = Record<
  string,
  string | number | null | undefined | Array<string | number | null | undefined>
>

// prettier-ignore
type ParseHrefParams<source extends string> =
  Split<source> extends infer split extends SplitPattern ?
    split extends ({ protocol: string, hostname: undefined } | { hostname: undefined, port: string }) ? never :
    ParseParams<split> extends infer params extends Record<string, string | undefined> ?
      params extends { '*': string } ? never :
      Optionalize<Omit<params, '*'>>
    :
    never
  :
  never

// prettier-ignore
type Optionalize<record extends Record<string, string | undefined>> =
  & { [key in keyof record as undefined extends record[key] ? never : key]: string | number }
  & { [key in keyof record as undefined extends record[key] ? key : never]?: string | number | null | undefined }

/**
 * Generate an href from a route pattern and the supplied params.
 *
 * @param pattern The parsed route pattern.
 * @param args Path params and optional search params.
 * @returns The generated href string.
 * @throws {CreateHrefError} When the pattern requires a hostname, contains a nameless wildcard,
 * is missing required params, or receives invalid params.
 */
export function createHref<source extends string>(
  pattern: source | RoutePattern<source>,
  ...args: CreateHrefArgs<source>
): string {
  pattern = typeof pattern === 'string' ? RoutePattern.parse(pattern) : pattern
  let [params, searchParams] = args
  searchParams ??= {}

  let hasOrigin = pattern.protocol !== null || pattern.hostname !== null || pattern.port !== null
  let result = ''

  if (hasOrigin) {
    let protocol =
      pattern.protocol === null || pattern.protocol === 'http(s)' ? 'https' : pattern.protocol

    if (pattern.hostname === null) {
      throw new CreateHrefError({ type: 'missing-hostname', pattern })
    }
    let hostname = hrefPart(pattern, pattern.hostname, params ?? {})

    let port = pattern.port === null ? '' : `:${pattern.port}`
    result += `${protocol}://${hostname}${port}`
  }

  let pathname = hrefPart(pattern, pattern.pathname, params ?? {})
  result += '/' + pathname

  let search = hrefSearch(pattern, searchParams)
  if (search) result += `?${search}`

  return result
}

function hrefPart(
  pattern: RoutePattern,
  part: PartPattern,
  params: Record<string, unknown>,
): string {
  let separator = part.type === 'hostname' ? '.' : '/'
  let missingParams: Array<string> = []

  let stack: Array<{ begin?: number; href: string }> = [{ href: '' }]
  let i = 0
  while (i < part.tokens.length) {
    let token = part.tokens[i]
    if (token.type === 'text') {
      stack[stack.length - 1].href += token.text
      i += 1
      continue
    }
    if (token.type === 'separator') {
      stack[stack.length - 1].href += separator
      i += 1
      continue
    }
    if (token.type === '(') {
      stack.push({ begin: i, href: '' })
      i += 1
      continue
    }
    if (token.type === ')') {
      let frame = stack.pop()!
      stack[stack.length - 1].href += frame.href
      i += 1
      continue
    }
    if (token.type === ':' || token.type === '*') {
      let value = params[token.name]
      if (value == null) {
        if (stack.length <= 1) {
          if (token.name === '*') {
            throw new CreateHrefError({ type: 'nameless-wildcard', pattern })
          }
          missingParams.push(token.name)
        }
        let frame = stack.pop()!
        i = part.optionals.get(frame.begin!)! + 1
        continue
      }
      // prettier-ignore
      stack[stack.length - 1].href +=
        part.type === 'pathname' && token.type === ':' ? encodePathnameVariableParam(pattern, token.name, value) :
        part.type === 'pathname' && token.type === '*' ? encodePathnameWildcard(value) :
        part.type === 'hostname' && token.type === ':' ? validateHostnameVariable(value) :
        part.type === 'hostname' && token.type === '*' ? validateHostnameWildcard(value) :
        unreachable()
      i += 1
      continue
    }
    unreachable(token.type)
  }
  if (missingParams.length > 0) {
    throw new CreateHrefError({
      type: 'missing-params',
      pattern,
      part,
      missingParams,
      params,
    })
  }
  if (stack.length !== 1) unreachable()
  return stack[0].href
}

function hrefSearch(pattern: RoutePattern, searchParams: SearchParams): string | undefined {
  let constraints = pattern.search
  if (constraints.size === 0 && Object.keys(searchParams).length === 0) {
    return undefined
  }

  let urlSearchParams = new URLSearchParams()

  for (let [key, value] of Object.entries(searchParams)) {
    if (Array.isArray(value)) {
      for (let v of value) {
        if (v != null) urlSearchParams.append(key, String(v))
      }
    } else if (value != null) {
      urlSearchParams.append(key, String(value))
    }
  }

  for (let [key, requiredValues] of constraints) {
    if (requiredValues.size === 0) {
      if (key in searchParams) continue
      urlSearchParams.append(key, '')
    } else {
      for (let value of requiredValues) {
        if (urlSearchParams.getAll(key).includes(value)) continue
        urlSearchParams.append(key, value)
      }
    }
  }

  let result = urlSearchParams.toString()
  return result || undefined
}

type CreateHrefErrorDetails =
  | { type: 'missing-hostname'; pattern: RoutePattern }
  | {
      type: 'missing-params'
      pattern: RoutePattern
      part: PartPattern
      missingParams: Array<string>
      params: Record<string, unknown>
    }
  | { type: 'nameless-wildcard'; pattern: RoutePattern }
  | {
      type: 'invalid-hostname-variable'
      value: string
      char: string
    }
  | {
      type: 'invalid-hostname-wildcard'
      value: string
      char: string
    }
  | {
      type: 'invalid-pathname-variable'
      pattern: RoutePattern
      paramName: string
      value: string
    }

/** Error thrown when a route pattern cannot generate an href from the supplied args. */
export class CreateHrefError extends Error {
  details: CreateHrefErrorDetails

  constructor(details: CreateHrefErrorDetails) {
    super(CreateHrefError.message(details))
    this.name = 'CreateHrefError'
    this.details = details
  }

  static message(details: CreateHrefErrorDetails): string {
    if (details.type === 'missing-hostname') {
      return `pattern requires hostname\n\nPattern: ${details.pattern}`
    }

    if (details.type === 'nameless-wildcard') {
      return `pattern contains nameless wildcard\n\nPattern: ${details.pattern}`
    }

    if (details.type === 'missing-params') {
      let params = details.missingParams.map((p) => `'${p}'`).join(', ')
      return `missing param(s): ${params}\n\nPattern: ${details.pattern}\nParams: ${JSON.stringify(details.params)}`
    }

    if (details.type === 'invalid-hostname-variable') {
      return `invalid hostname variable param: ${JSON.stringify(details.value)} contains ${JSON.stringify(details.char)}`
    }

    if (details.type === 'invalid-hostname-wildcard') {
      return `invalid hostname wildcard param: ${JSON.stringify(details.value)} contains ${JSON.stringify(details.char)}`
    }

    if (details.type === 'invalid-pathname-variable') {
      return `invalid pathname variable param: '${details.paramName}' cannot be empty\n\nPattern: ${details.pattern}\nValue: ${JSON.stringify(details.value)}`
    }

    unreachable(details)
  }
}

function encodePathnameVariableParam(pattern: RoutePattern, paramName: string, value: unknown) {
  let serialized = String(value)
  if (serialized.length === 0) {
    throw new CreateHrefError({
      type: 'invalid-pathname-variable',
      pattern,
      paramName,
      value: serialized,
    })
  }
  return encodePathnameSegment(serialized)
}

export function encodePathnameVariable(value: unknown) {
  return encodePathnameSegment(String(value))
}

export function encodePathnameWildcard(value: unknown) {
  return String(value).split('/').map(encodePathnameSegment).join('/')
}

/**
 * Keep pathname params from changing URL structure when parsed. `/`, `?`, and `#` are
 * path/query/fragment delimiters; `%` begins percent-encoded bytes; and `\\` is treated as a
 * path separator by special URL parsing.
 *
 * @see https://url.spec.whatwg.org/#path-percent-encode-set
 * @see https://url.spec.whatwg.org/#percent-encoded-bytes
 */
const PATHNAME_PARAM_STRUCTURAL_CHARS: Record<string, string> = {
  '/': '%2F',
  '?': '%3F',
  '#': '%23',
  '%': '%25',
  '\\': '%5C',
}

function encodePathnameSegment(value: string): string {
  let encoded = ''
  for (let char of value) {
    let encodedChar = PATHNAME_PARAM_STRUCTURAL_CHARS[char]
    encoded += encodedChar === undefined ? char : encodedChar
  }
  return encoded
}

/**
 * Keep hostname params from changing URL authority structure when parsed. `@` ends userinfo,
 * `:` starts the port, and `/`, `?`, and `#` start the path, query, and fragment. Hostname
 * variables also reject `.` because dots separate host labels; hostname wildcards allow `.` to
 * span labels intentionally.
 *
 * @see https://url.spec.whatwg.org/#authority-state
 * @see https://url.spec.whatwg.org/#host-parsing
 */
const HOSTNAME_PARAM_STRUCTURAL_CHARS = ['@', ':', '/', '?', '#']

export function validateHostnameVariable(value: unknown): string {
  let serialized = String(value)
  for (let char of serialized) {
    if (char === '.' || HOSTNAME_PARAM_STRUCTURAL_CHARS.includes(char)) {
      throw new CreateHrefError({
        type: 'invalid-hostname-variable',
        value: serialized,
        char,
      })
    }
  }
  return serialized
}

export function validateHostnameWildcard(value: unknown): string {
  let serialized = String(value)
  for (let char of serialized) {
    if (HOSTNAME_PARAM_STRUCTURAL_CHARS.includes(char)) {
      throw new CreateHrefError({
        type: 'invalid-hostname-wildcard',
        value: serialized,
        char,
      })
    }
  }
  return serialized
}
