// TODO infinite updates detection

// A data structure that stores a deferred DOM operation.
// the first element is the function to call, and the rest of the array
// stores up to 8 arguments.
type Op = [Function, ...any[]]

// A "job" stands for a unit of work that needs to be performed.
// Typically, one job corresponds to the mounting or updating of one component
// instance (including functional ones).
interface Job<T extends Function = () => void> {
  // A job is itself a function that performs work. It can contain work such as
  // calling render functions, running the diff algorithm (patch), mounting new
  // vnodes, and tearing down old vnodes. However, these work needs to be
  // performed in several different phases, most importantly to separate
  // workloads that do not produce side-effects ("stage") vs. those that do
  // ("commit").
  // During the stage call it should not perform any direct sife-effects.
  // Instead, it buffers them. All side effects from multiple jobs queued in the
  // same tick are flushed together during the "commit" phase. This allows us to
  // perform side-effect-free work over multiple frames (yielding to the browser
  // in-between to keep the app responsive), and only flush all the side effects
  // together when all work is done (AKA time-slicing).
  (): T
  // A job's status changes over the different update phaes. See comments for
  // phases below.
  status: JobStatus
  // Any operations performed by the job that directly mutates the DOM are
  // buffered inside the job's ops queue, and only flushed in the commit phase.
  // These ops are queued by calling `queueNodeOp` inside the job function.
  ops: Op[]
  // Any post DOM mutation side-effects (updated / mounted hooks, refs) are
  // buffered inside the job's effects queue.
  // Effects are queued by calling `queuePostEffect` inside the job function.
  postEffects: Function[]
  // A job may queue other jobs (e.g. a parent component update triggers the
  // update of a child component). Jobs queued by another job is kept in the
  // parent's children array, so that in case the parent job is invalidated,
  // all its children can be invalidated as well (recursively).
  children: Job[]
  // Sometimes it's inevitable for a stage fn to produce some side effects
  // (e.g. a component instance sets up an ReactiveEffect). In those cases the stage fn
  // can return a cleanup function which will be called when the job is
  // invalidated.
  cleanup: T | null
  // The expiration time is a timestamp past which the job needs to
  // be force-committed regardless of frame budget.
  // Why do we need an expiration time? Because a job may get invalidated before
  // it is fully commited. If it keeps getting invalidated, we may "starve" the
  // system and never apply any commits as jobs keep getting invalidated. The
  // expiration time sets a limit on how long before a job can keep getting
  // invalidated before it must be comitted.
  expiration: number
}

const enum JobStatus {
  IDLE = 0,
  PENDING_STAGE,
  PENDING_COMMIT
}

// Priorities for different types of jobs. This number is added to the
// current time when a new job is queued to calculate the expiration time
// for that job.
//
// Currently we have only one type which expires 500ms after it is initially
// queued. There could be higher/lower priorities in the future.
const enum JobPriorities {
  NORMAL = 500
}

// There can be only one job being patched at one time. This allows us to
// automatically "capture" and buffer the node ops and post effects queued
// during a job.
let currentJob: Job | null = null

// Indicates we have a flush pending.
let hasPendingFlush = false

// A timestamp that indicates when a flush was started.
let flushStartTimestamp: number = 0

// The frame budget is the maximum amount of time passed while performing
// "stage" work before we need to yield back to the browser.
// Aiming for 60fps. Maybe we need to dynamically adjust this?
const frameBudget = __JSDOM__ ? Infinity : 1000 / 60

const getNow = () => performance.now()

// An entire update consists of 4 phases:

// 1. Stage phase. Render functions are called, diffs are performed, new
//    component instances are created. However, no side-effects should be
//    performed (i.e. no lifecycle hooks, no direct DOM operations).
const stageQueue: Job[] = []

// 2. Commit phase. This is only reached when the stageQueue has been depleted.
//    Node ops are applied - in the browser, this means DOM is actually mutated
//    during this phase. If a job is committed, it's post effects are then
//    queued for the next phase.
const commitQueue: Job[] = []

// 3. Post-commit effects phase. Effect callbacks are only queued after a
//    successful commit. These include callbacks that need to be invoked
//    after DOM mutation - i.e. refs, mounted & updated hooks. This queue is
//    flushed in reverse because child component effects are queued after but
//    should be invoked before the parent's.
const postEffectsQueue: Function[] = []

// 4. NextTick phase. This is the user's catch-all mechanism for deferring
//    work after a complete update cycle.
const nextTickQueue: Function[] = []
const pendingRejectors: ErrorHandler[] = []

// Error handling --------------------------------------------------------------

type ErrorHandler = (err: Error) => any

let globalHandler: ErrorHandler

export function handleSchedulerError(handler: ErrorHandler) {
  globalHandler = handler
}

function handleError(err: Error) {
  if (globalHandler) globalHandler(err)
  pendingRejectors.forEach(handler => {
    handler(err)
  })
}

// Microtask defer -------------------------------------------------------------
// For batching state mutations before we start an update. This does
// NOT yield to the browser.

const p = Promise.resolve()

function flushAfterMicroTask() {
  flushStartTimestamp = getNow()
  return p.then(flush).catch(handleError)
}

// Macrotask defer -------------------------------------------------------------
// For time slicing. This uses the window postMessage event to "yield"
// to the browser so that other user events can trigger in between. This keeps
// the app responsive even when performing large amount of JavaScript work.

const key = `$vueTick`

window.addEventListener(
  'message',
  event => {
    if (event.source !== window || event.data !== key) {
      return
    }
    flushStartTimestamp = getNow()
    try {
      flush()
    } catch (e) {
      handleError(e)
    }
  },
  false
)

function flushAfterMacroTask() {
  window.postMessage(key, `*`)
}

// API -------------------------------------------------------------------------

// This is the main API of the scheduler. The raw job can actually be any
// function, but since they are invalidated by identity, it is important that
// a component's update job is a consistent function across its lifecycle -
// in the renderer, it's actually instance._update which is in turn
// an ReactiveEffect function.
export function queueJob(rawJob: Function) {
  const job = rawJob as Job
  if (currentJob) {
    currentJob.children.push(job)
  }
  // Let's see if this invalidates any work that
  // has already been staged.
  if (job.status === JobStatus.PENDING_COMMIT) {
    // staged job invalidated
    invalidateJob(job)
    // re-insert it into the stage queue
    requeueInvalidatedJob(job)
  } else if (job.status !== JobStatus.PENDING_STAGE) {
    // a new job
    queueJobForStaging(job)
  }
  if (!hasPendingFlush) {
    hasPendingFlush = true
    flushAfterMicroTask()
  }
}

export function queuePostEffect(fn: Function) {
  if (currentJob) {
    currentJob.postEffects.push(fn)
  } else {
    postEffectsQueue.push(fn)
  }
}

export function flushEffects() {
  // post commit hooks (updated, mounted)
  // this queue is flushed in reverse becuase these hooks should be invoked
  // child first
  let i = postEffectsQueue.length
  while (i--) {
    postEffectsQueue[i]()
  }
  postEffectsQueue.length = 0
}

export function queueNodeOp(op: Op) {
  if (currentJob) {
    currentJob.ops.push(op)
  } else {
    applyOp(op)
  }
}

// The original nextTick now needs to be reworked so that the callback only
// triggers after the next commit, when all node ops and post effects have been
// completed.
export function nextTick<T>(fn?: () => T): Promise<T> {
  return new Promise((resolve, reject) => {
    p.then(() => {
      if (hasPendingFlush) {
        nextTickQueue.push(() => {
          resolve(fn ? fn() : undefined)
        })
        pendingRejectors.push(err => {
          if (fn) fn()
          reject(err)
        })
      } else {
        resolve(fn ? fn() : undefined)
      }
    }).catch(reject)
  })
}

// Internals -------------------------------------------------------------------

function flush(): void {
  let job
  while (true) {
    job = stageQueue.shift()
    if (job) {
      stageJob(job)
    } else {
      break
    }
    if (!__COMPAT__) {
      const now = getNow()
      if (now - flushStartTimestamp > frameBudget && job.expiration > now) {
        break
      }
    }
  }

  if (stageQueue.length === 0) {
    // all done, time to commit!
    for (let i = 0; i < commitQueue.length; i++) {
      commitJob(commitQueue[i])
    }
    commitQueue.length = 0
    flushEffects()
    // some post commit hook triggered more updates...
    if (stageQueue.length > 0) {
      if (!__COMPAT__ && getNow() - flushStartTimestamp > frameBudget) {
        return flushAfterMacroTask()
      } else {
        // not out of budget yet, flush sync
        return flush()
      }
    }
    // now we are really done
    hasPendingFlush = false
    pendingRejectors.length = 0
    for (let i = 0; i < nextTickQueue.length; i++) {
      nextTickQueue[i]()
    }
    nextTickQueue.length = 0
  } else {
    // got more job to do
    // shouldn't reach here in compat mode, because the stageQueue is
    // guarunteed to have been depleted
    flushAfterMacroTask()
  }
}

function resetJob(job: Job) {
  job.ops.length = 0
  job.postEffects.length = 0
  job.children.length = 0
}

function queueJobForStaging(job: Job) {
  job.ops = job.ops || []
  job.postEffects = job.postEffects || []
  job.children = job.children || []
  resetJob(job)
  // inherit parent job's expiration deadline
  job.expiration = currentJob
    ? currentJob.expiration
    : getNow() + JobPriorities.NORMAL
  stageQueue.push(job)
  job.status = JobStatus.PENDING_STAGE
}

function invalidateJob(job: Job) {
  // recursively invalidate all child jobs
  const { children } = job
  for (let i = 0; i < children.length; i++) {
    const child = children[i]
    if (child.status === JobStatus.PENDING_COMMIT) {
      invalidateJob(child)
    } else if (child.status === JobStatus.PENDING_STAGE) {
      stageQueue.splice(stageQueue.indexOf(child), 1)
      child.status = JobStatus.IDLE
    }
  }
  if (job.cleanup) {
    job.cleanup()
    job.cleanup = null
  }
  resetJob(job)
  // remove from commit queue
  commitQueue.splice(commitQueue.indexOf(job), 1)
  job.status = JobStatus.IDLE
}

function requeueInvalidatedJob(job: Job) {
  // With varying priorities we should insert job at correct position
  // based on expiration time.
  for (let i = 0; i < stageQueue.length; i++) {
    if (job.expiration < stageQueue[i].expiration) {
      stageQueue.splice(i, 0, job)
      job.status = JobStatus.PENDING_STAGE
      return
    }
  }
  stageQueue.push(job)
  job.status = JobStatus.PENDING_STAGE
}

function stageJob(job: Job) {
  // job with existing ops means it's already been patched in a low priority queue
  if (job.ops.length === 0) {
    currentJob = job
    job.cleanup = job()
    currentJob = null
    commitQueue.push(job)
    job.status = JobStatus.PENDING_COMMIT
  }
}

function commitJob(job: Job) {
  const { ops, postEffects } = job
  for (let i = 0; i < ops.length; i++) {
    applyOp(ops[i])
  }
  // queue post commit cbs
  if (postEffects) {
    postEffectsQueue.push(...postEffects)
  }
  resetJob(job)
  job.status = JobStatus.IDLE
}

function applyOp(op: Op) {
  const fn = op[0]
  // optimize for more common cases
  // only patchData needs 8 arguments
  if (op.length <= 3) {
    fn(op[1], op[2], op[3])
  } else {
    fn(op[1], op[2], op[3], op[4], op[5], op[6], op[7], op[8])
  }
}
