import { fail } from 'node:assert'
import { inspect, isDeepStrictEqual } from 'node:util'

import { assert } from '../asserts'
import { $grn, $red, logOptions } from '../logging'

import type { InspectOptions } from 'node:util'

/* ========================================================================== *
 * EXPORTED INTERFACES                                                        *
 * ========================================================================== */

/** Identifies a single change */
export interface Change {
  /** The position in the Left-Hand side where items were deleted */
  lhsPos: number;
  /** The number of items deleted from the Left-Hand side */
  lhsDel: number;
  /** The position in the Right-Hand side where items were added */
  rhsPos: number;
  /** The number of items added to the Right-Hand side */
  rhsAdd: number;
}

/* ========================================================================== *
 * MYERS IMPLEMENTATION                                                       *
 * Lifted from https://github.com/wickedest/myers-diff/ (Apache 2.0)          *
 * ========================================================================== */

function compareLongestCommonSubsequence(lhsCtx: Context, rhsCtx: Context): Change[] {
  let lhsStart = 0
  let rhsStart = 0
  let lhsItem = 0
  let rhsItem = 0

  const changes: Change[] = []

  while (lhsItem < lhsCtx.length || rhsItem < rhsCtx.length) {
    if (
      (lhsItem < lhsCtx.length) && (!lhsCtx.modified[lhsItem]) &&
      (rhsItem < rhsCtx.length) && (!rhsCtx.modified[rhsItem])
    ) {
      // equal lines
      lhsItem++
      rhsItem++
      continue
    }

    // maybe deleted and/or inserted lines
    lhsStart = lhsItem
    rhsStart = rhsItem

    while ((lhsItem < lhsCtx.length) &&
      (rhsItem >= rhsCtx.length || lhsCtx.modified[lhsItem])) {
      lhsItem++
    }

    while ((rhsItem < rhsCtx.length) &&
      (lhsItem >= lhsCtx.length || rhsCtx.modified[rhsItem])) {
      rhsItem++
    }

    if ((lhsStart < lhsItem) || (rhsStart < rhsItem)) {
      const lat = Math.min(lhsStart, (lhsCtx.length) ? lhsCtx.length - 1 : 0)
      const rat = Math.min(rhsStart, (rhsCtx.length) ? rhsCtx.length - 1 : 0)

      changes.push({
        lhsPos: lat,
        lhsDel: lhsItem - lhsStart,
        rhsPos: rat,
        rhsAdd: rhsItem - rhsStart,
      })
    }
  }

  return changes
}

function getShortestMiddleSnake(
    lhsCtx: Context, lhsLower: number, lhsUpper: number,
    rhsCtx: Context, rhsLower: number, rhsUpper: number,
    vectorU: number[], vectorD: number[],
): { x: number, y: number } {
  const max = lhsCtx.length + rhsCtx.length + 1

  const kdown = lhsLower - rhsLower
  const kup = lhsUpper - rhsUpper
  const delta = (lhsUpper - lhsLower) - (rhsUpper - rhsLower)
  const odd = (delta & 1) != 0
  const offsetDown = max - kdown
  const offsetUp = max - kup
  const maxd = ((lhsUpper - lhsLower + rhsUpper - rhsLower) / 2) + 1
  const ret = { x: 0, y: 0 }
  let d: number
  let k: number
  let x: number
  let y: number

  vectorD[offsetDown + kdown + 1] = lhsLower
  vectorU[offsetUp + kup - 1] = lhsUpper
  for (d = 0; d <= maxd; ++d) {
    for (k = kdown - d; k <= kdown + d; k += 2) {
      if (k === kdown - d) {
        x = vectorD[offsetDown + k + 1]! // down
      } else {
        x = vectorD[offsetDown + k - 1]! + 1 // right
        if ((k < (kdown + d)) && (vectorD[offsetDown + k + 1]! >= x)) {
          x = vectorD[offsetDown + k + 1]! // down
        }
      }
      y = x - k
      // find the end of the furthest reaching forward D-path in diagonal k.
      while ((x < lhsUpper) &&
        (y < rhsUpper) &&
        (lhsCtx.codes[x] === rhsCtx.codes[y])
      ) {
        x++; y++
      }
      vectorD[offsetDown + k]! = x
      // overlap ?
      if (odd && (kup - d < k) && (k < kup + d)) {
        if (vectorU[offsetUp + k]! <= vectorD[offsetDown + k]!) {
          ret.x = vectorD[offsetDown + k]!
          ret.y = vectorD[offsetDown + k]! - k
          return (ret)
        }
      }
    }
    // Extend the reverse path.
    for (k = kup - d; k <= kup + d; k += 2) {
      // find the only or better starting point
      if (k === kup + d) {
        x = vectorU[offsetUp + k - 1]! // up
      } else {
        x = vectorU[offsetUp + k + 1]! - 1 // left
        if ((k > kup - d) && (vectorU[offsetUp + k - 1]! < x)) {
          x = vectorU[offsetUp + k - 1]!
        } // up
      }
      y = x - k
      while ((x > lhsLower) &&
        (y > rhsLower) &&
        (lhsCtx.codes[x - 1] === rhsCtx.codes[y - 1])
      ) {
        // diagonal
        x--
        y--
      }
      vectorU[offsetUp + k] = x
      // overlap ?
      if (!odd && (kdown - d <= k) && (k <= kdown + d)) {
        if (vectorU[offsetUp + k]! <= vectorD[offsetDown + k]!) {
          ret.x = vectorD[offsetDown + k]!
          ret.y = vectorD[offsetDown + k]! - k
          return (ret)
        }
      }
    }
  }

  // coverage ignore next // we should never get here
  fail('Unexpected state computing diff')
}

function getLongestCommonSubsequence(
    lhsCtx: Context, lhsLower: number, lhsUpper: number,
    rhsCtx: Context, rhsLower: number, rhsUpper: number,
    vectorU = [], vectorD = [],
): void {
  // trim off the matching items at the beginning
  while ( (lhsLower < lhsUpper) &&
    (rhsLower < rhsUpper) &&
    (lhsCtx.codes[lhsLower] === rhsCtx.codes[rhsLower]) ) {
    ++lhsLower
    ++rhsLower
  }
  // trim off the matching items at the end
  while ( (lhsLower < lhsUpper) &&
    (rhsLower < rhsUpper) &&
    (lhsCtx.codes[lhsUpper - 1] === rhsCtx.codes[rhsUpper - 1]) ) {
    --lhsUpper
    --rhsUpper
  }
  if (lhsLower === lhsUpper) {
    while (rhsLower < rhsUpper) {
      rhsCtx.modified[rhsLower++] = true
    }
  } else if (rhsLower === rhsUpper) {
    while (lhsLower < lhsUpper) {
      lhsCtx.modified[lhsLower++] = true
    }
  } else {
    const { x, y } = getShortestMiddleSnake(
        lhsCtx, lhsLower, lhsUpper,
        rhsCtx, rhsLower, rhsUpper,
        vectorU, vectorD)
    getLongestCommonSubsequence(
        lhsCtx, lhsLower, x,
        rhsCtx, rhsLower, y,
        vectorU, vectorD)
    getLongestCommonSubsequence(
        lhsCtx, x, lhsUpper,
        rhsCtx, y, rhsUpper,
        vectorU, vectorD)
  }
}

/* ========================================================================== *
 * INTERNAL CLASSES                                                           *
 * ========================================================================== */

/** The context to use while executing the diff */
class Context {
  /** Keep a tab on modified items */
  readonly modified: (true | undefined)[]
  /** A _code table_ for all the items in this context */
  readonly codes: readonly number[]
  /** The number of item held by this context */
  readonly length: number

  /** Construct with a _code table_ */
  constructor(codes: number[]) {
    const length = this.length = codes.length
    this.modified = new Array(length)
    this.codes = codes
  }
}

/** A codec producing _code tables_ */
class Coder<T, I extends Iterable<T> = Iterable<T>> {
  private _primitives = new Map<T, number>()
  private _objects: [ T, number ][] = []
  private _index = 1

  private _getObjectCode(item: T): number {
    for (const [ object, code ] of this._objects) {
      if (isDeepStrictEqual(item, object)) return code
    }

    const code = ++ this._index
    this._objects.push([ item, code ])
    return code
  }

  private _getPrimitiveCode(item: T): number {
    let code = this._primitives.get(item)
    if (code) return code

    code = ++ this._index
    this._primitives.set(item, code)
    return code
  }

  /** Get the code table for an {@link Iterable} */
  getCodes(iterable: I): number[] {
    const codes: number[] = []
    for (const item of iterable) {
      const type = item === null ? 'null' : typeof item

      const code = type === 'object' ?
        this._getObjectCode(item) :
        this._getPrimitiveCode(item)

      codes.push(code)
    }

    return codes
  }
}

/** Shared constant for inspect options */
const inspectOptions: InspectOptions = {
  showHidden: false,
  depth: 10,
  colors: false,
  maxArrayLength: 100,
  maxStringLength: 250,
  breakLength: Infinity,
  compact: false,
  sorted: true,
  getters: true,
}

/* ========================================================================== *
 * EXPORTED FUNCTIONS                                                         *
 * ========================================================================== */

/**
 * Compare the _Left-Hand side_ {@link Iterable} to _Right-Hand side_ one,
 * producing an array of {@link Change | Changes} identifying the differences.
 */
export function diff<T, I extends Iterable<T> = Iterable<T>>(
    lhs: I,
    rhs: I,
): Change[] {
  assert(lhs !== undefined, 'Left-Hand side undefined')
  assert(rhs !== undefined, 'Right-Hand side undefined')

  const codec = new Coder<T>()
  const lhsCtx = new Context(codec.getCodes(lhs))
  const rhsCtx = new Context(codec.getCodes(rhs))

  getLongestCommonSubsequence(
      lhsCtx, 0, lhsCtx.length,
      rhsCtx, 0, rhsCtx.length,
  )

  return compareLongestCommonSubsequence(lhsCtx, rhsCtx)
}

/** Produce a textual diff between two values. */
export function textDiff(
    lhs: any,
    rhs: any,
    add?: (s: string) => string,
    del?: (s: string) => string,
    not?: (s: string) => string,
): string {
  // Defaults for our "add" "del" and "not" functions (depending on colorization)
  const _add = add || (logOptions.colors ? $grn : (s: string): string => `+ ${s}`)
  const _del = del || (logOptions.colors ? $red : (s: string): string => `- ${s}`)
  const _not = not || (logOptions.colors ? (s: string): string => s : (s: string): string => `  ${s}`)

  // We'll need two array of string lines to compare...
  let lhsLines: string[]
  let rhsLines: string[]

  // If _both_ arguments are strings, then just split and compare, otherwise
  // we nuse NodeJS' inspect to prep their string version
  if ((typeof lhs === 'string') && (typeof rhs === 'string')) {
    lhsLines = lhs.split('\n')
    rhsLines = rhs.split('\n')
  } else {
    lhsLines = inspect(lhs, inspectOptions).split('\n')
    rhsLines = inspect(rhs, inspectOptions).split('\n')
  }

  // Calculate the difference between the two arrays of strings
  const changes = diff(lhsLines, rhsLines)
  if (changes.length === 0) return ''

  // Go through changes and highlight
  let offset = 0
  const result: string[] = []
  changes.forEach(({ lhsPos, lhsDel, rhsPos, rhsAdd }) => {
    if (offset != lhsPos) result.push(...lhsLines.slice(offset, lhsPos).map(_not))
    if (lhsDel) result.push(...lhsLines.slice(lhsPos, lhsPos + lhsDel).map(_del))
    if (rhsAdd) result.push(...rhsLines.slice(rhsPos, rhsPos + rhsAdd).map(_add))
    offset = lhsPos + lhsDel
  })
  if (offset < lhsLines.length) result.push(...lhsLines.slice(offset).map(_not))

  // Join our results and return
  return result.join('\n')
}
