import { IFunctionResult, RuleDefinition, RulesetFunctionContext } from '@stoplight/spectral-core'
import type { JsonPath, Segment } from '@stoplight/types'
import { JSONPath } from 'jsonpath-plus'
import { Logger } from '../logger'

export const TRUTHY_WITH_MESSAGE_ID = 'truthyWithMessage'

type SpectralDocument = RulesetFunctionContext['document']
type RulesetThenFn = Exclude<RuleDefinition['then'], Array<any>>['function']

type MessageFn = ({ path, isFallback }: { path: (string | number)[]; isFallback: boolean }) => string
type TruthyWithMessageOptions = {
  failMsgMapper: MessageFn
  fallbackExtractor: FallbackExtractor
}

/** Extracts a fallback result that will be tested for truthiness */
type FallbackExtractor = (
  failedPath: JsonPath,
  jsonPathExtractor: (fallbackPath: string) => { resolvedPath: JsonPath; value: any }[]
) => { path: JsonPath; value: string } | null

const _anyOrAllOfFallbackExtractor = (
  /** The relative path from within the "anyOf" node */
  pathFromAnyOf: Segment[],
  options: { pathBackoff?: number } = {}
): FallbackExtractor => {
  const { pathBackoff = pathFromAnyOf.length } = options

  if (pathBackoff < 0 || !Number.isInteger(pathBackoff)) {
    throw new Error('Path backoff must be a non-negative integer')
  }

  const anyOrAllOfSegment = '[?(@property === "allOf" || @property === "anyOf")][*]'
  return (failedPath, jsonPathExtractor) => {
    const spliceIndex = failedPath.length - pathBackoff
    const newPath = `$.${failedPath.toSpliced(spliceIndex, pathBackoff, anyOrAllOfSegment, ...pathFromAnyOf).join('.')}`
    const match = jsonPathExtractor(newPath).find(({ value }) => typeof value === 'string')
    return match ? { value: match.value, path: match.resolvedPath } : null
  }
}

export const titleFallbackExtractor: FallbackExtractor = _anyOrAllOfFallbackExtractor(['x-zui', 'title'])
export const descriptionFallbackExtractor: FallbackExtractor = _anyOrAllOfFallbackExtractor(['description'])

/**
 * When the input is falsy, return a message that is generated by the provided function
 *
 * @remark When using the `fallbackExtractor`, it's expected to use a portion
 *  of the resolved path so the fallback query continues from that point.
 */
export function truthyWithMessage(failMsgMapper: MessageFn): RulesetThenFn
export function truthyWithMessage(options: TruthyWithMessageOptions): RulesetThenFn
export function truthyWithMessage(failMsgMapperOrOptions: MessageFn | TruthyWithMessageOptions): RulesetThenFn {
  const { failMsgMapper, fallbackExtractor } =
    typeof failMsgMapperOrOptions === 'function' ? { failMsgMapper: failMsgMapperOrOptions } : failMsgMapperOrOptions

  const fn = (input: string, options: unknown, context: RulesetFunctionContext) => {
    const messages: IFunctionResult[] = []

    if (!_isFalsy(input)) {
      return messages
    }

    const logger = _extractLoggerFromOptions(options)

    const { path } = context
    if (!fallbackExtractor) {
      const message = failMsgMapper({ path, isFallback: false })
      messages.push({ message })
    } else {
      const jsonPathFn = _extractJsonPath.bind(null, context.document)
      try {
        const fallbackResult = fallbackExtractor(path, jsonPathFn)
        if (fallbackResult === null || _isFalsy(fallbackResult.value)) {
          const isFallback = fallbackResult !== null
          const resolvedPath = isFallback ? fallbackResult.path : path

          if (!Array.isArray(resolvedPath)) {
            // This is likely due to a mistake in the fallbackExtractor,
            // which is easy to make since there are "any" types involved
            throw new Error('The resolved path was not provided or is invalid')
          }

          const message = failMsgMapper({ path: resolvedPath, isFallback })
          messages.push({ message })

          // This is for the unit tests
          context.path = resolvedPath
        }
      } catch (thrown: unknown) {
        const error = thrown instanceof Error ? thrown : new Error(String(thrown))
        const message = `[${_getRuleDescriptor(context)}] Ruleset failed in either the fallback extractor or the message supplier -> ${error.message}`
        if (logger) {
          logger.error(message)
        } else {
          messages.push({
            message: message + '\n[IGNORE] ->',
          })
        }
      }
    }

    return messages
  }

  Object.defineProperty(fn, 'name', {
    value: TRUTHY_WITH_MESSAGE_ID,
    configurable: false,
    writable: false,
  })
  return fn
}

const _getRuleDescriptor = (context: RulesetFunctionContext): string | null =>
  ('name' in context.rule ? `${context.rule.name}` : context.rule.description) ?? '<unknown-rule>'

const _isFalsy = (input: string): boolean => {
  return !input
}

/** Parses the equivalent of Stoplight's JsonPath from the JsonPath-plus "pointer" output */
const _parseJsonPathPointer = (pointer: string): JsonPath => {
  return pointer
    .replace(/^\//, '')
    .split('/')
    .map((segment: string) => {
      // Do not use "parseInt" here
      const numOrNan = Number(segment)
      return isNaN(numOrNan) ? segment : numOrNan
    })
}

const _isObjectAndHasProp = <K extends string>(obj: unknown, key: K): obj is Record<K, unknown> => {
  return typeof obj === 'object' ? _safeHasProp(obj, key) : false
}

// This is safer than `"key" in obj` when
// dealing with an unknown/any variable
const _safeHasProp = (obj: unknown, key: string) => {
  return {}.hasOwnProperty.call(obj ?? {}, key)
}

const _extractJsonPath = (schemaDocument: SpectralDocument, fallbackJsonPath: string) => {
  const output = JSONPath({
    path: fallbackJsonPath,
    json: schemaDocument.data as object,
    resultType: 'all',
  })

  // Sanity check (Error should never actually be thrown)
  if (!Array.isArray(output)) {
    throw new Error('JSONPath output is not an array')
  }

  // There is a "path" property, but it's serialized in a way that is harder to parse than "pointer"
  if (!output.every((result) => _safeHasProp(result, 'value') && _safeHasProp(result, 'pointer'))) {
    throw new Error("Fallback JSONPath output is missing either one or both the 'value' or 'pointer' property")
  }

  return output.map((result) => {
    const resolvedPath = _parseJsonPathPointer(result.pointer)
    return { resolvedPath, value: result.value }
  })
}

const _extractLoggerFromOptions = (options: unknown): Logger | null => {
  if (!_isObjectAndHasProp(options, 'logger')) {
    return null
  }

  return options.logger instanceof Logger ? options.logger : null
}
