import {
    Lambda,
    ComputedValue,
    IDerivation,
    IDerivationState_,
    globalState,
    runReactions,
    checkIfStateReadsAreAllowed
} from "../internal"

export interface IDepTreeNode {
    name_: string
    observing_?: IObservable[]
}

export interface IObservable extends IDepTreeNode {
    diffValue: number
    /**
     * Id of the derivation *run* that last accessed this observable.
     * If this id equals the *run* id of the current derivation,
     * the dependency is already established
     */
    lastAccessedBy_: number
    isBeingObserved: boolean

    lowestObserverState_: IDerivationState_ // Used to avoid redundant propagations
    isPendingUnobservation: boolean // Used to push itself to global.pendingUnobservations at most once per batch.

    observers_: Set<IDerivation> | null

    onBUO(): void
    onBO(): void

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

export function hasObservers(observable: IObservable): boolean {
    return !!observable.observers_ && observable.observers_.size > 0
}

export function getObservers(observable: IObservable): Set<IDerivation> {
    return observable.observers_ ?? new Set()
}

// function invariantObservers(observable: IObservable) {
//     const list = observable.observers
//     const map = observable.observersIndexes
//     const l = list.length
//     for (let i = 0; i < l; i++) {
//         const id = list[i].__mapid
//         if (i) {
//             invariant(map[id] === i, "INTERNAL ERROR maps derivation.__mapid to index in list") // for performance
//         } else {
//             invariant(!(id in map), "INTERNAL ERROR observer on index 0 shouldn't be held in map.") // for performance
//         }
//     }
//     invariant(
//         list.length === 0 || Object.keys(map).length === list.length - 1,
//         "INTERNAL ERROR there is no junk in map"
//     )
// }
export function addObserver(observable: IObservable, node: IDerivation) {
    // invariant(node.dependenciesState !== -1, "INTERNAL ERROR, can add only dependenciesState !== -1");
    // invariant(observable._observers.indexOf(node) === -1, "INTERNAL ERROR add already added node");
    // invariantObservers(observable);

    ;(observable.observers_ ??= new Set()).add(node)
    if (observable.lowestObserverState_ > node.dependenciesState_) {
        observable.lowestObserverState_ = node.dependenciesState_
    }

    // invariantObservers(observable);
    // invariant(observable._observers.indexOf(node) !== -1, "INTERNAL ERROR didn't add node");
}

export function removeObserver(observable: IObservable, node: IDerivation) {
    // invariant(globalState.inBatch > 0, "INTERNAL ERROR, remove should be called only inside batch");
    // invariant(observable._observers.indexOf(node) !== -1, "INTERNAL ERROR remove already removed node");
    // invariantObservers(observable);
    const observers = observable.observers_
    if (!observers) {
        return
    }
    observers.delete(node)
    if (observers.size === 0) {
        // deleting last observer
        queueForUnobservation(observable)
    }
    // invariantObservers(observable);
    // invariant(observable._observers.indexOf(node) === -1, "INTERNAL ERROR remove already removed node2");
}

export function queueForUnobservation(observable: IObservable) {
    if (observable.isPendingUnobservation === false) {
        // invariant(observable._observers.length === 0, "INTERNAL ERROR, should only queue for unobservation unobserved observables");
        observable.isPendingUnobservation = true
        globalState.pendingUnobservations.push(observable)
    }
}

/**
 * Batch starts a transaction, at least for purposes of memoizing ComputedValues when nothing else does.
 * During a batch `onBecomeUnobserved` will be called at most once per observable.
 * Avoids unnecessary recalculations.
 */
export function startBatch() {
    globalState.inBatch++
}

export function endBatch() {
    if (--globalState.inBatch === 0) {
        runReactions()
        // the batch is actually about to finish, all unobserving should happen here.
        // Guard against re-entering this loop: an onBUO handler can dispose a Reaction,
        // which calls startBatch/endBatch again while we're still iterating. Bail out of
        // the nested call instead of recursing; the outer loop re-reads list.length on
        // every iteration, so it picks up anything the nested dispose() pushes onto the
        // same pendingUnobservations array.
        if (!globalState.isRunningUnobservations) {
            globalState.isRunningUnobservations = true
            try {
                const list = globalState.pendingUnobservations
                for (let i = 0; i < list.length; i++) {
                    const observable = list[i]
                    observable.isPendingUnobservation = false
                    if (!observable.observers_ || observable.observers_.size === 0) {
                        if (observable.isBeingObserved) {
                            // if this observable had reactive observers, trigger the hooks
                            observable.isBeingObserved = false
                            observable.onBUO()
                        }
                        if (observable instanceof ComputedValue) {
                            // computed values are automatically teared down when the last observer leaves
                            // this process happens recursively, this computed might be the last observabe of another, etc..
                            observable.suspend_()
                        }
                    }
                }
                globalState.pendingUnobservations = []
            } finally {
                // Always release the guard, even if an onBUO handler (user code) threw,
                // otherwise every future endBatch() would see isRunningUnobservations
                // stuck true and silently stop draining pendingUnobservations forever.
                globalState.isRunningUnobservations = false
            }
        }
    }
}

/**
 * Marks an observable as observed, cascading into the dependencies of a ComputedValue.
 * Unobservation already cascades (`suspend_` -> `clearObserving`), observation normally
 * only does so by accident: a newly observed computed usually recomputes and re-reports
 * its dependencies. When it serves a cached value instead nothing re-reports them, so the
 * transition has to be propagated by hand. See #4547.
 */
export function markObserved(observable: IObservable) {
    if (observable.isBeingObserved) {
        return
    }
    observable.isBeingObserved = true
    observable.onBO()
    // No queueForUnobservation here: the observer links already exist, so the regular
    // suspend_ -> clearObserving -> removeObserver teardown still delivers the onBUO.
    observable.observing_?.forEach(markObserved)
}

export function reportObserved(observable: IObservable): boolean {
    checkIfStateReadsAreAllowed(observable)

    const derivation = globalState.trackingDerivation
    if (derivation !== null) {
        /**
         * Simple optimization, give each derivation run an unique id (runId)
         * Check if last time this observable was accessed the same runId is used
         * if this is the case, the relation is already known
         */
        if (derivation.runId_ !== observable.lastAccessedBy_) {
            observable.lastAccessedBy_ = derivation.runId_
            // Tried storing newObserving, or observing, or both as Set, but performance didn't come close...
            derivation.newObserving_![derivation.unboundDepsCount_++] = observable
            if (!observable.isBeingObserved && globalState.trackingContext) {
                observable.isBeingObserved = true
                observable.onBO()
            }
        }
        return observable.isBeingObserved
    } else if (
        (!observable.observers_ || observable.observers_.size === 0) &&
        globalState.inBatch > 0
    ) {
        queueForUnobservation(observable)
    }

    return false
}

// function invariantLOS(observable: IObservable, msg: string) {
//     // it's expensive so better not run it in produciton. but temporarily helpful for testing
//     const min = getObservers(observable).reduce((a, b) => Math.min(a, b.dependenciesState), 2)
//     if (min >= observable.lowestObserverState) return // <- the only assumption about `lowestObserverState`
//     throw new Error(
//         "lowestObserverState is wrong for " +
//             msg +
//             " because " +
//             min +
//             " < " +
//             observable.lowestObserverState
//     )
// }

/**
 * NOTE: current propagation mechanism will in case of self reruning autoruns behave unexpectedly
 * It will propagate changes to observers from previous run
 * It's hard or maybe impossible (with reasonable perf) to get it right with current approach
 * Hopefully self reruning autoruns aren't a feature people should depend on
 * Also most basic use cases should be ok
 */

// Called by Atom when its value changes
export function propagateChanged(observable: IObservable) {
    // invariantLOS(observable, "changed start");
    if (observable.lowestObserverState_ === IDerivationState_.STALE_) {
        return
    }
    observable.lowestObserverState_ = IDerivationState_.STALE_

    // Ideally we use for..of here, but the downcompiled version is really slow...
    observable.observers_?.forEach(d => {
        if (d.dependenciesState_ === IDerivationState_.UP_TO_DATE_) {
            d.onBecomeStale_()
        }
        d.dependenciesState_ = IDerivationState_.STALE_
    })
    // invariantLOS(observable, "changed end");
}

// Called by ComputedValue when it recalculate and its value changed
export function propagateChangeConfirmed(observable: IObservable) {
    // invariantLOS(observable, "confirmed start");
    if (observable.lowestObserverState_ === IDerivationState_.STALE_) {
        return
    }
    observable.lowestObserverState_ = IDerivationState_.STALE_

    observable.observers_?.forEach(d => {
        if (d.dependenciesState_ === IDerivationState_.POSSIBLY_STALE_) {
            d.dependenciesState_ = IDerivationState_.STALE_
        } else if (
            d.dependenciesState_ === IDerivationState_.UP_TO_DATE_ // this happens during computing of `d`, just keep lowestObserverState up to date.
        ) {
            observable.lowestObserverState_ = IDerivationState_.UP_TO_DATE_
        }
    })
    // invariantLOS(observable, "confirmed end");
}

// Used by computed when its dependency changed, but we don't wan't to immediately recompute.
export function propagateMaybeChanged(observable: IObservable) {
    // invariantLOS(observable, "maybe start");
    if (observable.lowestObserverState_ !== IDerivationState_.UP_TO_DATE_) {
        return
    }
    observable.lowestObserverState_ = IDerivationState_.POSSIBLY_STALE_

    observable.observers_?.forEach(d => {
        if (d.dependenciesState_ === IDerivationState_.UP_TO_DATE_) {
            d.dependenciesState_ = IDerivationState_.POSSIBLY_STALE_
            d.onBecomeStale_()
        }
    })
    // invariantLOS(observable, "maybe end");
}
