/* eslint-disable unicorn/no-instanceof-builtins */
import { $grn, $gry, $red, $und, $wht, $ylw } from '@plugjs/plug/logging'
import { textDiff } from '@plugjs/plug/utils'

import { isMatcher, stringifyValue } from './types'

import type { Logger } from '@plugjs/plug/logging'
import type {
  Diff,
  ExpectedDiff,
  ExtraValueDiff,
  MissingValueDiff,
  ObjectDiff,
} from './diff'

/* ========================================================================== *
 * CONSTANT LABELS FOR PRINTING                                               *
 * ========================================================================== */

const _opnPar = $gry('(')
const _clsPar = $gry(')')

const _opnCrl = $gry('{')
const _clsCrl = $gry('}')
const _curls = $gry('{}')

const _opnSqr = $gry('[')
const _clsSqr = $gry(']')
const _squares = $gry('[]')

const _slash = $gry('/')
const _tilde = $gry('~')

const _error = `${_opnPar}${$gry($und('error'))}${_clsPar}`
const _string = `${_opnPar}${$gry($und('string'))}${_clsPar}`
const _matcher = $gry('\u2026 matcher \u2026')
const _extraProps = $gry('\u2026 extra props \u2026')
const _diffHeader = `${$wht('Differences')} ${_opnPar}${$red('actual')}${_slash}${$grn('expected')}${_slash}${$ylw('errors')}${_clsPar}:`

/* ========================================================================== *
 * PRINT DEPENDING ON DIFF TYPE                                               *
 * ========================================================================== */

function printBaseDiff(
    log: Logger,
    diff: Diff,
    prop: string,
    mapping: boolean,
    comma: boolean,
): void {
  if ('props' in diff) return printObjectDiff(log, diff, prop, mapping, comma)
  if ('values' in diff) return printObjectDiff(log, diff, prop, mapping, comma)
  if ('mappings' in diff) return printObjectDiff(log, diff, prop, mapping, comma)
  if ('expected' in diff) return printExpectedDiff(log, diff, prop, mapping, comma)
  if ('missing' in diff) return printMissingDiff(log, diff, prop, mapping, comma)
  if ('extra' in diff) return printExtraDiff(log, diff, prop, mapping, comma)

  const { prefix, suffix } =
    diff.error ? // default style if error is the only property
      fixups(prop, mapping, comma, diff.error) :
      diff.diff ? // label as "differs" if no error was found
        fixups(prop, mapping, comma, diff.error, $red, 'differs') :
        fixups(prop, mapping, comma, diff.error)

  dump(log, diff.value, prefix, suffix, diff.diff ? $red : $wht)
}

/* ========================================================================== */

function formatString(
    value: string,
    color: ((string: string) => string),
): string {
  // determine the color of each character
  const characters: [ (string: string) => string, string ][] = []
  for (let i = 0; i < value.length; i ++) {
    const c = value.charCodeAt(i)
    if (c === 0x20) { // space
      characters.push([ $gry, '\u00b7' ])
    } else if (c === 0x09) { // tab
      characters.push([ $gry, ' \u2192 ' ])
    } else if (c < 0x10) { // control characters (1 hex digit)
      characters.push([ $gry, `\\0${c.toString(16).toUpperCase()}` ])
    } else if (c < 0x20) { // control characters (2 hex digits)
      characters.push([ $gry, `\\${c.toString(16).toUpperCase()}` ])
    } else if ((c >= 0x7F) && (c <= 0xA0)) { // del and non-printable latin 1
      characters.push([ $gry, `\\${c.toString(16).toUpperCase()}` ])
    } else { // anything else
      characters.push([ color, value[i]! ])
    }
  }

  // group characters by color
  const chunks = characters.reduce((acc, [ color, c ]) => {
    const prev = acc[acc.length - 1]
    if (prev?.[0] === color) prev[1] += c
    else acc.push([ color, c ])
    return acc
  }, [] as typeof characters)

  // join chunks together colorizing each one
  return chunks.map(([ color, c ]) => color(c)).join('')
}

function printExpectedDiff(
    log: Logger,
    diff: ExpectedDiff,
    prop: string,
    mapping: boolean,
    comma: boolean,
): void {
  // two different strings get a special treatment: a proper "diff"
  if ((typeof diff.value === 'string') && (typeof diff.expected === 'string')) {
    const { prefix, suffix } = fixups(prop, mapping, false, diff.error)

    log.warn(`${prefix}${_string}${suffix}`)
    log.warn(textDiff(
        diff.value,
        diff.expected,
        (add: string) => `  ${$gry('+')} ${formatString(add, $grn)}`,
        (del: string) => `  ${$gry('-')} ${formatString(del, $red)}`,
        (txt: string) => `    ${formatString(txt, (s) => s)}`))

  // if "value" is not an object (can fit on one line) we use it as prefix
  } else if ((diff.value === null) || (typeof diff.value !== 'object')) {
    const { prefix, suffix } = fixups(prop, mapping, comma, diff.error)

    const joined = `${prefix}${$red(stringify(diff.value))} ${_tilde} `
    dump(log, diff.expected, joined, suffix, $grn)

  // if "expected" is not an object (can fit on one line) we use it as suffix
  } else if ((diff.expected === null) || (typeof diff.expected !== 'object')) {
    const { prefix, suffix } = fixups(prop, mapping, comma, diff.error)

    const joined = ` ${_tilde} ${$grn(stringify(diff.expected))}${suffix}`
    dump(log, diff.value, prefix, joined, $red)

  // both "value" and "expected" are objects, so, we join them with a ~
  } else {
    // here the error _only_ goes on the last line...
    const { prefix, suffix: suffix1 } = fixups(prop, mapping, false, '')
    const { suffix: suffix2 } = fixups(prop, mapping, comma, diff.error)

    const lastLine = dumpAndContinue(log, diff.expected, prefix, suffix1, $red)
    dump(log, diff.value, `${lastLine} ${_tilde} `, suffix2, $grn)
  }
}

/* ========================================================================== */

function printMissingDiff(
    log: Logger,
    diff: MissingValueDiff,
    prop: string,
    mapping: boolean,
    comma: boolean,
): void {
  const { prefix, suffix } = fixups(prop, mapping, comma, diff.error, $red, 'missing')
  dump(log, diff.missing, prefix, suffix, $red)
}

/* ========================================================================== */

function printExtraDiff(
    log: Logger,
    diff: ExtraValueDiff,
    prop: string,
    mapping: boolean,
    comma: boolean,
): void {
  const { prefix, suffix } = fixups(prop, mapping, comma, diff.error, $red, 'extra')
  dump(log, diff.extra, prefix, suffix, $red)
}

/* ========================================================================== */

function printObjectDiff(
    log: Logger,
    diff: ObjectDiff,
    prop: string,
    mapping: boolean,
    comma: boolean,
): void {
  const { prefix, suffix } = fixups(prop, mapping, comma, diff.error)

  // prepare for deep inspection
  const value = diff.value
  const ctor = Object.getPrototypeOf(value)?.constructor
  const string = (ctor === Object) || (ctor === Array) ? '' : stringifyValue(value)

  // prepare first line of output
  let line = string ? `${prefix}${$wht(string)} ` : prefix
  let marked = false

  // arrays or sets
  if (diff.values) {
    if (diff.values.length === 0) {
      line = `${line}${_squares}`
    } else {
      log.warn(`${line}${_opnSqr}`)
      log.enter()
      try {
        for (const subdiff of diff.values) {
          printBaseDiff(log, subdiff, '', false, true)
        }
      } finally {
        log.leave()
      }
      line = _clsSqr
    }
    marked = true

  // values and mappings (arrays/sets and maps) are mutually exclusive
  } else if (diff.mappings) {
    if (Object.keys(diff.mappings).length === 0) {
      line = `${line}${_curls}`
    } else {
      log.warn(`${line}${_opnCrl}`)
      log.enter()
      try {
        for (const [ key, subdiff ] of diff.mappings) {
          printBaseDiff(log, subdiff, stringifyValue(key), true, true)
        }
      } finally {
        log.leave()
      }
      line = _clsCrl
    }
    marked = true
  }

  // extra properties
  if (diff.props) {
    if (marked) line = `${line} ${_extraProps} `
    if (Object.keys(diff.props).length === 0) {
      line = `${line}${_curls}`
    } else {
      log.warn(`${line}${_opnCrl}`)
      log.enter()
      try {
        for (const [ prop, subdiff ] of Object.entries(diff.props)) {
          printBaseDiff(log, subdiff, stringifyValue(prop), false, true)
        }
      } finally {
        log.leave()
      }
      line = _clsCrl
    }
  }

  log.warn(`${line}${suffix}`)
}

/* ========================================================================== *
 * PRINT HELPERS                                                              *
 * ========================================================================== */

function stringify(
    // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
    value: null | undefined | string | number | boolean | bigint | symbol | Function,
): string {
  if (typeof value === 'string') return JSON.stringify(value)
  return stringifyValue(value)
}

function fixups(
    prop: string,
    mapping: boolean,
    comma: boolean,
    error: string | undefined,
    color?: ((string: string) => string) | undefined,
    label?: string,
): { prefix: string, suffix: string } {
  if (error) color = color || $ylw

  const lbl = label ? `${_opnPar}${$gry($und(label))}${_clsPar} ` : ''
  const sep = mapping ? ' => ': ': '
  const prefix = prop ?
    color ?
      `${$gry(lbl)}${color(prop)}${$gry(sep)}` :
      `${$gry(lbl)}${prop}${$gry(sep)}` :
    label ?
      `${$gry(lbl)}` :
      ''
  error = error ? ` ${_error} ${$ylw(error)}` : ''
  const suffix = `${comma ? $gry(',') : ''}${error}`
  return { prefix, suffix }
}

function dump(
    log: Logger,
    value: any,
    prefix: string,
    suffix: string,
    color: (string: string) => string,
    stack: any[] = [],
): void {
  log.warn(dumpAndContinue(log, value, prefix, suffix, color, stack))
}

function dumpAndContinue(
    log: Logger,
    value: any,
    prefix: string,
    suffix: string,
    color: (string: string) => string,
    stack: any[] = [],
): string {
  // primitives just get dumped
  if ((value === null) || (typeof value !== 'object')) {
    return `${prefix}${color(stringify(value))}${suffix}`
  }

  // matchers are a very special value...
  if (isMatcher(value)) {
    return `${prefix}${_matcher}${suffix}`
  }

  // check for circular dependencies
  const circular = stack.indexOf(value)
  if (circular >= 0) {
    return `${prefix}${$gry($und(`<circular ${circular}>`))}${suffix}`
  }

  // prepare for deep inspection
  const ctor = Object.getPrototypeOf(value)?.constructor
  const string = (ctor === Object) || (ctor === Array) ? '' : stringifyValue(value)
  const keys = new Set(Object.keys(value))

  // prepare first line of output
  let line = string ? `${prefix}${color(string)} ` : prefix
  let marked = false

  // arrays (will remove keys for properties)
  if (Array.isArray(value)) {
    if (value.length === 0) {
      line = `${line}${_squares}`
    } else {
      log.warn(`${line}${_opnSqr}`)
      log.enter()
      try {
        for (let i = 0; i < value.length; i ++) {
          const { prefix, suffix } = fixups('', false, true, undefined, color)
          dump(log, value[i], prefix, suffix, color, [ ...stack, value ])
          keys.delete(String(i))
        }
      } finally {
        log.leave()
      }
      line = _clsSqr
    }
    marked = true

    // arrays, sets and maps are mutually exclusive...
  } else if (value instanceof Set) {
    if (value.size === 0) {
      line = `${line}${_squares}`
    } else {
      log.warn(`${line}${_opnSqr}`)
      log.enter()
      try {
        const { prefix, suffix } = fixups('', false, true, undefined, color)
        value.forEach((v) => dump(log, v, prefix, suffix, color, [ ...stack, value ]))
      } finally {
        log.leave()
      }
      line = _clsSqr
    }
    marked = true

    // arrays, sets and maps are mutually exclusive...
  } else if (value instanceof Map) {
    if (value.size === 0) {
      line = `${line}${_curls}`
    } else {
      log.warn(`${line}${_opnCrl}`)
      log.enter()
      try {
        for (const [ key, subvalue ] of value) {
          const { prefix, suffix } = fixups(stringifyValue(key), true, true, undefined, color)
          dump(log, subvalue, prefix, suffix, color, [ ...stack, value ])
        }
      } finally {
        log.leave()
      }
      line = _clsCrl
    }
    marked = true
  }

  // boxed strings leave props around
  if (value instanceof String) {
    const length = value.valueOf().length
    for (let i = 0; i < length; i ++) keys.delete(String(i))
  }

  // extra properties might appear at any time...
  if (keys.size) {
    if (marked) line = `${line} ${_extraProps} `
    log.warn(`${line}${_opnCrl}`)
    log.enter()
    try {
      for (const key of keys) {
        const { prefix, suffix } = fixups(stringifyValue(key), false, true, undefined, color)
        dump(log, value[key], prefix, suffix, color, [ ...stack, value ])
      }
    } finally {
      log.leave()
    }
    line = _clsCrl
    marked = true
  }

  if (marked) {
    return `${line}${suffix}`
  } else {
    return `${line}${_curls}${suffix}`
  }
}

/* ========================================================================== *
 * EXPORTSD                                                                   *
 * ========================================================================== */

/** Print a {@link Diff} to a log, with a nice header by default... */
export function printDiff(log: Logger, diff: Diff, header = true): void {
  if (! header) return printBaseDiff(log, diff, '', false, false)

  log.warn(_diffHeader)
  log.enter()
  try {
    printBaseDiff(log, diff, '', false, false)
  } finally {
    log.leave()
  }
}
