import { isBrowser, isDev, isServer } from '@qwik.dev/core/build';
import { qwikDebugToString } from '../../debug';
import { assertFalse, assertTrue } from '../../shared/error/assert';
import { QError, qError } from '../../shared/error/error';
import { isServerPlatform } from '../../shared/platform/platform';
import type { QRLInternal } from '../../shared/qrl/qrl-class';
import type { Container } from '../../shared/types';
import { isPromise, maybeThen, retryOnPromise } from '../../shared/utils/promises';
import { qTest } from '../../shared/utils/qdev';
import type { ValueOrPromise } from '../../shared/utils/types';
import type { SSRContainer } from '../../ssr/ssr-types';
import { invokeApply, newInvokeContext, tryGetInvokeContext } from '../../use/use-core';
import type { Tracker } from '../../use/use-task';
import { trackFn } from '../../use/utils/tracker';
import { _EFFECT_BACK_REF, type BackRef } from '../backref';
import { clearEffectSubscription } from '../cleanup';
import { getSubscriber } from '../subscriber';
import {
  AsyncSignalFlags,
  ComputeQRL,
  ComputedSignalFlags,
  EffectProperty,
  EffectSubscription,
  NEEDS_COMPUTATION,
  SerializationSignalFlags,
  type ComputeCtx,
  type ComputedOptions,
} from '../types';
import {
  addQrlToSerializationCtx,
  ensureContainsBackRef,
  ensureContainsSubscription,
  scheduleEffects,
  throwIfQRLNotResolved,
} from '../utils';
import { SignalImpl } from './signal-impl';

const DEBUG = false;
// eslint-disable-next-line no-console
const log = (...args: any[]) => console.log('COMPUTED SIGNAL', ...args.map(qwikDebugToString));

/** Retains job metadata and also serves as the argument for CTX_ARG compute functions */
export class Job<T> implements ComputeCtx<T> {
  /** First holds the compute promise and then the cleanup promise */
  $promise$: Promise<void> | null | void = null;
  $cleanupRequested$: boolean = false;
  $canWrite$: boolean = true;
  declare $track$: Tracker | undefined;
  declare $cleanups$: Parameters<ComputeCtx<T>['cleanup']>[0][] | undefined;
  declare $abortController$: AbortController | undefined;
  declare info: unknown;
  declare $infoVersion$: number | undefined;

  constructor(
    readonly $signal$: ComputedSignalImpl<T, any>,
    info: unknown,
    $infoVersion$: number | undefined
  ) {
    if (info !== undefined) {
      this.info = info;
      this.$infoVersion$ = $infoVersion$;
    }
  }

  get track(): Tracker {
    return (this.$track$ ||= trackFn(this.$signal$, this.$signal$.$container$));
  }

  get abortSignal(): AbortSignal {
    return (this.$abortController$ ||= new AbortController()).signal;
  }

  /** Backward compatible cache method for resource */
  cache(): void {
    isDev &&
      console.error(
        'useResource cache() method does not do anything. Use `useComputed$` instead of `useResource$`, use the `expires` option for polling behavior.'
      );
  }

  get previous(): T | undefined {
    const val = this.$signal$.$untrackedValue$;
    if (val !== NEEDS_COMPUTATION) {
      return val;
    }
  }

  cleanup(callback: () => void) {
    if (typeof callback === 'function') {
      (this.$cleanups$ ||= []).push(callback);
    }
  }
}

/**
 * A signal which is computed from other signals.
 *
 * Sync compute functions resolve synchronously and every read they perform is auto-tracked. When
 * the compute function turns out to be async (it returned a promise), the signal lazily switches on
 * the async engine — jobs, `loading`, `error`, polling — giving it the same API as an AsyncSignal.
 * Auto-tracking only covers reads before the first await point (the invoke context is lost after
 * it); later reads must use the ComputeCtx `track()`.
 */
// TODO when we make core lazy async, lazy load the async engine part of this class
export class ComputedSignalImpl<T, S extends QRLInternal = ComputeQRL<T>>
  extends SignalImpl<T>
  implements BackRef
{
  /**
   * The compute function is stored here.
   *
   * Sync computed functions must be executed synchronously (because of this we need to eagerly
   * resolve the QRL during the mark dirty phase so that any call to it will be synchronous).
   */
  $computeQrl$: S;
  $flags$: number;
  [_EFFECT_BACK_REF]: Map<EffectProperty | string, EffectSubscription> | undefined = undefined;

  // Async engine state: only populated when the compute fn is async (ASYNC_MODE),
  // except $untrackedError$ which also captures sync compute throws
  declare $untrackedPending$: boolean | undefined;
  declare $untrackedError$: Error | undefined;
  declare $current$: Job<T> | null | undefined;
  declare $jobs$: Job<T>[] | undefined;
  declare $concurrency$: number | undefined;
  declare $expires$: number | undefined;
  declare $timeoutMs$: number | undefined;
  declare $loadingEffects$: undefined | Set<EffectSubscription>;
  declare $errorEffects$: undefined | Set<EffectSubscription>;
  declare $pollTimeoutId$: ReturnType<typeof setTimeout> | undefined;
  declare $computationTimeoutId$: ReturnType<typeof setTimeout> | undefined;
  declare $info$: unknown | undefined;
  declare $infoVersion$: number | undefined;

  constructor(
    container: Container | null,
    fn: S,
    // We need a separate flag to know when the computation needs running because
    // we need the old value to know if effects need running after computation
    flags: number = ComputedSignalFlags.INVALID |
      SerializationSignalFlags.SERIALIZATION_STRATEGY_ALWAYS,
    options?: ComputedOptions<T>
  ) {
    // The value is used for comparison when signals trigger, which can only happen
    // when it was calculated before. Therefore we can pass whatever we like.
    super(container || tryGetInvokeContext()?.$container$, NEEDS_COMPUTATION);
    this.$computeQrl$ = fn;
    this.$flags$ = flags;
    if (!options) {
      return;
    }

    // Eagerly evaluate an initial value without clearing INVALID, so the compute still runs
    const initial = options.initial;
    if (initial !== undefined) {
      this.$untrackedValue$ = typeof initial === 'function' ? (initial as () => T)() : initial;
    }

    const concurrency = options.concurrency;
    if (concurrency !== undefined && concurrency >= 0 && concurrency !== 1) {
      this.$concurrency$ = concurrency;
      this.$jobs$ = [];
    }
    if (options.timeout) {
      this.$timeoutMs$ = options.timeout;
    }
    if (options.eagerCleanup) {
      this.$flags$ |= AsyncSignalFlags.EAGER_CLEANUP;
    }
    if (options.clientOnly) {
      // clientOnly can never resolve during SSR, so it must be async from the start
      this.$flags$ |= AsyncSignalFlags.CLIENT_ONLY | AsyncSignalFlags.ASYNC_MODE;
    }
    if (options.allowStale === false) {
      if (isDev && initial !== undefined) {
        throw new Error(
          'allowStale: false and initial cannot be used together. ' +
            'allowStale: false clears the value on invalidation, which conflicts with providing an initial value.'
        );
      }
      this.$flags$ |= AsyncSignalFlags.CLEAR_ON_INVALIDATE;
    }
    const expires = options.expires ?? (options.interval ? Math.abs(options.interval) : undefined);
    if (expires) {
      this.expires = expires;
    }
    if (options.poll === false || (options.interval !== undefined && options.interval < 0)) {
      this.$flags$ |= AsyncSignalFlags.NO_POLL;
    }
  }

  invalidate(info?: unknown) {
    if (arguments.length > 0) {
      this.$info$ = info;
      this.$infoVersion$ = this.$infoVersion$ === undefined ? 1 : this.$infoVersion$ + 1;
    }
    if (this.$flags$ & AsyncSignalFlags.ASYNC_MODE) {
      this.$setInvalid$(true, this.$flags$ & AsyncSignalFlags.CLEAR_ON_INVALIDATE);
      return;
    }
    this.$flags$ |= ComputedSignalFlags.INVALID;
    const ctx = newInvokeContext();
    ctx.$container$ = this.$container$ || undefined;
    // @ts-expect-error it's confused about args any[] vs []
    const running = retryOnPromise(invokeApply.bind(this, ctx, this.$computeIfNeeded$));
    if (isPromise(running)) {
      running.catch((err: unknown) => {
        if (this.$container$) {
          this.$container$.handleError(err, null);
        } else {
          console.error('Error during computation', err);
        }
      });
    }
  }

  /**
   * Read the value, subscribing if in a tracking context. Triggers computation if needed.
   *
   * Setting the value will mark the signal as not loading and clear any error, and prevent any
   * pending computations from writing their results.
   *
   * If you want to set the value without affecting loading or error state, set `untrackedValue`
   * instead and make sure to trigger effects manually if needed.
   *
   * If you want to abort pending computations when setting, you have to call `abort()` manually.
   */
  override get value(): T {
    return super.value;
  }

  override set value(value: T) {
    this.$flags$ &= ~ComputedSignalFlags.INVALID;
    this.untrackedError = undefined;
    if (this.$flags$ & AsyncSignalFlags.ASYNC_MODE) {
      this.untrackedPending = false;
      this.$info$ = undefined;
      // Prevent pending computations from overwriting this value
      if (this.$jobs$) {
        for (let i = 0; i < this.$jobs$.length; i++) {
          this.$jobs$[i].$canWrite$ = false;
        }
      } else if (this.$current$) {
        this.$current$.$canWrite$ = false;
      }
      this.$clearNextPoll$();
      super.value = value;
      this.$scheduleNextPoll$();
      return;
    }
    super.value = value;
  }

  get untrackedValue() {
    this.$computeIfNeeded$();
    if (this.$current$?.$promise$) {
      if (this.$untrackedValue$ === NEEDS_COMPUTATION || (qTest ? isServerPlatform() : isServer)) {
        DEBUG && log('Throwing promise while computing initial value', this);
        throw this.$current$?.$promise$;
      }
      DEBUG &&
        log('Returning stale value', this.$untrackedValue$, 'while computing', this.$current$);
      return this.$untrackedValue$;
    }
    if (this.$untrackedError$) {
      DEBUG && log('Throwing error while reading value', this);
      throw this.$untrackedError$;
    }
    // For clientOnly signals without initial value during SSR, throw if trying to read value
    // During SSR, clientOnly signals are skipped, so there's no computed value available
    if (
      (qTest ? isServerPlatform() : isServer) &&
      this.$flags$ & AsyncSignalFlags.CLIENT_ONLY &&
      this.$untrackedValue$ === NEEDS_COMPUTATION
    ) {
      throw qError(QError.asyncClientOnlyValueDuringSSR);
    }
    isDev &&
      !(this.$flags$ & AsyncSignalFlags.ASYNC_MODE) &&
      assertFalse(this.$untrackedValue$ === NEEDS_COMPUTATION, 'Invalid state');
    return this.$untrackedValue$;
  }
  set untrackedValue(value: T) {
    this.$untrackedValue$ = value;
  }

  /**
   * Loading is true if the signal is still waiting for the promise to resolve, false if the promise
   * has resolved or rejected (or the compute function is synchronous).
   *
   * Accessing `.pending` will trigger computation if needed, since it's often used like
   *
   * ```ts
   * signal.pending ? <Loading /> : signal.value
   * ```
   */
  get pending(): boolean {
    const val = this.untrackedPending;
    const ctx = tryGetInvokeContext();
    if (ctx && (this.$container$ ||= ctx.$container$ || null)) {
      isDev &&
        assertTrue(
          !ctx.$container$ || ctx.$container$ === this.$container$,
          'Do not use signals across containers'
        );
      const effectSubscriber = ctx.$effectSubscriber$;
      if (effectSubscriber) {
        ensureContainsSubscription((this.$loadingEffects$ ||= new Set()), effectSubscriber);
        ensureContainsBackRef(effectSubscriber, this);
        addQrlToSerializationCtx(effectSubscriber, this.$container$);
      }
    }
    return val;
  }

  set untrackedPending(value: boolean) {
    if (value !== !!this.$untrackedPending$) {
      this.$untrackedPending$ = value;
      DEBUG && log('Set untrackedPending', value);
      scheduleEffects(this.$container$, this, this.$loadingEffects$);
    }
  }

  get untrackedPending(): boolean {
    // reading `.loading` means someone is interested in the result, so we should trigger the computation. The alternative is eager computation or imperative calls to invalidate; this seems nicer.
    this.$computeIfNeeded$();
    // During SSR there's no such thing as loading state, we must render complete results
    if ((qTest ? isServerPlatform() : isServer) && this.$current$?.$promise$) {
      DEBUG && log('Throwing loading promise for SSR');
      throw this.$current$?.$promise$;
    }
    return !!this.$untrackedPending$;
  }

  /** @deprecated Use `pending` instead */
  get loading(): boolean {
    return this.pending;
  }

  /** @deprecated Use `untrackedPending` instead */
  get untrackedLoading(): boolean {
    return this.untrackedPending;
  }

  set untrackedLoading(value: boolean) {
    this.untrackedPending = value;
  }

  /**
   * The error the compute function threw or rejected with.
   *
   * Accessing .error will trigger computation if needed, since it's often used like
   *
   * ```ts
   * signal.error ? <Failed /> : signal.value
   * ```
   */
  get error(): Error | undefined {
    const val = this.untrackedError;
    const ctx = tryGetInvokeContext();
    if (ctx && (this.$container$ ||= ctx.$container$ || null)) {
      isDev &&
        assertTrue(
          !ctx.$container$ || ctx.$container$ === this.$container$,
          'Do not use signals across containers'
        );
      const effectSubscriber = ctx.$effectSubscriber$;
      if (effectSubscriber) {
        ensureContainsSubscription((this.$errorEffects$ ||= new Set()), effectSubscriber);
        ensureContainsBackRef(effectSubscriber, this);
        addQrlToSerializationCtx(effectSubscriber, this.$container$);
      }
    }
    return val;
  }

  set untrackedError(value: Error | undefined) {
    if (value !== this.$untrackedError$) {
      this.$untrackedError$ = value;
      scheduleEffects(this.$container$, this, this.$errorEffects$);
    }
  }

  get untrackedError() {
    this.$computeIfNeeded$();
    // During SSR we must render settled results
    if ((qTest ? isServerPlatform() : isServer) && this.$current$?.$promise$) {
      throw this.$current$?.$promise$;
    }
    return this.$untrackedError$;
  }

  get expires() {
    return this.$expires$ || 0;
  }

  set expires(value: number) {
    this.$clearNextPoll$();
    this.$expires$ = value;
    if (this.$expires$ && this.$hasSubscribers$()) {
      this.$scheduleNextPoll$();
    }
  }

  get poll() {
    return !(this.$flags$ & AsyncSignalFlags.NO_POLL);
  }

  set poll(value: boolean) {
    if (value) {
      this.$flags$ &= ~AsyncSignalFlags.NO_POLL;
    } else {
      this.$flags$ |= AsyncSignalFlags.NO_POLL;
    }
    // Reschedule since poll behavior changed
    if (this.$expires$ && this.$hasSubscribers$()) {
      this.$clearNextPoll$();
      this.$scheduleNextPoll$();
    }
  }

  /** @deprecated Use `expires` and `poll` instead. */
  get interval() {
    const expires = this.$expires$ || 0;
    return this.$flags$ & AsyncSignalFlags.NO_POLL ? -expires : expires;
  }

  set interval(value: number) {
    if (value < 0) {
      this.$flags$ |= AsyncSignalFlags.NO_POLL;
    } else {
      this.$flags$ &= ~AsyncSignalFlags.NO_POLL;
    }
    this.expires = Math.abs(value);
  }

  $setInvalid$(allowRecalc: boolean, mustClear: boolean | number): void {
    this.$flags$ |= ComputedSignalFlags.INVALID;
    this.$clearNextPoll$();
    if (mustClear) {
      this.$untrackedValue$ = NEEDS_COMPUTATION;
    }
    if (
      allowRecalc &&
      (this.$effects$?.size || this.$loadingEffects$?.size || this.$errorEffects$?.size)
    ) {
      // compute in next microtask
      Promise.resolve().then(() => this.$computeIfNeeded$());
    }
  }

  /** Abort the current computation and run cleanups if needed. */
  abort(reason?: any): void {
    if (this.$current$) {
      this.$requestCleanups$(this.$current$, reason);
    }
  }

  /** Schedule eager cleanup on next macro task if no subscribers remain. */
  $scheduleEagerCleanup$(): void {
    if (!(this.$flags$ & AsyncSignalFlags.EAGER_CLEANUP) || this.$hasSubscribers$()) {
      return;
    }
    if (!(qTest ? !isServerPlatform() : isBrowser)) {
      return;
    }
    setTimeout(() => {
      if (!this.$hasSubscribers$()) {
        this.abort();
      }
    });
  }

  /** Returns a promise resolves when the signal finished computing. */
  async promise(): Promise<void> {
    this.$computeIfNeeded$();
    // Wait for the current computation to finish, but if we became invalid while running, we need to wait for the new computation instead. So we loop until we are no longer invalid
    while (this.$current$?.$promise$) {
      await this.$current$?.$promise$;
    }
  }

  /** Run the computation if needed */
  $computeIfNeeded$(): void {
    if (!(this.$flags$ & ComputedSignalFlags.INVALID)) {
      return;
    }
    if (this.$flags$ & AsyncSignalFlags.ASYNC_MODE) {
      return this.$computeAsync$();
    }
    const computeQrl = this.$computeQrl$;
    throwIfQRLNotResolved(computeQrl);

    // The previous compute's cleanups run before the new compute, like the async engine
    const prevJob = this.$current$;
    if (prevJob && !prevJob.$cleanupRequested$) {
      prevJob.$cleanupRequested$ = true;
      prevJob.$abortController$?.abort();
      this.$runCleanupCallbacks$(prevJob);
    }
    const running = new Job<T>(this, this.$info$, this.$infoVersion$);
    this.$current$ = running;

    let result: T | Promise<T>;
    try {
      result = this.$invokeComputeFn$(computeQrl.resolved as Function, running);
    } catch (err) {
      // A thrown promise is a retry request, not a failure
      if (isPromise(err)) {
        throw err;
      }
      if (running.$infoVersion$ === this.$infoVersion$) {
        this.$info$ = undefined;
      }
      this.$flags$ &= ~ComputedSignalFlags.INVALID;
      this.$setError$(running, err as Error);
      return;
    }
    if (isPromise(result)) {
      // The compute fn is async: hand the in-flight computation to the async engine
      this.$flags$ |= AsyncSignalFlags.ASYNC_MODE;
      this.$flags$ &= ~ComputedSignalFlags.INVALID;
      // Consume the in-flight promise first; retries re-invoke the compute fn
      let first: Promise<T> | undefined = result;
      const compute = () => {
        const r =
          first ?? (this.$invokeComputeFn$(this.$computeQrl$.resolved as Function, running) as T);
        first = undefined;
        return r;
      };
      running.$promise$ = this.$settleComputation$(running, () => retryOnPromise(compute));
      return;
    }
    DEBUG && log('Signal.$compute$', result);

    if (running.$infoVersion$ === this.$infoVersion$) {
      this.$info$ = undefined;
    }
    this.$flags$ &= ~ComputedSignalFlags.INVALID;
    this.untrackedError = undefined;
    super.value = result;
  }

  /**
   * Invoke the compute fn with the ComputeCtx argument. CTX_ARG signals (useAsync$, useResource$)
   * only track via its explicit track(). Computeds auto-track every synchronous read instead; after
   * the first await they must use the explicit track() too.
   */
  $invokeComputeFn$(fn: Function, job: Job<T>): T | Promise<T> {
    const computeFn = fn as (job: Job<T>) => T;
    if (this.$flags$ & AsyncSignalFlags.CTX_ARG) {
      // Strip the ambient effect subscriber: these compute fns track only via their track()
      let iCtx = tryGetInvokeContext();
      if (iCtx?.$effectSubscriber$) {
        iCtx = { ...iCtx, $effectSubscriber$: undefined };
      }
      return invokeApply(iCtx, computeFn, [job]);
    }
    const container = (this.$container$ ||= tryGetInvokeContext()?.$container$ || null);
    const subscriber = getSubscriber(this, EffectProperty.VNODE);
    if (container) {
      clearEffectSubscription(container, subscriber);
    }
    const ctx = newInvokeContext();
    ctx.$container$ = container || undefined;
    ctx.$effectSubscriber$ = subscriber;
    return invokeApply(ctx, computeFn, [job]);
  }

  /** Start an async computation (the async engine; assumes the INVALID flag is set) */
  $computeAsync$(): void {
    // Skip computation on SSR for clientOnly signals
    if ((qTest ? isServerPlatform() : isServer) && this.$flags$ & AsyncSignalFlags.CLIENT_ONLY) {
      // We must pretend to load, and register as a listener for the captures
      this.$untrackedPending$ = true;
      (this.$container$ as SSRContainer)?.serializationCtx.$eagerResume$.add(this);
      return;
    }
    this.$clearNextPoll$();

    // Clear flag here to make sure the cleanups don't start another compute
    this.$flags$ &= ~ComputedSignalFlags.INVALID;

    const current = this.$current$;
    if (current) {
      this.$requestCleanups$(current);
    }

    const limit = this.$concurrency$ === 0 ? Number.POSITIVE_INFINITY : (this.$concurrency$ ?? 1);
    // We only have $jobs$[] when concurrency != 1
    if (this.$jobs$ ? this.$jobs$.length >= limit : current?.$promise$) {
      DEBUG && log(`Concurrency limit ${limit} reached, not starting new computation`);
      // We requested cleanups for all the previous jobs, once one finishes it will be removed from the jobs array and trigger computeIfNeeded
      // Restore invalid state
      this.$flags$ |= ComputedSignalFlags.INVALID;
      return;
    }

    DEBUG && log('Starting new async computation');

    // We put the actual computation in a separate method so we can easily retain the promise
    const infoVersion = this.$infoVersion$;
    const running = new Job<T>(this, this.$info$, infoVersion);
    this.$current$ = running;
    if (this.$jobs$) {
      this.$jobs$.push(running);
    }
    running.$promise$ = this.$runComputation$(running);
  }

  async $runComputation$(running: Job<T>): Promise<void> {
    let fn = this.$computeQrl$.resolved;
    if (!fn) {
      // QRL resolution is async — we have to publish loading=true before awaiting so
      // subscribers know the value isn't ready yet.
      this.untrackedPending = true;
      fn = await this.$computeQrl$.resolve();
      if (running.$abortController$?.signal.aborted) {
        DEBUG && log('Computation aborted before it started');
        running.$promise$ = null;
        return;
      }
    }

    if (this.$timeoutMs$) {
      this.$computationTimeoutId$ = setTimeout(() => {
        const error = new Error(`timeout ${this.$timeoutMs$}ms`);
        this.$setError$(running, error);
        running.$abortController$?.abort(error);
      }, this.$timeoutMs$);
    }

    return this.$settleComputation$(running, () =>
      retryOnPromise(() => this.$invokeComputeFn$(fn as Function, running))
    );
  }

  /** Await the computation and publish its result, error, and loading transitions. */
  async $settleComputation$(running: Job<T>, compute: () => ValueOrPromise<T>): Promise<void> {
    const isCurrent = () => running === this.$current$;

    try {
      // Try to stay sync if possible. Only publish loading=true to subscribers when
      // the compute is actually asynchronous — a synchronous resolve (e.g. pre-loaded
      // values injected via _injectAsyncSignalValue) should never transition through a
      // visible loading state, which on SSR would fire the loading-effect subscribers
      // (tasks) while the value is still "loading" from their perspective.
      const valuePromise = compute();
      let value: T;
      if (isPromise(valuePromise)) {
        this.untrackedPending = true;
        value = await valuePromise;
      } else {
        value = valuePromise;
      }

      running.$promise$ = null;

      if (running.$canWrite$) {
        const jobs = this.$jobs$;
        if (jobs) {
          let doDisable = false;
          for (let i = jobs.length - 1; i >= 0; i--) {
            if (jobs[i] === running) {
              doDisable = true;
            } else if (doDisable) {
              jobs[i].$canWrite$ = false;
            }
          }
        }

        DEBUG && log('Promise resolved', value);
        // we leave error as-is until result

        // Note that these assignments run setters
        this.untrackedError = undefined;
        /**
         * Use SignalImpl.value instead of this.value to persist invalid state, so that invalidation
         * during computation recomputes
         */
        super.value = value;
      }
    } catch (err) {
      running.$promise$ = null;
      DEBUG && log('Error caught in promise.catch', err);
      this.$setError$(running, err as Error);
    }

    if (isCurrent()) {
      clearTimeout(this.$computationTimeoutId$);
      if (running.$infoVersion$ === this.$infoVersion$) {
        this.$info$ = undefined;
      }

      if (this.$flags$ & ComputedSignalFlags.INVALID) {
        DEBUG && log('Computation finished but signal is invalid, re-running');
        // we became invalid again while running, so we need to re-run the computation to get the new promise
        this.$computeIfNeeded$();
      } else {
        this.untrackedPending = false;
        this.$scheduleNextPoll$();
      }
    }
  }

  /**
   * Sets the error from the given job. We only accept errors from the current job and we ignore
   * AbortErrors.
   */
  $setError$(job: Job<T>, error: Error): void {
    if (job !== this.$current$ || !job.$canWrite$) {
      return;
    }
    job.$canWrite$ = false;
    if (error instanceof Error && error.name === 'AbortError') {
      // AbortError from AbortSignal is a cancellation, not an actual error
      return;
    }
    this.untrackedError = error;
    // Job failures should be rare and require retrying
    this.untrackedValue = NEEDS_COMPUTATION;
  }

  /** Called after SSR/unmount */
  async $destroy$() {
    this.$clearNextPoll$();
    clearTimeout(this.$computationTimeoutId$);
    const current = this.$current$;
    if (current) {
      this.$requestCleanups$(current);
    }
    if (this.$jobs$) {
      await Promise.all(this.$jobs$.map((job) => job.$promise$));
    } else {
      await current?.$promise$;
    }
  }

  private $clearNextPoll$() {
    if (this.$pollTimeoutId$ !== undefined) {
      clearTimeout(this.$pollTimeoutId$);
      this.$pollTimeoutId$ = undefined;
    }
  }

  private $scheduleNextPoll$() {
    if ((qTest ? isServerPlatform() : isServer) || !this.$expires$) {
      return;
    }

    this.$clearNextPoll$();

    const allowRecalc = !(this.$flags$ & AsyncSignalFlags.NO_POLL);
    // Even when clear on invalidate, we don't clear if we're merely re-running due to polling
    // We expect to get the new value soon, so we can avoid showing a loading state
    const mustClear = this.$flags$ & AsyncSignalFlags.CLEAR_ON_INVALIDATE && !allowRecalc;
    this.$pollTimeoutId$ = setTimeout(
      () => this.$setInvalid$(allowRecalc, mustClear),
      this.$expires$!
    );

    this.$pollTimeoutId$?.unref?.();
  }

  private $hasSubscribers$(): boolean {
    return !!(this.$effects$?.size || this.$loadingEffects$?.size || this.$errorEffects$?.size);
  }

  $requestCleanups$(job: Job<T>, reason?: any) {
    if (job.$cleanupRequested$) {
      return;
    }
    DEBUG && log('Requesting cleanups for job', job);
    job.$cleanupRequested$ = true;
    job.$abortController$?.abort(reason);
    job.$promise$ = maybeThen(job.$promise$, () => this.$runCleanups$(job));
  }
  /** Run a job's cleanup callbacks, returning a promise when any of them are async. */
  $runCleanupCallbacks$(job: Job<T>): Promise<void> | undefined {
    const cleanups = job.$cleanups$;
    const onError = (err: any) => {
      const handleError = this.$container$?.handleError;
      if (handleError) {
        handleError(err, null!);
      } else {
        console.error('Error in async signal cleanup', err);
      }
    };
    let promiseChain: Promise<void> | undefined = undefined;
    if (cleanups) {
      DEBUG && log('cleanup start for real', job);
      // Keep this sync-ish so sync functions run immediately.
      for (let i = 0; i < cleanups.length; i++) {
        try {
          const result = cleanups[i]();
          if (isPromise(result)) {
            promiseChain = (promiseChain ? promiseChain.then(() => result) : result).catch(onError);
          }
        } catch (err) {
          onError(err);
        }
      }
      cleanups.length = 0;
    }
    return promiseChain;
  }

  /** Clean up and trigger signal compute once complete */
  $runCleanups$(job: Job<T>) {
    DEBUG && log('cleanup start', job);
    const onDone = () => {
      job.$promise$ = null;
      DEBUG && log('cleanup finished', job);
      // Now trigger compute
      const jobs = this.$jobs$;
      if (jobs) {
        const idx = jobs.indexOf(job);
        if (idx !== -1) {
          jobs.splice(idx, 1);
        }
      }
      this.$computeIfNeeded$();
    };
    const promiseChain = this.$runCleanupCallbacks$(job);
    if (promiseChain) {
      return promiseChain.then(onDone);
    } else {
      onDone();
    }
  }
}
