import {
  ActionChain,
  createStore,
  Dispatchable,
  zeduxTypes,
  is,
  Observable,
  RecursivePartial,
  Settable,
  Store,
  Subscription,
} from '@zedux/core'
import {
  AtomGenerics,
  AtomGenericsToAtomApiGenerics,
  Cleanup,
  DependentEdge,
  EvaluationReason,
  EvaluationSourceType,
  ExportsInfusedSetter,
  LifecycleStatus,
  PromiseState,
  PromiseStatus,
} from '@zedux/atoms/types/index'
import { pluginActions } from '@zedux/atoms/utils/plugin-actions'
import {
  getErrorPromiseState,
  getInitialPromiseState,
  getSuccessPromiseState,
} from '@zedux/atoms/utils/promiseUtils'
import { InjectorDescriptor } from '@zedux/atoms/utils/types'
import { Ecosystem } from '../Ecosystem'
import { AtomApi } from '../AtomApi'
import { AtomInstanceBase } from './AtomInstanceBase'
import { AtomTemplateBase } from '../templates/AtomTemplateBase'

const StoreState = 1
const RawState = 2

const getStateType = (val: any) => {
  if (is(val, Store)) return StoreState

  return RawState
}

const getStateStore = <
  State = any,
  StoreType extends Store<State> = Store<State>,
  P extends State | StoreType = State | StoreType
>(
  factoryResult: P
) => {
  const stateType = getStateType(factoryResult)

  const stateStore =
    stateType === StoreState
      ? (factoryResult as unknown as StoreType)
      : (createStore<State>() as StoreType)

  // define how we populate our store (doesn't apply to user-supplied stores)
  if (stateType === RawState) {
    stateStore.setState(
      typeof factoryResult === 'function'
        ? () => factoryResult as State
        : (factoryResult as unknown as State)
    )
  }

  return [stateType, stateStore] as const
}

export class AtomInstance<
  G extends AtomGenerics,
  AtomTemplateType extends AtomTemplateBase<
    G,
    AtomInstance<G, AtomTemplateType>
  > = AtomTemplateBase<G, AtomInstance<G, any>>
> extends AtomInstanceBase<G['State'], AtomTemplateType> {
  public status: LifecycleStatus = 'Initializing'
  public api?: AtomApi<AtomGenericsToAtomApiGenerics<G>>
  public exports: G['Exports']
  public nextReasons: EvaluationReason[] = []
  public prevReasons?: EvaluationReason[]
  public promise: G['Promise']
  public store: G['Store']

  public _cancelDestruction?: Cleanup
  public _createdAt: number
  public _injectors?: InjectorDescriptor[]
  public _isEvaluating?: boolean
  public _lastEdge?: WeakRef<DependentEdge>
  public _nextInjectors?: InjectorDescriptor[]
  public _promiseError?: Error
  public _promiseStatus?: PromiseStatus
  public _stateType?: typeof StoreState | typeof RawState

  private _bufferedUpdate?: {
    newState: G['State']
    oldState?: G['State']
    action: ActionChain
  }
  private _subscription?: Subscription

  constructor(
    public readonly ecosystem: Ecosystem,
    public readonly template: AtomTemplateType,
    public readonly id: string,
    public readonly params: G['Params']
  ) {
    super()
    this._createdAt = ecosystem._idGenerator.now()

    // lol
    this.exports = (this as any).exports
    this.promise = (this as any).promise
    this.store = (this as any).store
    this._promiseStatus = (this as any)._promiseStatus
  }

  /**
   * Detach this atom instance from the ecosystem and clean up all graph edges
   * and other subscriptions/effects created by this atom instance.
   *
   * Destruction will bail out if this atom instance still has dependents. Pass
   * `true` to force-destroy the atom instance anyway.
   */
  public destroy(force?: boolean) {
    if (this.status === 'Destroyed') return

    // If we're not force-destroying, don't destroy if there are dependents
    if (!force && this.ecosystem._graph.nodes[this.id]?.refCount) {
      return
    }

    this._cancelDestruction?.()
    this._cancelDestruction = undefined

    this._setStatus('Destroyed')

    if (this.nextReasons.length) {
      this.ecosystem._scheduler.unschedule(this.evaluationTask)
    }

    // Clean up effect injectors first, then everything else
    const nonEffectInjectors: InjectorDescriptor[] = []
    this._injectors?.forEach(injector => {
      if (injector.type !== '@@zedux/effect') {
        nonEffectInjectors.push(injector)
        return
      }
      injector.cleanup?.()
    })
    nonEffectInjectors.forEach(injector => {
      injector.cleanup?.()
    })

    this.ecosystem._graph.removeDependencies(this.id)
    this._subscription?.unsubscribe()
    this.ecosystem._destroyAtomInstance(this.id)
  }

  /**
   * An alias for `.store.dispatch()`
   */
  public dispatch = (action: Dispatchable) => this.store.dispatch(action)

  /**
   * An alias for `instance.store.getState()`. Returns the current state of this
   * atom instance's store.
   */
  public getState(): G['State'] {
    return this.store.getState()
  }

  /**
   * Force this atom instance to reevaluate.
   */
  public invalidate(
    operation = 'invalidate',
    sourceType: EvaluationSourceType = 'External'
  ) {
    this._scheduleEvaluation(
      {
        operation,
        sourceType,
        type: 'cache invalidated',
      },
      false
    )

    // run the scheduler synchronously after invalidation
    this.ecosystem._scheduler.flush()
  }

  /**
   * An alias for `.store.setState()`
   */
  public setState = (settable: Settable<G['State']>, meta?: any): G['State'] =>
    this.store.setState(settable, meta)

  /**
   * An alias for `.store.setStateDeep()`
   */
  public setStateDeep = (
    settable: Settable<RecursivePartial<G['State']>, G['State']>,
    meta?: any
  ): G['State'] => this.store.setStateDeep(settable, meta)

  public _set?: ExportsInfusedSetter<G['State'], G['Exports']>
  public get _infusedSetter() {
    if (this._set) return this._set
    const setState: any = (settable: any, meta?: any) =>
      this.setState(settable, meta)

    return (this._set = Object.assign(setState, this.exports))
  }

  public _init() {
    const factoryResult = this._doEvaluate()

    ;[this._stateType, this.store] = getStateStore(factoryResult)

    this._subscription = this.store.subscribe((newState, oldState, action) => {
      // buffer updates (with cache size of 1) if this instance is currently
      // evaluating
      if (this._isEvaluating) {
        this._bufferedUpdate = { newState, oldState, action }
        return
      }

      this._handleStateChange(newState, oldState, action)
    })

    this._setStatus('Active')
    this.ecosystem._graph.flushUpdates()

    // hydrate if possible
    const hydration = this.ecosystem._consumeHydration(this)

    if (this.template.manualHydration || typeof hydration === 'undefined') {
      return
    }

    this.store.setState(hydration)
  }

  /**
   * When a standard atom instance's refCount hits 0 and a ttl is set, we set a
   * timeout to destroy this atom instance.
   */
  public _scheduleDestruction() {
    // the atom is already scheduled for destruction or destroyed
    if (this.status !== 'Active') return

    this._setStatus('Stale')

    const ttl = this._getTtl()
    if (ttl == null || ttl === -1) return
    if (ttl === 0) return this.destroy()

    if (typeof ttl === 'number') {
      // ttl is > 0; schedule destruction
      const timeoutId = setTimeout(() => {
        this._cancelDestruction = undefined
        this.destroy()
      }, ttl)

      // TODO: dispatch an action over stateStore for these mutations
      this._cancelDestruction = () => {
        this._setStatus('Active')
        this._cancelDestruction = undefined
        clearTimeout(timeoutId)
      }

      return
    }

    if (typeof (ttl as Promise<any>).then === 'function') {
      let isCanceled = false
      Promise.allSettled([ttl as Promise<any>]).then(() => {
        this._cancelDestruction = undefined
        if (!isCanceled) this.destroy()
      })

      this._cancelDestruction = () => {
        this._setStatus('Active')
        this._cancelDestruction = undefined
        isCanceled = true
      }

      return
    }

    // ttl is an observable; destroy as soon as it emits
    const subscription = (ttl as Observable).subscribe(() => {
      this._cancelDestruction = undefined
      this.destroy()
    })

    this._cancelDestruction = () => {
      this._setStatus('Active')
      this._cancelDestruction = undefined
      subscription.unsubscribe()
    }
  }

  public _scheduleEvaluation = (
    reason: EvaluationReason,
    shouldSetTimeout?: boolean
  ) => {
    // TODO: Any calls in this case probably indicate a memory leak on the
    // user's part. Notify them. TODO: Can we pause evaluations while
    // status is Stale (and should we just always evaluate once when
    // waking up a stale atom)?
    if (this.status === 'Destroyed') return

    this.nextReasons.push(reason)

    if (this.nextReasons.length > 1) return // job already scheduled

    this.ecosystem._scheduler.schedule(
      {
        id: this.id,
        task: this.evaluationTask,
        type: 2, // EvaluateGraphNode (2)
      },
      shouldSetTimeout
    )
  }

  private evaluationTask = () => this._evaluationTask()

  private _doEvaluate(): G['Store'] | G['State'] {
    const { _evaluationStack, _graph } = this.ecosystem

    this._nextInjectors = []
    let newFactoryResult: G['Store'] | G['State']
    _evaluationStack.start(this)
    this._isEvaluating = true
    _graph.bufferUpdates(this.id)

    try {
      newFactoryResult = this._evaluate()
    } catch (err) {
      this._nextInjectors.forEach(injector => {
        injector.cleanup?.()
      })

      this._nextInjectors = undefined
      _graph.destroyBuffer()

      throw err
    } finally {
      _evaluationStack.finish()
      this._isEvaluating = false

      // even if evaluation errored, we need to update dependents if the store's
      // state changed
      if (this._bufferedUpdate) {
        this._handleStateChange(
          this._bufferedUpdate.newState,
          this._bufferedUpdate.oldState,
          this._bufferedUpdate.action
        )
        this._bufferedUpdate = undefined
      }

      this.prevReasons = this.nextReasons
      this.nextReasons = []
    }

    this._injectors = this._nextInjectors
    this._nextInjectors = undefined

    if (this.status !== 'Initializing') {
      // let this._init flush updates after status is set to Active
      _graph.flushUpdates()
    }

    return newFactoryResult
  }

  /**
   * A standard atom's value can be one of:
   *
   * - A raw value
   * - A Zedux store
   * - A function that returns a raw value
   * - A function that returns a Zedux store
   * - A function that returns an AtomApi
   */
  private _evaluate() {
    const { _value } = this.template

    if (typeof _value !== 'function') {
      return _value
    }

    try {
      const val = (
        _value as (
          ...params: G['Params']
        ) => G['Store'] | G['State'] | AtomApi<AtomGenericsToAtomApiGenerics<G>>
      )(...this.params)

      if (!is(val, AtomApi)) return val as G['Store'] | G['State']

      const api = (this.api = val as AtomApi<AtomGenericsToAtomApiGenerics<G>>)

      // Exports can only be set on initial evaluation
      if (this.status === 'Initializing' && api.exports) {
        this.exports = api.exports
      }

      // if api.value is a promise, we ignore api.promise
      if (typeof (api.value as unknown as Promise<any>)?.then === 'function') {
        return this._setPromise(api.value as unknown as Promise<any>, true)
      } else if (api.promise) {
        this._setPromise(api.promise)
      }

      return api.value as G['Store'] | G['State']
    } catch (err) {
      console.error(
        `Zedux: Error while evaluating atom "${this.template.key}" with params:`,
        this.params,
        err
      )

      throw err
    }
  }

  private _evaluationTask() {
    const newFactoryResult = this._doEvaluate()

    const newStateType = getStateType(newFactoryResult)

    if (DEV && newStateType !== this._stateType) {
      throw new Error(
        `Zedux: atom factory for atom "${this.template.key}" returned a different type than the previous evaluation. This can happen if the atom returned a store initially but then returned a non-store value on a later evaluation or vice versa`
      )
    }

    if (DEV && newStateType === StoreState && newFactoryResult !== this.store) {
      throw new Error(
        `Zedux: atom factory for atom "${this.template.key}" returned a different store. Did you mean to use \`injectStore()\`, or \`injectMemo()\`?`
      )
    }

    // there is no way to cause an evaluation loop when the StateType is Value
    if (newStateType === RawState) {
      this.store.setState(
        typeof newFactoryResult === 'function'
          ? () => newFactoryResult as G['State']
          : (newFactoryResult as G['State'])
      )
    }
  }

  private _getTtl() {
    if (this.api?.ttl == null) {
      return this.template.ttl ?? this.ecosystem.atomDefaults?.ttl
    }

    // this atom instance set its own ttl
    const { ttl } = this.api

    return typeof ttl === 'function' ? ttl() : ttl
  }

  private _handleStateChange(
    newState: G['State'],
    oldState: G['State'] | undefined,
    action: ActionChain
  ) {
    this.ecosystem._graph.scheduleDependents(
      this.id,
      this.nextReasons,
      newState,
      oldState,
      false
    )

    if (this.ecosystem._mods.stateChanged) {
      this.ecosystem.modBus.dispatch(
        pluginActions.stateChanged({
          action,
          instance: this,
          newState,
          oldState,
          reasons: this.nextReasons,
        })
      )
    }

    // run the scheduler synchronously after any atom instance state update
    if (action.meta !== zeduxTypes.batch) {
      this.ecosystem._scheduler.flush()
    }
  }

  private _setStatus(newStatus: LifecycleStatus) {
    const oldStatus = this.status
    this.status = newStatus

    if (this.ecosystem._mods.statusChanged) {
      this.ecosystem.modBus.dispatch(
        pluginActions.statusChanged({
          newStatus,
          node: this,
          oldStatus,
        })
      )
    }
  }

  private _setPromise(promise: Promise<any>, isStateUpdater?: boolean) {
    const currentState = this.store?.getState()
    if (promise === this.promise) return currentState

    this.promise = promise as G['Promise']

    // since we're the first to chain off the returned promise, we don't need to
    // track the chained promise - it will run first, before React suspense's
    // `.then` on the thrown promise, for example
    promise
      .then(data => {
        if (this.promise !== promise) return

        this._promiseStatus = 'success'
        if (!isStateUpdater) return

        this.store.setState(
          getSuccessPromiseState(data) as unknown as G['State']
        )
      })
      .catch(error => {
        if (this.promise !== promise) return

        this._promiseStatus = 'error'
        this._promiseError = error
        if (!isStateUpdater) return

        this.store.setState(
          getErrorPromiseState(error) as unknown as G['State']
        )
      })

    const state: PromiseState<any> = getInitialPromiseState(currentState?.data)
    this._promiseStatus = state.status

    this.ecosystem._graph.scheduleDependents(
      this.id,
      this.nextReasons,
      undefined,
      undefined,
      true,
      'promise changed',
      'Updated',
      true
    )

    return state as unknown as G['State']
  }
}
