import {type Doc} from '../document/types'
import {Matcher} from '../jsonpath'
import {ImmutableAccessor} from './ImmutableAccessor'
import {parsePatch} from './parse'
import {SetIfMissingPatch} from './SetIfMissingPatch'
import {SetPatch} from './SetPatch'
import {type PatchTypes, type SingleDocumentPatch} from './types'

export interface Patch {
  id: string
  path: string
}

export class Patcher {
  patches: PatchTypes[]

  constructor(patch: SingleDocumentPatch | SingleDocumentPatch[]) {
    this.patches = parsePatch(patch)
  }

  apply(value: Doc | null): unknown {
    // Apply just makes a root accessor around the provided
    // value, then applies the patches. Due to the use of
    // ImmutableAccessor it is guaranteed to return either the
    // exact same object it was provided (in the case of no changes),
    // or a completely new object. It will never mutate the object in place.
    const accessor = new ImmutableAccessor(value)
    return this.applyViaAccessor(accessor).get()
  }

  // If you want to use your own accessor implementation, you can use this method
  // to invoke the patcher. Since all subsequent accessors for children of this accessor
  // are obtained through the methods in the accessors, you retain full control of the
  // implementation throguhgout the application. Have a look in ImmutableAccessor
  // to see an example of how accessors are implemented.
  applyViaAccessor(accessor: ImmutableAccessor): ImmutableAccessor {
    let result = accessor
    const idAccessor = accessor.getAttribute('_id')
    if (!idAccessor) {
      throw new Error('Cannot apply patch to document with no _id')
    }

    const id = idAccessor.get()
    for (const patch of this.patches) {
      if (patch.id !== id) {
        // Ignore patches that are not targetted at this document
        continue
      }

      const matcher = Matcher.fromPath(patch.path).setPayload(patch)
      result = process(matcher, result)
    }

    return result
  }
}

// Recursively (depth first) follows any leads generated by the matcher, expecting
// a patch to be the payload. When matchers report a delivery, the
// apply(targets, accessor) is called on the patch
function process(matcher: Matcher, accessor: ImmutableAccessor) {
  const isSetPatch =
    matcher.payload instanceof SetPatch || matcher.payload instanceof SetIfMissingPatch

  let result = accessor
  // Every time we execute the matcher a new set of leads is generated. Each lead
  // is a target (being an index, an attribute name or a range) in the form of an
  // Expression instance. For each lead target there is also a matcher. Our job is to obtain
  // accessor(s) for each target (there might be more than one, since the targets may
  // be ranges) and run the provided matcher on those accessors.
  const {leads, delivery} = matcher.match(accessor)
  leads.forEach((lead) => {
    if (lead.target.isIndexReference()) {
      lead.target.toIndicies().forEach((i) => {
        const item = result.getIndex(i)
        if (!item) {
          throw new Error('Index out of bounds')
        }

        result = result.setIndexAccessor(i, process(lead.matcher, item))
      })
    } else if (lead.target.isAttributeReference()) {
      // `set`/`setIfMissing` on a primitive value overwrites it
      if (isSetPatch && result.containerType() === 'primitive') {
        result = result.set({})
      }

      let oldValueAccessor = result.getAttribute(lead.target.name())

      // If the patch is a set/setIfMissing patch, we allow deeply setting properties,
      // creating missing segments as we go.
      if (!oldValueAccessor && isSetPatch) {
        result = result.setAttribute(lead.target.name(), {})
        oldValueAccessor = result.getAttribute(lead.target.name())
      }

      if (!oldValueAccessor) {
        // Don't follow lead, no such attribute
        return
      }

      const newValueAccessor = process(lead.matcher, oldValueAccessor)
      if (oldValueAccessor !== newValueAccessor) {
        result = result.setAttributeAccessor(lead.target.name(), newValueAccessor)
      }
    } else {
      throw new Error(`Unable to handle target ${lead.target.toString()}`)
    }
  })

  // Each time we run the matcher, we might also get a delivery. This means that a
  // term in the jsonpath terminated here and the patch should be applied. The delivery
  // arrives in the form of an array of targets and a payload (which in this application
  // is the patch). Conveniently the patches accept an array of targets and an accessor
  // to do its work, so here we just pass those to the patch and we're done.
  if (delivery && isPatcher(delivery.payload)) {
    const patch = delivery.payload
    result = patch.apply(delivery.targets, result)
  }

  return result
}

function isPatcher(payload: unknown): payload is PatchTypes {
  return Boolean(
    payload &&
      typeof payload === 'object' &&
      payload !== null &&
      'apply' in payload &&
      typeof (payload as PatchTypes).apply === 'function',
  )
}
