// Copyright 2018-2026 the Deno authors. MIT license.
// This module is browser compatible.

/**
 * Options for {@linkcode Lazy.prototype.get}.
 */
export interface LazyGetOptions {
  /**
   * Signal used to abort the wait for initialization.
   *
   * Aborting does not cancel the underlying initializer — it only rejects the
   * caller's promise. Other callers and any in-flight initialization are
   * unaffected.
   */
  signal?: AbortSignal;
}

/**
 * A lazy value that is initialized at most once, with built-in deduplication of
 * concurrent callers. Prevents the common race where two concurrent `get()` calls
 * both trigger the initializer; only one initialization runs and all callers share
 * the same promise.
 *
 * If the initializer rejects, the error is propagated to all concurrent callers
 * and the internal state is cleared — the next {@linkcode Lazy.prototype.get}
 * call will re-run the initializer. Compose with {@linkcode retry} for
 * automatic back-off on transient failures.
 *
 * @example Concurrent deduplication
 *
 * ```ts
 * import { Lazy } from "@std/async/lazy";
 * import { assertEquals } from "@std/assert";
 *
 * let initCount = 0;
 * const config = new Lazy<number>(async () => {
 *   initCount++;
 *   return 42;
 * });
 *
 * const [a, b] = await Promise.all([config.get(), config.get()]);
 * assertEquals(a, 42);
 * assertEquals(b, 42);
 * assertEquals(initCount, 1);
 * ```
 *
 * @example Composing with retry
 *
 * ```ts ignore
 * import { Lazy } from "@std/async/lazy";
 * import { retry } from "@std/async/retry";
 *
 * const db = new Lazy(() =>
 *   retry(() => connectDb(), { minTimeout: 100, maxAttempts: 3 })
 * );
 * await db.get();
 * ```
 *
 * @typeParam T The type of the lazily initialized value.
 */
export class Lazy<T> {
  #init: () => T | Promise<T>;
  #promise: Promise<T> | undefined = undefined;
  #value: T | undefined = undefined;
  #settled = false;

  /**
   * Creates a new lazy value.
   *
   * @param init Initializer function, called at most once (until {@linkcode reset}).
   */
  constructor(init: () => T | Promise<T>) {
    this.#init = init;
  }

  /**
   * Returns the cached value, initializing it on first call. Concurrent callers
   * share the same in-flight promise — the initializer is never invoked more
   * than once at a time.
   *
   * Always returns a promise, even when the initializer is synchronous.
   *
   * @example Usage
   * ```ts no-assert
   * import { Lazy } from "@std/async/lazy";
   *
   * const config = new Lazy(async () => ({ loaded: true }));
   * const value = await config.get();
   * ```
   *
   * @example Abort a slow initialization
   * ```ts
   * import { Lazy } from "@std/async/lazy";
   * import { assertRejects } from "@std/assert";
   *
   * const slow = new Lazy(() => new Promise<string>(() => {}));
   * const controller = new AbortController();
   * controller.abort(new Error("timed out"));
   * await assertRejects(
   *   () => slow.get({ signal: controller.signal }),
   *   Error,
   *   "timed out",
   * );
   * ```
   *
   * @param options Optional settings for this call.
   * @returns The cached or newly initialized value.
   */
  get(options?: LazyGetOptions): Promise<T> {
    if (this.#settled) return Promise.resolve(this.#value as T);
    const signal = options?.signal;
    if (signal?.aborted) return Promise.reject(signal.reason);

    if (this.#promise === undefined) {
      const p = new Promise<T>((resolve, reject) => {
        Promise.resolve().then(() => this.#init()).then(
          (value) => {
            if (this.#promise === p) {
              this.#value = value;
              this.#settled = true;
            }
            resolve(value);
          },
          (err) => {
            if (this.#promise === p) {
              this.#promise = undefined;
            }
            reject(err);
          },
        );
      });
      this.#promise = p;
    }

    if (!signal) return this.#promise;

    return new Promise<T>((resolve, reject) => {
      const abort = () => reject(signal.reason);
      signal.addEventListener("abort", abort, { once: true });
      this.#promise!.then(
        (value) => {
          signal.removeEventListener("abort", abort);
          resolve(value);
        },
        (err) => {
          signal.removeEventListener("abort", abort);
          reject(err);
        },
      );
    });
  }

  /**
   * Whether the value has been successfully initialized.
   *
   * @example Check initialization state
   * ```ts
   * import { Lazy } from "@std/async/lazy";
   * import { assertEquals } from "@std/assert";
   *
   * const lazy = new Lazy(() => 42);
   * assertEquals(lazy.initialized, false);
   * await lazy.get();
   * assertEquals(lazy.initialized, true);
   * ```
   *
   * @returns `true` if the value has been initialized, `false` otherwise.
   */
  get initialized(): boolean {
    return this.#settled;
  }

  /**
   * Returns the value if already resolved, or indicates that it is not yet
   * available. The discriminated union avoids ambiguity when `T` itself can
   * be `undefined`.
   *
   * @example Fast-path when already initialized
   * ```ts
   * import { Lazy } from "@std/async/lazy";
   * import { assertEquals } from "@std/assert";
   *
   * const config = new Lazy(async () => ({ port: 8080 }));
   * await config.get();
   *
   * const result = config.peek();
   * assertEquals(result, { ok: true, value: { port: 8080 } });
   * ```
   *
   * @example Not yet initialized
   * ```ts
   * import { Lazy } from "@std/async/lazy";
   * import { assertEquals } from "@std/assert";
   *
   * const lazy = new Lazy(() => 42);
   * assertEquals(lazy.peek(), { ok: false });
   * ```
   *
   * @returns `{ ok: true, value }` if the value has been initialized, or
   *   `{ ok: false }` if not yet initialized or still in-flight.
   */
  peek(): { ok: true; value: T } | { ok: false } {
    return this.#settled
      ? { ok: true, value: this.#value as T }
      : { ok: false };
  }

  /**
   * Resets the lazy so the next {@linkcode get} re-runs the initializer. Does
   * not cancel an in-flight initialization; callers that already have the
   * promise will still receive its result.
   *
   * @example Force reload
   * ```ts ignore
   * import { Lazy } from "@std/async/lazy";
   *
   * const config = new Lazy(async () => loadConfig());
   * await config.get();
   * config.reset();
   * const fresh = await config.get();
   * ```
   */
  reset(): void {
    this.#promise = undefined;
    this.#value = undefined;
    this.#settled = false;
  }
}
