import * as dntShim from "../../../../_dnt.test_shims.js";
import {
  AssertionError,
  equal
} from '../../std@0.97.0/testing/asserts.js'

import {
  diff,
  DiffResult,
  DiffType
} from '../../std@0.97.0/testing/_diff.js'

import {
  bold,
  gray,
  green,
  red,
  white
} from '../../std@0.97.0/fmt/colors.js'

import * as mock from './mock.js'

type MatcherState = {
  isNot: boolean
}

export type Matcher = (value: any, ...args: any[]) => MatchResult

export type Matchers = {
  [key: string]: Matcher
}
export type MatchResult = {
  pass: boolean
  message?: string
}

const ACTUAL = red(bold('actual'))
const EXPECTED = green(bold('expected'))

const CAN_NOT_DISPLAY = '[Cannot display]'

function createStr(v: unknown): string {
  try {
    return dntShim.Deno.inspect(v)
  } catch (e) {
    return red(CAN_NOT_DISPLAY)
  }
}

function createColor(diffType: DiffType): (s: string) => string {
  switch (diffType) {
    case DiffType.added:
      return (s: string) => green(bold(s))
    case DiffType.removed:
      return (s: string) => red(bold(s))
    default:
      return white
  }
}

function createSign(diffType: DiffType): string {
  switch (diffType) {
    case DiffType.added:
      return '+   '
    case DiffType.removed:
      return '-   '
    default:
      return '    '
  }
}

function buildMessage(diffResult: ReadonlyArray<DiffResult<string>>): string {
  return diffResult
    .map((result: DiffResult<string>) => {
      const c = createColor(result.type)
      return c(`${createSign(result.type)}${result.value}`)
    })
    .join('\n')
}

function buildDiffMessage(actual: unknown, expected: unknown) {
  const actualString = createStr(actual)
  const expectedString = createStr(expected)

  let message
  try {
    const diffResult = diff(
      actualString.split('\n'),
      expectedString.split('\n')
    )

    return buildMessage(diffResult)
  } catch (e) {
    return `\n${red(CAN_NOT_DISPLAY)} + \n\n`
  }
}

function buildFail(message: string) {
  return {
    pass: false,
    message
  }
}

export function toBe(actual: any, expected: any): MatchResult {
  if (actual === expected) return { pass: true }

  return buildFail(
    `expect(${ACTUAL}).toBe(${EXPECTED})\n\n${buildDiffMessage(
      actual,
      expected
    )}`
  )
}

export function toEqual(actual: any, expected: any): MatchResult {
  if (equal(actual, expected)) return { pass: true }

  return buildFail(
    `expect(${ACTUAL}).toEqual(${EXPECTED})\n\n${buildDiffMessage(
      actual,
      expected
    )}`
  )
}

export function toBeGreaterThan(actual: any, comparison: number): MatchResult {
  if (actual > comparison) return { pass: true }

  const actualString = createStr(actual)
  const comparisonString = createStr(comparison)

  return buildFail(
    `expect(${ACTUAL}).toBeGreaterThan(${EXPECTED})\n\n  ${red(
      actualString
    )} is not greater than ${green(comparisonString)}`
  )
}

export function toBeLessThan(actual: any, comparison: number): MatchResult {
  if (actual < comparison) return { pass: true }

  const actualString = createStr(actual)
  const comparisonString = createStr(comparison)

  return buildFail(
    `expect(${ACTUAL}).toBeLessThan(${EXPECTED})\n\n  ${red(
      actualString
    )} is not less than ${green(comparisonString)}`
  )
}

export function toBeGreaterThanOrEqual(
  actual: any,
  comparison: number
): MatchResult {
  if (actual >= comparison) return { pass: true }

  const actualString = createStr(actual)
  const comparisonString = createStr(comparison)

  return buildFail(
    `expect(${ACTUAL}).toBeGreaterThanOrEqual(${EXPECTED})\n\n  ${red(
      actualString
    )} is not greater than or equal to ${green(comparisonString)}`
  )
}

export function toBeLessThanOrEqual(
  actual: any,
  comparison: number
): MatchResult {
  if (actual <= comparison) return { pass: true }

  const actualString = createStr(actual)
  const comparisonString = createStr(comparison)

  return buildFail(
    `expect(${ACTUAL}).toBeLessThanOrEqual(${EXPECTED})\n\n  ${red(
      actualString
    )} is not less than or equal to ${green(comparisonString)}`
  )
}

export function toBeTruthy(value: any): MatchResult {
  if (value) return { pass: true }

  const actualString = createStr(value)

  return buildFail(`expect(${ACTUAL}).toBeTruthy()

      ${red(actualString)} is not truthy`)
}

export function toBeFalsy(value: any): MatchResult {
  if (!value) return { pass: true }

  const actualString = createStr(value)

  return buildFail(
    `expect(${ACTUAL}).toBeFalsy()\n\n    ${red(actualString)} is not falsy`
  )
}

export function toBeDefined(value: unknown): MatchResult {
  if (typeof value !== 'undefined') return { pass: true }

  const actualString = createStr(value)

  return buildFail(
    `expect(${ACTUAL}).toBeDefined()\n\n    ${red(actualString)} is not defined`
  )
}

export function toBeUndefined(value: unknown): MatchResult {
  if (typeof value === 'undefined') return { pass: true }

  const actualString = createStr(value)

  return buildFail(
    `expect(${ACTUAL}).toBeUndefined()\n\n    ${red(
      actualString
    )} is defined but should be undefined`
  )
}

export function toBeNull(value: unknown): MatchResult {
  if (value === null) return { pass: true }

  const actualString = createStr(value)

  return buildFail(
    `expect(${ACTUAL}).toBeNull()\n\n    ${red(actualString)} should be null`
  )
}

export function toBeNaN(value: unknown): MatchResult {
  if (typeof value === 'number' && isNaN(value)) return { pass: true }

  const actualString = createStr(value)

  return buildFail(
    `expect(${ACTUAL}).toBeNaN()\n\n    ${red(actualString)} should be NaN`
  )
}

export function toBeInstanceOf(value: any, expected: Function): MatchResult {
  if (value instanceof expected) return { pass: true }

  const actualString = createStr(value)
  const expectedString = createStr(expected)

  return buildFail(
    `expect(${ACTUAL}).toBeInstanceOf(${EXPECTED})\n\n    expected ${green(
      expected.name
    )} but received ${red(actualString)}`
  )
}

export function toMatch(value: any, pattern: RegExp | string): MatchResult {
  const valueStr = value.toString()
  if (typeof pattern === 'string') {
    if (valueStr.indexOf(pattern) !== -1) return { pass: true }

    const actualString = createStr(value)
    const patternString = createStr(pattern)

    return buildFail(
      `expect(${ACTUAL}).toMatch(${EXPECTED})\n\n    expected ${red(
        actualString
      )} to contain ${green(patternString)}`
    )
  } else if (pattern instanceof RegExp) {
    if (pattern.exec(valueStr)) return { pass: true }

    const actualString = createStr(value)
    const patternString = createStr(pattern)

    return buildFail(
      `expect(${ACTUAL}).toMatch(${EXPECTED})\n\n    ${red(
        actualString
      )} did not match regex ${green(patternString)}`
    )
  } else {
    return buildFail('Invalid internal state')
  }
}

export function toHaveProperty(value: any, propName: string): MatchResult {
  if (typeof value === 'object' && typeof value[propName] !== 'undefined') {
    return { pass: true }
  }

  const actualString = createStr(value)
  const propNameString = createStr(propName)

  return buildFail(
    `expect(${ACTUAL}).toHaveProperty(${EXPECTED})\n\n    ${red(
      actualString
    )} did not contain property ${green(propNameString)}`
  )
}
export function toHaveLength(value: any, length: number): MatchResult {
  if (value?.length === length) return { pass: true }

  const actualString = createStr(value.length)
  const lengthString = createStr(length)

  return buildFail(
    `expect(${ACTUAL}).toHaveLength(${EXPECTED})\n\n    expected array to have length ${green(
      lengthString
    )} but was ${red(actualString)}`
  )
}

export function toContain(value: any, item: any): MatchResult {
  if (value && typeof value.includes === 'function' && value.includes(item)) {
    return { pass: true }
  }

  const actualString = createStr(value)
  const itemString = createStr(item)

  if (value && typeof value.includes === 'function') {
    return buildFail(
      `expect(${ACTUAL}).toContain(${EXPECTED})\n\n    ${red(
        actualString
      )} did not contain ${green(itemString)}`
    )
  } else {
    return buildFail(
      `expect(${ACTUAL}).toContain(${EXPECTED})\n\n    expected ${red(
        actualString
      )} to have an includes method but it is ${green(itemString)}`
    )
  }
}

export function toThrow(value: any, error?: RegExp | string): MatchResult {
  let fn
  if (typeof value === 'function') {
    fn = value
    try {
      value = value()
    } catch (err) {
      value = err
    }
  }

  const actualString = createStr(fn)
  const errorString = createStr(error)

  if (value instanceof Error) {
    if (typeof error === 'string') {
      if (!value.message.includes(error)) {
        return buildFail(
          `expect(${ACTUAL}).toThrow(${EXPECTED})\n\nexpected ${red(
            actualString
          )} to throw error matching ${green(errorString)} but it threw ${red(
            value.toString()
          )}`
        )
      }
    } else if (error instanceof RegExp) {
      if (!value.message.match(error)) {
        return buildFail(
          `expect(${ACTUAL}).toThrow(${EXPECTED})\n\nexpected ${red(
            actualString
          )} to throw error matching ${green(errorString)} but it threw ${red(
            value.toString()
          )}`
        )
      }
    }

    return { pass: true }
  } else {
    return buildFail(
      `expect(${ACTUAL}).toThrow(${EXPECTED})\n\nexpected ${red(
        actualString
      )} to throw but it did not`
    )
  }
}

function extractMockCalls(
  value: any,
  name: string
): { error?: string; calls: mock.MockCall[] | null } {
  if (typeof value !== 'function') {
    return {
      calls: null,
      error: `${name} only works on mock functions. received: ${value}`
    }
  }
  const calls = mock.calls(value)
  if (calls === null) {
    return { calls: null, error: `${name} only works on mock functions` }
  }

  return { calls }
}

export function toHaveBeenCalled(value: any): MatchResult {
  const { calls, error } = extractMockCalls(value, 'toHaveBeenCalled')
  if (error) return buildFail(error)

  const actualString = createStr(value)

  if (calls && calls.length !== 0) return { pass: true }

  return buildFail(
    `expect(${ACTUAL}).toHaveBeenCalled()\n\n    ${red(
      actualString
    )} was not called`
  )
}

export function toHaveBeenCalledTimes(value: any, times: number): MatchResult {
  const { calls, error } = extractMockCalls(value, 'toHaveBeenCalledTimes')
  if (error) return buildFail(error)
  if (!calls) return buildFail('Invalid internal state')

  if (calls && calls.length === times) return { pass: true }

  return buildFail(
    `expect(${ACTUAL}).toHaveBeenCalledTimes(${EXPECTED})\n\n    expected ${times} calls but was called: ${calls.length}`
  )
}

export function toHaveBeenCalledWith(value: any, ...args: any[]): MatchResult {
  const { calls, error } = extractMockCalls(value, 'toHaveBeenCalledWith')
  if (error) return buildFail(error)

  const wasCalledWith = calls && calls.some((c) => equal(c.args, args))
  if (wasCalledWith) return { pass: true }

  const argsString = createStr(args)

  return buildFail(
    `expect(${ACTUAL}).toHaveBeenCalledWith(${EXPECTED})\n\n    function was not called with: ${green(
      argsString
    )}`
  )
}

export function toHaveBeenLastCalledWith(
  value: any,
  ...args: any[]
): MatchResult {
  const { calls, error } = extractMockCalls(value, 'toHaveBeenLastCalledWith')
  if (error) return buildFail(error)

  if (!calls || !calls.length) {
    return buildFail(
      `expect(${ACTUAL}).toHaveBeenLastCalledWith(...${EXPECTED})\n\n    expect last call args to be ${args} but was not called`
    )
  }

  const lastCall = calls[calls.length - 1]
  if (equal(lastCall.args, args)) return { pass: true }

  return buildFail(
    `expect(${ACTUAL}).toHaveBeenLastCalledWith(...${EXPECTED})\n\n    expect last call args to be ${args} but was: ${lastCall.args}`
  )
}

export function toHaveBeenNthCalledWith(
  value: any,
  nth: number,
  ...args: any[]
): MatchResult {
  const { calls, error } = extractMockCalls(value, 'toHaveBeenNthCalledWith')
  if (error) return buildFail(error)

  const nthCall = calls && calls[nth - 1]
  if (nthCall) {
    if (equal(nthCall.args, args)) return { pass: true }
    return buildFail(
      `expect(${ACTUAL}).toHaveBeenNthCalledWith(${EXPECTED})\n\n    expect ${nth}th call args to be ${args} but was: ${nthCall.args}`
    )
  } else {
    return buildFail(
      `expect(${ACTUAL}).toHaveBeenNthCalledWith(${EXPECTED})\n\n    ${nth}th call was not made.`
    )
  }
}

export function toHaveReturnedWith(value: any, result: any): MatchResult {
  const { calls, error } = extractMockCalls(value, 'toHaveReturnedWith')
  if (error) return buildFail(error)

  const wasReturnedWith =
    calls && calls.some((c) => c.returns && equal(c.returned, result))
  if (wasReturnedWith) return { pass: true }

  return buildFail(
    `expect(${ACTUAL}).toHaveReturnedWith(${EXPECTED})\n\n    function did not return: ${result}`
  )
}

export function toHaveReturned(value: any): MatchResult {
  const { calls, error } = extractMockCalls(value, 'toHaveReturned')
  if (error) return buildFail(error)

  if (calls && calls.some((c) => c.returns)) return { pass: true }

  // TODO(allain): better messages
  return buildFail(`expected function to return but it never did`)
}

// TODO(allain): better messages
export function toHaveLastReturnedWith(value: any, expected: any): MatchResult {
  const { calls, error } = extractMockCalls(value, 'toHaveLastReturnedWith')
  if (error) return buildFail(error)

  const lastCall = calls && calls[calls.length - 1]
  if (!lastCall) {
    return buildFail('no calls made to function')
  }
  if (lastCall.throws) {
    return buildFail(`last call to function threw: ${lastCall.thrown}`)
  }

  if (equal(lastCall.returned, expected)) return { pass: true }

  return buildFail(
    `expected last call to return ${expected} but returned: ${lastCall.returned}`
  )
}

export function toHaveReturnedTimes(value: any, times: number): MatchResult {
  const { calls, error } = extractMockCalls(value, 'toHaveReturnedTimes')
  if (error) return buildFail(error)

  const returnCount = calls && calls.filter((c) => c.returns).length
  if (returnCount !== times) {
    return buildFail(
      `expected ${times} returned times but returned ${returnCount} times`
    )
  }

  return { pass: true }
}

export function toHaveNthReturnedWith(
  value: any,
  nth: number,
  expected: any
): MatchResult {
  const { calls, error } = extractMockCalls(value, 'toHaveNthReturnedWith')
  if (error) return buildFail(error)

  const nthCall = calls && calls[nth - 1]
  if (!nthCall) {
    return buildFail(`${nth} calls were now made`)
  }

  if (nthCall.throws) {
    return buildFail(`${nth}th call to function threw: ${nthCall.thrown}`)
  }

  if (!equal(nthCall.returned, expected)) {
    return buildFail(
      `expected ${nth}th call to return ${expected} but returned: ${nthCall.returned}`
    )
  }

  return { pass: true }
}
