import {
    CaughtException,
    IDerivation,
    IDerivationState_,
    IEqualsComparer,
    IObservable,
    Lambda,
    clearObserving,
    compareDefault,
    createAction,
    createInstanceofPredicate,
    endBatch,
    getNextId,
    globalState,
    isCaughtException,
    isSpyEnabled,
    markObserved,
    propagateChangeConfirmed,
    propagateMaybeChanged,
    reportObserved,
    shouldCompute,
    spyReport,
    startBatch,
    toPrimitive,
    trackDerivedFunction,
    die,
    allowStateChangesStart,
    allowStateChangesEnd
} from "../internal"

import { getFlag, setFlag } from "../utils/utils"

export interface IComputedValue<T> {
    get(): T
    set(value: T): void
}

export interface IComputedValueOptions<T> {
    get?: () => T
    set?: (value: T) => void
    name?: string
    equals?: IEqualsComparer<T>
    context?: any
    requiresReaction?: boolean
    keepAlive?: boolean
}

export type IComputedDidChange<T = any> = {
    type: "update"
    observableKind: "computed"
    object: unknown
    debugObjectName: string
    newValue: T
    oldValue: T | undefined
}

/**
 * A node in the state dependency root that observes other nodes, and can be observed itself.
 *
 * ComputedValue will remember the result of the computation for the duration of the batch, or
 * while being observed.
 *
 * During this time it will recompute only when one of its direct dependencies changed,
 * but only when it is being accessed with `ComputedValue.get()`.
 *
 * Implementation description:
 * 1. First time it's being accessed it will compute and remember result
 *    give back remembered result until 2. happens
 * 2. First time any deep dependency change, propagate POSSIBLY_STALE to all observers, wait for 3.
 * 3. When it's being accessed, recompute if any shallow dependency changed.
 *    if result changed: propagate STALE to all observers, that were POSSIBLY_STALE from the last step.
 *    go to step 2. either way
 *
 * If at any point it's outside batch and it isn't observed: reset everything and go to 1.
 */
const enum ComputedValueFlags {
    isComputing = 0b00001,
    isRunningSetter = 0b00010,
    isBeingObserved = 0b00100,
    isPendingUnobservation = 0b01000,
    diffValue = 0b10000
}

export class ComputedValue<T> implements IObservable, IComputedValue<T>, IDerivation {
    dependenciesState_ = IDerivationState_.NOT_TRACKING_
    observing_: IObservable[] = [] // nodes we are looking at. Our value depends on these nodes
    newObserving_ = null // during tracking it's an array with new observed observers
    // Lazily allocated on first observer - see Atom.observers_.
    observers_: Set<IDerivation> | null = null
    runId_ = 0
    lastAccessedBy_ = 0
    lowestObserverState_ = IDerivationState_.UP_TO_DATE_
    unboundDepsCount_ = 0
    protected value_: T | undefined | CaughtException = new CaughtException(null)
    name_: string
    triggeredBy_?: string

    private flags_ = 0b00000

    derivation: () => T // N.B: unminified as it is used by MST
    setter_?: (value: T) => void
    scope_: Object | undefined
    private equals_: IEqualsComparer<any>
    private requiresReaction_: boolean | undefined
    keepAlive_: boolean

    /**
     * Create a new computed value based on a function expression.
     *
     * The `name` property is for debug purposes only.
     *
     * The `equals` property specifies the comparer function used to determine if a newly produced
     * value differs from the previous value. Structural comparison can be convenient if you always
     * produce a new aggregated object and don't want to notify observers if it is structurally the same.
     * This is useful for working with vectors, mouse coordinates etc.
     */
    constructor(options: IComputedValueOptions<T>) {
        if (!options.get) {
            die(31)
        }
        this.derivation = options.get!
        this.name_ = options.name || (__DEV__ ? "ComputedValue@" + getNextId() : "ComputedValue")
        if (options.set) {
            this.setter_ = createAction(
                __DEV__ ? this.name_ + "-setter" : "ComputedValue-setter",
                options.set
            ) as any
        }
        this.equals_ = options.equals || compareDefault
        this.scope_ = options.context
        this.requiresReaction_ = options.requiresReaction
        this.keepAlive_ = !!options.keepAlive
    }

    onBecomeStale_() {
        propagateMaybeChanged(this)
    }

    public onBOL: Set<Lambda> | undefined
    public onBUOL: Set<Lambda> | undefined

    public onBO() {
        if (this.onBOL) {
            this.onBOL.forEach(listener => listener())
        }
    }

    public onBUO() {
        if (this.onBUOL) {
            this.onBUOL.forEach(listener => listener())
        }
    }

    // to check for cycles
    private get isComputing(): boolean {
        return getFlag(this.flags_, ComputedValueFlags.isComputing)
    }
    private set isComputing(newValue: boolean) {
        this.flags_ = setFlag(this.flags_, ComputedValueFlags.isComputing, newValue)
    }

    private get isRunningSetter(): boolean {
        return getFlag(this.flags_, ComputedValueFlags.isRunningSetter)
    }
    private set isRunningSetter(newValue: boolean) {
        this.flags_ = setFlag(this.flags_, ComputedValueFlags.isRunningSetter, newValue)
    }

    get isBeingObserved(): boolean {
        return getFlag(this.flags_, ComputedValueFlags.isBeingObserved)
    }
    set isBeingObserved(newValue: boolean) {
        this.flags_ = setFlag(this.flags_, ComputedValueFlags.isBeingObserved, newValue)
    }

    get isPendingUnobservation(): boolean {
        return getFlag(this.flags_, ComputedValueFlags.isPendingUnobservation)
    }
    set isPendingUnobservation(newValue: boolean) {
        this.flags_ = setFlag(this.flags_, ComputedValueFlags.isPendingUnobservation, newValue)
    }

    get diffValue(): 0 | 1 {
        return getFlag(this.flags_, ComputedValueFlags.diffValue) ? 1 : 0
    }
    set diffValue(newValue: 0 | 1) {
        this.flags_ = setFlag(
            this.flags_,
            ComputedValueFlags.diffValue,
            newValue === 1 ? true : false
        )
    }

    /**
     * Returns the current value of this computed value.
     * Will evaluate its computation first if needed.
     */
    public get(): T {
        if (this.isComputing) {
            die(32, this.name_, this.derivation)
        }
        if (
            globalState.inBatch === 0 &&
            // !globalState.trackingDerivatpion &&
            (!this.observers_ || this.observers_.size === 0) &&
            !this.keepAlive_
        ) {
            if (shouldCompute(this)) {
                this.warnAboutUntrackedRead_()
                startBatch() // See perf test 'computed memoization'
                this.value_ = this.computeValue_(false)
                endBatch()
            }
        } else {
            const wasBeingObserved = this.isBeingObserved
            reportObserved(this)
            if (shouldCompute(this)) {
                let prevTrackingContext = globalState.trackingContext
                if (this.keepAlive_ && !prevTrackingContext) {
                    globalState.trackingContext = this
                }
                if (this.trackAndCompute()) {
                    propagateChangeConfirmed(this)
                }
                globalState.trackingContext = prevTrackingContext
            } else if (!wasBeingObserved && this.isBeingObserved) {
                // We just became observed while serving a cached value, so the getter
                // won't run and won't re-report our dependencies. Cascade to them. #4547
                this.observing_.forEach(markObserved)
            }
        }
        const result = this.value_!

        if (isCaughtException(result)) {
            throw result.cause
        }
        return result
    }

    public set(value: T) {
        if (this.setter_) {
            if (this.isRunningSetter) {
                die(33, this.name_)
            }
            this.isRunningSetter = true
            try {
                this.setter_.call(this.scope_, value)
            } finally {
                this.isRunningSetter = false
            }
        } else {
            die(34, this.name_)
        }
    }

    trackAndCompute(): boolean {
        // N.B: unminified as it is used by MST
        const oldValue = this.value_
        const wasSuspended =
            /* see #1208 */ this.dependenciesState_ === IDerivationState_.NOT_TRACKING_
        const newValue = this.computeValue_(true)

        const changed =
            wasSuspended ||
            isCaughtException(oldValue) ||
            isCaughtException(newValue) ||
            !this.equals_(oldValue, newValue)

        if (changed) {
            this.value_ = newValue

            if (__DEV__ && isSpyEnabled()) {
                spyReport({
                    observableKind: "computed",
                    debugObjectName: this.name_,
                    object: this.scope_,
                    type: "update",
                    oldValue,
                    newValue
                } as IComputedDidChange)
            }
        }

        return changed
    }

    computeValue_(track: boolean) {
        this.isComputing = true
        // don't allow state changes during computation
        const prev = __DEV__ ? allowStateChangesStart(false) : false
        let res: T | CaughtException
        if (track) {
            res = trackDerivedFunction(this, this.derivation, this.scope_)
        } else {
            if (globalState.disableErrorBoundaries === true) {
                res = this.derivation.call(this.scope_)
            } else {
                try {
                    res = this.derivation.call(this.scope_)
                } catch (e) {
                    res = new CaughtException(e)
                }
            }
        }
        if (__DEV__) {
            allowStateChangesEnd(prev)
        }
        this.isComputing = false
        return res
    }

    suspend_() {
        if (!this.keepAlive_) {
            clearObserving(this)
            this.value_ = undefined // don't hold on to computed value!
        }
    }

    warnAboutUntrackedRead_() {
        if (!__DEV__) {
            return
        }
        if (
            typeof this.requiresReaction_ === "boolean"
                ? this.requiresReaction_
                : globalState.computedRequiresReaction
        ) {
            console.warn(
                `[mobx] Computed value '${this.name_}' is being read outside a reactive context. Doing a full recompute.`
            )
        }
    }

    toString() {
        return `${this.name_}[${this.derivation.toString()}]`
    }

    valueOf(): T {
        return toPrimitive(this.get())
    }

    [Symbol.toPrimitive]() {
        return this.valueOf()
    }
}

export const isComputedValue = createInstanceofPredicate("ComputedValue", ComputedValue)
