/**
 * The `ScopedCache` module provides a cache for values that acquire scoped
 * resources during lookup. Each cached entry owns a `Scope`, so resources
 * created while computing a value stay alive for as long as that entry remains
 * cached and are released when the entry is removed.
 *
 * A `ScopedCache` is itself created inside a scope. Calls to {@link get} run the
 * lookup effect on cache misses, share the same in-flight lookup among
 * concurrent callers for the same key, and store the resulting exit according
 * to a time-to-live policy. Entries can be inserted manually with {@link set},
 * refreshed with {@link refresh}, inspected without triggering lookup with
 * {@link getOption}, and removed with {@link invalidate} or
 * {@link invalidateAll}. Capacity limits evict the oldest entries.
 *
 * **Lifecycle notes**
 *
 * - Entry scopes are closed when entries expire, are invalidated, are evicted,
 *   are replaced, or when the cache's owning scope closes
 * - Successful and failed lookup exits are both cached according to the
 *   configured TTL
 * - Expired entries may remain counted by {@link size} until a cache operation
 *   observes and removes them
 * - Once the owning scope closes, the cache is closed and lookup-style
 *   operations interrupt instead of acquiring new values
 *
 * @since 4.0.0
 */
import * as Arr from "./Array.ts"
import * as Context from "./Context.ts"
import * as Deferred from "./Deferred.ts"
import * as Duration from "./Duration.ts"
import type * as Effect from "./Effect.ts"
import type * as Exit from "./Exit.ts"
import * as Fiber from "./Fiber.ts"
import { dual, identity } from "./Function.ts"
import * as core from "./internal/core.ts"
import { PipeInspectableProto } from "./internal/core.ts"
import * as effect from "./internal/effect.ts"
import * as MutableHashMap from "./MutableHashMap.ts"
import * as Option from "./Option.ts"
import type { Pipeable } from "./Pipeable.ts"
import * as Predicate from "./Predicate.ts"
import * as Scope from "./Scope.ts"

const TypeId = "~effect/ScopedCache"

/**
 * A scoped cache whose values are acquired by a lookup effect and stored in
 * per-entry scopes.
 *
 * **When to use**
 *
 * Use to cache values that acquire scoped resources and must release those
 * resources when entries expire, are evicted, or are invalidated.
 *
 * **Details**
 *
 * Concurrent requests for the same key share the same in-flight lookup.
 * Entries can expire based on the lookup exit, are evicted when capacity is
 * exceeded, and release their entry scopes when invalidated, evicted, expired,
 * or when the cache's owning scope closes.
 *
 * @see {@link make} for creating a scoped cache with a fixed time-to-live
 * @see {@link makeWith} for creating a scoped cache with dynamic time-to-live
 *
 * @category models
 * @since 2.0.0
 */
export interface ScopedCache<in out Key, in out A, in out E = never, out R = never> extends Pipeable {
  readonly [TypeId]: typeof TypeId
  state: State<Key, A, E>
  readonly capacity: number
  readonly lookup: (key: Key) => Effect.Effect<A, E, R | Scope.Scope>
  readonly timeToLive: (exit: Exit.Exit<A, E>, key: Key) => Duration.Duration
}

/**
 * Represents whether a `ScopedCache` is open or closed.
 *
 * **When to use**
 *
 * Use when inspecting the low-level lifecycle state of a scoped cache.
 *
 * **Details**
 *
 * `Open` stores cached entries in access order for reuse and eviction.
 * `Closed` means the owning scope has closed and the cache can no longer
 * perform lookup operations.
 *
 * @category models
 * @since 4.0.0
 */
export type State<K, A, E> = {
  readonly _tag: "Open"
  readonly map: MutableHashMap.MutableHashMap<K, Entry<A, E>>
} | {
  readonly _tag: "Closed"
}

/**
 * A single scoped cache entry.
 *
 * **When to use**
 *
 * Use when inspecting the open state of a `ScopedCache` and you need the stored
 * deferred result, entry scope, or expiration timestamp for a key.
 *
 * **Details**
 *
 * The entry contains the deferred lookup result shared by readers, the scope
 * that owns resources acquired while computing the value, and an optional
 * expiration time in milliseconds. Removing the entry closes its scope.
 *
 * @see {@link State} for the open/closed cache state that stores entries by key
 *
 * @category models
 * @since 4.0.0
 */
export interface Entry<A, E> {
  expiresAt: number | undefined
  readonly deferred: Deferred.Deferred<A, E>
  readonly scope: Scope.Closeable
}

/**
 * Creates a `ScopedCache` from a lookup function, maximum capacity, and a
 * time-to-live function computed from each lookup exit and key.
 *
 * **When to use**
 *
 * Use when cached scoped resources need different lifetimes based on the lookup
 * result or key.
 *
 * **Details**
 *
 * The cache must be constructed in a `Scope`. Each lookup runs in its own entry
 * scope, and that scope is closed when the entry expires, is invalidated, is
 * evicted by capacity, or when the cache's owning scope closes.
 * `requireServicesAt` controls whether lookup services are captured at
 * construction time or required when lookup operations run.
 *
 * @see {@link make} for creating a scoped cache with one fixed time-to-live
 *
 * @category constructors
 * @since 2.0.0
 */
export const makeWith = <
  Key,
  A,
  E = never,
  R = never,
  ServiceMode extends "lookup" | "construction" = never
>(options: {
  readonly lookup: (key: Key) => Effect.Effect<A, E, R | Scope.Scope>
  readonly capacity: number
  readonly timeToLive?: ((exit: Exit.Exit<A, E>, key: Key) => Duration.Input) | undefined
  readonly requireServicesAt?: ServiceMode | undefined
}): Effect.Effect<
  ScopedCache<Key, A, E, "lookup" extends ServiceMode ? Exclude<R, Scope.Scope> : never>,
  never,
  ("lookup" extends ServiceMode ? never : R) | Scope.Scope
> =>
  effect.contextWith((context: Context.Context<any>) => {
    const scope = Context.get(context, Scope.Scope)
    const self = Object.create(Proto)
    self.lookup = (key: Key): Effect.Effect<A, E> =>
      effect.updateContext(
        options.lookup(key),
        (input) => Context.merge(context, input)
      )
    const map = MutableHashMap.empty<Key, Entry<A, E>>()
    self.state = { _tag: "Open", map }
    self.capacity = options.capacity
    self.timeToLive = options.timeToLive
      ? (exit: Exit.Exit<A, E>, key: Key) => Duration.fromInputUnsafe(options.timeToLive!(exit, key))
      : defaultTimeToLive
    return effect.as(
      Scope.addFinalizer(
        scope,
        core.withFiber((fiber) => {
          self.state = { _tag: "Closed" }
          return invalidateAllImpl(fiber, map)
        })
      ),
      self
    )
  })

/**
 * Creates a `ScopedCache` with a fixed time-to-live for every lookup result.
 *
 * **When to use**
 *
 * Use to create a scoped cache when every cached lookup result should share the
 * same lifetime.
 *
 * **Details**
 *
 * This is the constant-TTL variant of `makeWith`: values are acquired by the
 * lookup effect in per-entry scopes, capacity can evict older entries, and
 * entry scopes are closed when entries expire, are invalidated, are evicted, or
 * when the cache's owning scope closes.
 *
 * @see {@link makeWith} for computing time-to-live from each lookup result and key
 *
 * @category constructors
 * @since 2.0.0
 */
export const make = <
  Key,
  A,
  E = never,
  R = never,
  ServiceMode extends "lookup" | "construction" = never
>(
  options: {
    readonly lookup: (key: Key) => Effect.Effect<A, E, R | Scope.Scope>
    readonly capacity: number
    readonly timeToLive?: Duration.Input | undefined
    readonly requireServicesAt?: ServiceMode | undefined
  }
): Effect.Effect<
  ScopedCache<Key, A, E, "lookup" extends ServiceMode ? Exclude<R, Scope.Scope> : never>,
  never,
  ("lookup" extends ServiceMode ? never : R) | Scope.Scope
> =>
  makeWith<Key, A, E, R, ServiceMode>({
    ...options,
    timeToLive: options.timeToLive ? () => options.timeToLive! : defaultTimeToLive
  })

const Proto = {
  ...PipeInspectableProto,
  [TypeId]: TypeId,
  toJSON(this: ScopedCache<any, any, any>) {
    return {
      _id: "ScopedCache",
      capacity: this.capacity,
      state: this.state
    }
  }
}

const defaultTimeToLive = <A, E>(_: Exit.Exit<A, E>, _key: unknown): Duration.Duration => Duration.infinity

/**
 * Gets the value for a key, running the cache lookup when no unexpired entry is
 * present.
 *
 * **When to use**
 *
 * Use to retrieve a scoped cached value by key when a missing or expired entry
 * should run the cache lookup and share the in-flight lookup with concurrent
 * callers.
 *
 * **Details**
 *
 * Concurrent `get` calls for the same key share the same in-flight lookup.
 * Successful and failed lookup exits are cached according to the configured
 * TTL. If the cache is closed, the effect is interrupted.
 *
 * @see {@link getOption} for reading only when an unexpired entry is already cached
 * @see {@link getSuccess} for inspecting an already-completed successful entry
 * @see {@link refresh} for forcing a new lookup
 *
 * @category combinators
 * @since 4.0.0
 */
export const get: {
  /**
   * Gets the value for a key, running the cache lookup when no unexpired entry is
   * present.
   *
   * **When to use**
   *
   * Use to retrieve a scoped cached value by key when a missing or expired entry
   * should run the cache lookup and share the in-flight lookup with concurrent
   * callers.
   *
   * **Details**
   *
   * Concurrent `get` calls for the same key share the same in-flight lookup.
   * Successful and failed lookup exits are cached according to the configured
   * TTL. If the cache is closed, the effect is interrupted.
   *
   * @see {@link getOption} for reading only when an unexpired entry is already cached
   * @see {@link getSuccess} for inspecting an already-completed successful entry
   * @see {@link refresh} for forcing a new lookup
   *
   * @category combinators
   * @since 4.0.0
   */
  <Key, A>(key: Key): <E, R>(self: ScopedCache<Key, A, E, R>) => Effect.Effect<A, E, R>
  /**
   * Gets the value for a key, running the cache lookup when no unexpired entry is
   * present.
   *
   * **When to use**
   *
   * Use to retrieve a scoped cached value by key when a missing or expired entry
   * should run the cache lookup and share the in-flight lookup with concurrent
   * callers.
   *
   * **Details**
   *
   * Concurrent `get` calls for the same key share the same in-flight lookup.
   * Successful and failed lookup exits are cached according to the configured
   * TTL. If the cache is closed, the effect is interrupted.
   *
   * @see {@link getOption} for reading only when an unexpired entry is already cached
   * @see {@link getSuccess} for inspecting an already-completed successful entry
   * @see {@link refresh} for forcing a new lookup
   *
   * @category combinators
   * @since 4.0.0
   */
  <Key, A, E, R>(self: ScopedCache<Key, A, E, R>, key: Key): Effect.Effect<A, E, R>
} = dual(
  2,
  <Key, A, E, R>(self: ScopedCache<Key, A, E, R>, key: Key): Effect.Effect<A, E, R> =>
    effect.uninterruptibleMask((restore) =>
      core.withFiber((fiber) => {
        const state = self.state
        if (state._tag === "Closed") {
          return effect.interrupt
        }
        const oentry = MutableHashMap.get(state.map, key)
        if (Option.isSome(oentry) && !hasExpired(oentry.value, fiber)) {
          // Move the entry to the end of the map to keep it fresh
          MutableHashMap.remove(state.map, key)
          MutableHashMap.set(state.map, key, oentry.value)
          return restore(Deferred.await(oentry.value.deferred))
        }
        const scope = Scope.makeUnsafe()
        const deferred = Deferred.makeUnsafe</**
         * Gets the value for a key, running the cache lookup when no unexpired entry is
         * present.
         *
         * **When to use**
         *
         * Use to retrieve a scoped cached value by key when a missing or expired entry
         * should run the cache lookup and share the in-flight lookup with concurrent
         * callers.
         *
         * **Details**
         *
         * Concurrent `get` calls for the same key share the same in-flight lookup.
         * Successful and failed lookup exits are cached according to the configured
         * TTL. If the cache is closed, the effect is interrupted.
         *
         * @see {@link getOption} for reading only when an unexpired entry is already cached
         * @see {@link getSuccess} for inspecting an already-completed successful entry
         * @see {@link refresh} for forcing a new lookup
         *
         * @category combinators
         * @since 4.0.0
         */
        A, /**
         * Gets the value for a key, running the cache lookup when no unexpired entry is
         * present.
         *
         * **When to use**
         *
         * Use to retrieve a scoped cached value by key when a missing or expired entry
         * should run the cache lookup and share the in-flight lookup with concurrent
         * callers.
         *
         * **Details**
         *
         * Concurrent `get` calls for the same key share the same in-flight lookup.
         * Successful and failed lookup exits are cached according to the configured
         * TTL. If the cache is closed, the effect is interrupted.
         *
         * @see {@link getOption} for reading only when an unexpired entry is already cached
         * @see {@link getSuccess} for inspecting an already-completed successful entry
         * @see {@link refresh} for forcing a new lookup
         *
         * @category combinators
         * @since 4.0.0
         */
        E>()
        const entry: Entry<A, E> = {
          expiresAt: undefined,
          deferred,
          scope
        }
        MutableHashMap.set(state.map, key, entry)
        return checkCapacity(fiber, state.map, self.capacity).pipe(
          Option.isSome(oentry) ? effect.flatMap(() => Scope.close(oentry.value.scope, effect.exitVoid)) : identity,
          effect.flatMap(() => Scope.provide(restore(self.lookup(key)), scope)),
          effect.onExit((exit) => {
            Deferred.doneUnsafe(deferred, exit)
            const ttl = self.timeToLive(exit, key)
            if (Duration.isFinite(ttl)) {
              entry.expiresAt = fiber.getRef(effect.ClockRef).currentTimeMillisUnsafe() + Duration.toMillis(ttl)
            }
            return effect.void
          })
        )
      })
    )
)

const hasExpired = <A, E>(entry: Entry<A, E>, fiber: Fiber.Fiber<unknown, unknown>): boolean => {
  if (entry.expiresAt === undefined) {
    return false
  }
  return fiber.getRef(effect.ClockRef).currentTimeMillisUnsafe() >= entry.expiresAt
}

const checkCapacity = <K, A, E>(
  parent: Fiber.Fiber<unknown, unknown>,
  map: MutableHashMap.MutableHashMap<K, Entry<A, E>>,
  capacity: number
): Effect.Effect<void> => {
  if (!Number.isFinite(capacity)) return effect.void
  let diff = MutableHashMap.size(map) - capacity
  if (diff <= 0) return effect.void
  // MutableHashMap has insertion order, so we can remove the oldest entries
  const fibers = Arr.empty<Fiber.Fiber<unknown, unknown>>()
  for (const [key, entry] of map) {
    MutableHashMap.remove(map, key)
    fibers.push(effect.forkUnsafe(parent as any, Scope.close(entry.scope, effect.exitVoid), true))
    diff--
    if (diff === 0) break
  }
  return effect.fiberAwaitAll(fibers)
}

/**
 * Reads an existing unexpired cache entry without running the lookup function.
 *
 * **When to use**
 *
 * Use to read a scoped value only when it is already cached, without starting
 * the lookup for missing or expired keys.
 *
 * **Details**
 *
 * Returns `Option.none` when the key is absent or expired. If an entry exists,
 * the effect waits for its cached result and returns `Option.some(value)` on
 * success, or fails with the cached lookup error.
 *
 * @see {@link get} for running the lookup on missing or expired keys
 * @see {@link getSuccess} for inspecting only already-completed successful entries
 *
 * @category combinators
 * @since 4.0.0
 */
export const getOption: {
  /**
   * Reads an existing unexpired cache entry without running the lookup function.
   *
   * **When to use**
   *
   * Use to read a scoped value only when it is already cached, without starting
   * the lookup for missing or expired keys.
   *
   * **Details**
   *
   * Returns `Option.none` when the key is absent or expired. If an entry exists,
   * the effect waits for its cached result and returns `Option.some(value)` on
   * success, or fails with the cached lookup error.
   *
   * @see {@link get} for running the lookup on missing or expired keys
   * @see {@link getSuccess} for inspecting only already-completed successful entries
   *
   * @category combinators
   * @since 4.0.0
   */
  <Key, A>(key: Key): <E, R>(self: ScopedCache<Key, A, E, R>) => Effect.Effect<Option.Option<A>, E>
  /**
   * Reads an existing unexpired cache entry without running the lookup function.
   *
   * **When to use**
   *
   * Use to read a scoped value only when it is already cached, without starting
   * the lookup for missing or expired keys.
   *
   * **Details**
   *
   * Returns `Option.none` when the key is absent or expired. If an entry exists,
   * the effect waits for its cached result and returns `Option.some(value)` on
   * success, or fails with the cached lookup error.
   *
   * @see {@link get} for running the lookup on missing or expired keys
   * @see {@link getSuccess} for inspecting only already-completed successful entries
   *
   * @category combinators
   * @since 4.0.0
   */
  <Key, A, E, R>(self: ScopedCache<Key, A, E, R>, key: Key): Effect.Effect<Option.Option<A>, E>
} = dual(
  2,
  <Key, A, E, R>(self: ScopedCache<Key, A, E, R>, key: Key): Effect.Effect<Option.Option<A>, E> =>
    effect.uninterruptibleMask((restore) =>
      core.withFiber((fiber) =>
        effect.flatMap(
          getImpl(self, key, fiber),
          (entry) => entry ? effect.asSome(restore(Deferred.await(entry.deferred))) : effect.succeedNone
        )
      )
    )
)

const getImpl = <Key, A, E, R>(
  self: ScopedCache<Key, A, E, R>,
  key: Key,
  fiber: Fiber.Fiber<any, any>,
  isRead = true
): Effect.Effect<Entry<A, E> | undefined> => {
  if (self.state._tag === "Closed") {
    return effect.interrupt
  }
  const state = self.state
  const oentry = MutableHashMap.get(state.map, key)
  if (Option.isNone(oentry)) {
    return effect.undefined
  } else if (hasExpired(oentry.value, fiber)) {
    MutableHashMap.remove(state.map, key)
    return effect.as(
      Scope.close(oentry.value.scope, effect.exitVoid),
      undefined
    )
  } else if (isRead) {
    MutableHashMap.remove(state.map, key)
    MutableHashMap.set(state.map, key, oentry.value)
  }
  return effect.succeed(oentry.value)
}

/**
 * Retrieves the value associated with the specified key from the cache, only if
 * it contains a resolved successful value.
 *
 * **When to use**
 *
 * Use to inspect an already-completed successful scoped cache entry without
 * running or awaiting the lookup effect.
 *
 * **Details**
 *
 * Returns `Option.some` for a resolved successful entry. Returns `Option.none`
 * for missing, expired, failed, or still-pending entries.
 *
 * @see {@link get} for awaiting or starting the lookup effect
 * @see {@link getOption} for awaiting an already-cached entry without starting a lookup
 *
 * @category combinators
 * @since 4.0.0
 */
export const getSuccess: {
  /**
   * Retrieves the value associated with the specified key from the cache, only if
   * it contains a resolved successful value.
   *
   * **When to use**
   *
   * Use to inspect an already-completed successful scoped cache entry without
   * running or awaiting the lookup effect.
   *
   * **Details**
   *
   * Returns `Option.some` for a resolved successful entry. Returns `Option.none`
   * for missing, expired, failed, or still-pending entries.
   *
   * @see {@link get} for awaiting or starting the lookup effect
   * @see {@link getOption} for awaiting an already-cached entry without starting a lookup
   *
   * @category combinators
   * @since 4.0.0
   */
  <Key, A, R>(key: Key): <E>(self: ScopedCache<Key, A, E, R>) => Effect.Effect<Option.Option<A>>
  /**
   * Retrieves the value associated with the specified key from the cache, only if
   * it contains a resolved successful value.
   *
   * **When to use**
   *
   * Use to inspect an already-completed successful scoped cache entry without
   * running or awaiting the lookup effect.
   *
   * **Details**
   *
   * Returns `Option.some` for a resolved successful entry. Returns `Option.none`
   * for missing, expired, failed, or still-pending entries.
   *
   * @see {@link get} for awaiting or starting the lookup effect
   * @see {@link getOption} for awaiting an already-cached entry without starting a lookup
   *
   * @category combinators
   * @since 4.0.0
   */
  <Key, A, E, R>(self: ScopedCache<Key, A, E, R>, key: Key): Effect.Effect<Option.Option<A>>
} = dual(
  2,
  <Key, A, E, R>(self: ScopedCache<Key, A, E, R>, key: Key): Effect.Effect<Option.Option<A>> =>
    effect.uninterruptible(
      core.withFiber((fiber) =>
        effect.map(
          getImpl(self, key, fiber),
          (entry) => {
            const exit = entry?.deferred.effect as Exit.Exit<A, E> | undefined
            if (exit && effect.exitIsSuccess(exit)) {
              return Option.some(exit.value)
            }
            return Option.none()
          }
        )
      )
    )
)

/**
 * Sets a successful value for a key without running the lookup function.
 *
 * **When to use**
 *
 * Use to seed or overwrite a scoped cache entry with an already available
 * successful value.
 *
 * **Details**
 *
 * This replaces and closes any existing entry scope for the key, applies the
 * cache's TTL using a successful exit for the value, and may evict older
 * entries if the cache capacity is exceeded.
 *
 * @see {@link get} for reading or computing a cached value
 * @see {@link refresh} for replacing an entry by running the lookup function
 *
 * @category combinators
 * @since 4.0.0
 */
export const set: {
  /**
   * Sets a successful value for a key without running the lookup function.
   *
   * **When to use**
   *
   * Use to seed or overwrite a scoped cache entry with an already available
   * successful value.
   *
   * **Details**
   *
   * This replaces and closes any existing entry scope for the key, applies the
   * cache's TTL using a successful exit for the value, and may evict older
   * entries if the cache capacity is exceeded.
   *
   * @see {@link get} for reading or computing a cached value
   * @see {@link refresh} for replacing an entry by running the lookup function
   *
   * @category combinators
   * @since 4.0.0
   */
  <Key, A>(key: Key, value: A): <E, R>(self: ScopedCache<Key, A, E, R>) => Effect.Effect<void>
  /**
   * Sets a successful value for a key without running the lookup function.
   *
   * **When to use**
   *
   * Use to seed or overwrite a scoped cache entry with an already available
   * successful value.
   *
   * **Details**
   *
   * This replaces and closes any existing entry scope for the key, applies the
   * cache's TTL using a successful exit for the value, and may evict older
   * entries if the cache capacity is exceeded.
   *
   * @see {@link get} for reading or computing a cached value
   * @see {@link refresh} for replacing an entry by running the lookup function
   *
   * @category combinators
   * @since 4.0.0
   */
  <Key, A, E, R>(self: ScopedCache<Key, A, E, R>, key: Key, value: A): Effect.Effect<void>
} = dual(
  3,
  <Key, A, E, R>(self: ScopedCache<Key, A, E, R>, key: Key, value: A): Effect.Effect<void> =>
    effect.uninterruptible(
      core.withFiber((fiber) => {
        if (self.state._tag === "Closed") {
          return effect.interrupt
        }
        const oentry = MutableHashMap.get(self.state.map, key)
        const state = self.state
        const exit = core.exitSucceed(value)
        const deferred = Deferred.makeUnsafe</**
         * Sets a successful value for a key without running the lookup function.
         *
         * **When to use**
         *
         * Use to seed or overwrite a scoped cache entry with an already available
         * successful value.
         *
         * **Details**
         *
         * This replaces and closes any existing entry scope for the key, applies the
         * cache's TTL using a successful exit for the value, and may evict older
         * entries if the cache capacity is exceeded.
         *
         * @see {@link get} for reading or computing a cached value
         * @see {@link refresh} for replacing an entry by running the lookup function
         *
         * @category combinators
         * @since 4.0.0
         */
        A, /**
         * Sets a successful value for a key without running the lookup function.
         *
         * **When to use**
         *
         * Use to seed or overwrite a scoped cache entry with an already available
         * successful value.
         *
         * **Details**
         *
         * This replaces and closes any existing entry scope for the key, applies the
         * cache's TTL using a successful exit for the value, and may evict older
         * entries if the cache capacity is exceeded.
         *
         * @see {@link get} for reading or computing a cached value
         * @see {@link refresh} for replacing an entry by running the lookup function
         *
         * @category combinators
         * @since 4.0.0
         */
        E>()
        Deferred.doneUnsafe(deferred, exit)
        const ttl = self.timeToLive(exit, key)
        MutableHashMap.set(state.map, key, {
          scope: Scope.makeUnsafe(),
          deferred,
          expiresAt: Duration.isFinite(ttl)
            ? fiber.getRef(effect.ClockRef).currentTimeMillisUnsafe() + Duration.toMillis(ttl)
            : undefined
        })
        const check = checkCapacity(fiber, state.map, self.capacity)
        return Option.isSome(oentry)
          ? effect.flatMap(Scope.close(oentry.value.scope, effect.exitVoid), () => check)
          : check
      })
    )
)

/**
 * Checks whether the cache contains an entry for the specified key.
 *
 * **When to use**
 *
 * Use to test whether an unexpired entry exists for a key without running the
 * cache lookup.
 *
 * **Details**
 *
 * This does not start lookups and does not refresh access order. Expired
 * entries are treated as absent and their scopes are closed while checking. If
 * the cache is closed, the effect is interrupted.
 *
 * @see {@link getOption} for reading an existing cached entry
 * @see {@link get} for running the lookup on missing or expired keys
 *
 * @category combinators
 * @since 4.0.0
 */
export const has: {
  /**
   * Checks whether the cache contains an entry for the specified key.
   *
   * **When to use**
   *
   * Use to test whether an unexpired entry exists for a key without running the
   * cache lookup.
   *
   * **Details**
   *
   * This does not start lookups and does not refresh access order. Expired
   * entries are treated as absent and their scopes are closed while checking. If
   * the cache is closed, the effect is interrupted.
   *
   * @see {@link getOption} for reading an existing cached entry
   * @see {@link get} for running the lookup on missing or expired keys
   *
   * @category combinators
   * @since 4.0.0
   */
  <Key, A>(key: Key): <E, R>(self: ScopedCache<Key, A, E, R>) => Effect.Effect<boolean>
  /**
   * Checks whether the cache contains an entry for the specified key.
   *
   * **When to use**
   *
   * Use to test whether an unexpired entry exists for a key without running the
   * cache lookup.
   *
   * **Details**
   *
   * This does not start lookups and does not refresh access order. Expired
   * entries are treated as absent and their scopes are closed while checking. If
   * the cache is closed, the effect is interrupted.
   *
   * @see {@link getOption} for reading an existing cached entry
   * @see {@link get} for running the lookup on missing or expired keys
   *
   * @category combinators
   * @since 4.0.0
   */
  <Key, A, E, R>(self: ScopedCache<Key, A, E, R>, key: Key): Effect.Effect<boolean>
} = dual(
  2,
  <Key, A, E>(self: ScopedCache<Key, A, E>, key: Key): Effect.Effect<boolean> =>
    effect.uninterruptible(
      core.withFiber((fiber) => effect.map(getImpl(self, key, fiber, false), Predicate.isNotUndefined))
    )
)

/**
 * Removes the entry associated with a key and closes its entry scope.
 *
 * **When to use**
 *
 * Use to remove a single key from a scoped cache and release any resources owned
 * by that entry before a later lookup computes it again.
 *
 * **Details**
 *
 * If the key is absent, this is a no-op.
 *
 * **Gotchas**
 *
 * If the cache is closed, the effect is interrupted.
 *
 * @see {@link refresh} for replacing a key by running a new lookup immediately
 * @see {@link invalidateWhen} for invalidating only when a cached value matches a predicate
 * @see {@link invalidateAll} for removing every cached entry
 *
 * @category combinators
 * @since 4.0.0
 */
export const invalidate: {
  /**
   * Removes the entry associated with a key and closes its entry scope.
   *
   * **When to use**
   *
   * Use to remove a single key from a scoped cache and release any resources owned
   * by that entry before a later lookup computes it again.
   *
   * **Details**
   *
   * If the key is absent, this is a no-op.
   *
   * **Gotchas**
   *
   * If the cache is closed, the effect is interrupted.
   *
   * @see {@link refresh} for replacing a key by running a new lookup immediately
   * @see {@link invalidateWhen} for invalidating only when a cached value matches a predicate
   * @see {@link invalidateAll} for removing every cached entry
   *
   * @category combinators
   * @since 4.0.0
   */
  <Key, A>(key: Key): <E, R>(self: ScopedCache<Key, A, E, R>) => Effect.Effect<void>
  /**
   * Removes the entry associated with a key and closes its entry scope.
   *
   * **When to use**
   *
   * Use to remove a single key from a scoped cache and release any resources owned
   * by that entry before a later lookup computes it again.
   *
   * **Details**
   *
   * If the key is absent, this is a no-op.
   *
   * **Gotchas**
   *
   * If the cache is closed, the effect is interrupted.
   *
   * @see {@link refresh} for replacing a key by running a new lookup immediately
   * @see {@link invalidateWhen} for invalidating only when a cached value matches a predicate
   * @see {@link invalidateAll} for removing every cached entry
   *
   * @category combinators
   * @since 4.0.0
   */
  <Key, A, E, R>(self: ScopedCache<Key, A, E, R>, key: Key): Effect.Effect<void>
} = dual(2, <Key, A, E, R>(self: ScopedCache<Key, A, E, R>, key: Key): Effect.Effect<void> =>
  effect.uninterruptible(
    effect.suspend(() => {
      if (self.state._tag === "Closed") {
        return effect.interrupt
      }
      const oentry = MutableHashMap.get(self.state.map, key)
      if (Option.isNone(oentry)) {
        return effect.void
      }
      MutableHashMap.remove(self.state.map, key)
      return Scope.close(oentry.value.scope, effect.exitVoid)
    })
  ))

/**
 * Invalidates the entry associated with the specified key in the cache when the
 * predicate returns true for the cached value.
 *
 * **When to use**
 *
 * Use to remove an already-cached scoped value only when the successful cached
 * value satisfies a predicate.
 *
 * **Details**
 *
 * Returns `true` only when a successful cached value matches and is removed. It
 * returns `false` for absent, expired, failed, or non-matching entries.
 *
 * **Gotchas**
 *
 * A matching invalidation closes the entry scope and releases its resources.
 *
 * @see {@link invalidate} for unconditional removal by key
 *
 * @category combinators
 * @since 4.0.0
 */
export const invalidateWhen: {
  /**
   * Invalidates the entry associated with the specified key in the cache when the
   * predicate returns true for the cached value.
   *
   * **When to use**
   *
   * Use to remove an already-cached scoped value only when the successful cached
   * value satisfies a predicate.
   *
   * **Details**
   *
   * Returns `true` only when a successful cached value matches and is removed. It
   * returns `false` for absent, expired, failed, or non-matching entries.
   *
   * **Gotchas**
   *
   * A matching invalidation closes the entry scope and releases its resources.
   *
   * @see {@link invalidate} for unconditional removal by key
   *
   * @category combinators
   * @since 4.0.0
   */
  <Key, A>(key: Key, f: Predicate.Predicate<A>): <E, R>(self: ScopedCache<Key, A, E, R>) => Effect.Effect<boolean>
  /**
   * Invalidates the entry associated with the specified key in the cache when the
   * predicate returns true for the cached value.
   *
   * **When to use**
   *
   * Use to remove an already-cached scoped value only when the successful cached
   * value satisfies a predicate.
   *
   * **Details**
   *
   * Returns `true` only when a successful cached value matches and is removed. It
   * returns `false` for absent, expired, failed, or non-matching entries.
   *
   * **Gotchas**
   *
   * A matching invalidation closes the entry scope and releases its resources.
   *
   * @see {@link invalidate} for unconditional removal by key
   *
   * @category combinators
   * @since 4.0.0
   */
  <Key, A, E, R>(self: ScopedCache<Key, A, E, R>, key: Key, f: Predicate.Predicate<A>): Effect.Effect<boolean>
} = dual(
  3,
  <Key, A, E, R>(self: ScopedCache<Key, A, E, R>, key: Key, f: Predicate.Predicate<A>): Effect.Effect<boolean> =>
    effect.uninterruptibleMask((restore) =>
      core.withFiber((fiber) =>
        effect.flatMap(getImpl(self, key, fiber, false), (entry) => {
          if (entry === undefined) {
            return effect.succeed(false)
          }
          return restore(Deferred.await(entry.deferred)).pipe(
            effect.flatMap((value) => {
              if (self.state._tag === "Closed") {
                return effect.succeed(false)
              } else if (f(value)) {
                MutableHashMap.remove(self.state.map, key)
                return effect.as(Scope.close(entry.scope, effect.exitVoid), true)
              }
              return effect.succeed(false)
            }),
            effect.catch_(() => effect.succeed(false))
          )
        })
      )
    )
)

/**
 * Forces a refresh of the value associated with the specified key in the cache.
 *
 * **When to use**
 *
 * Use to recompute a scoped cache entry immediately, even when an unexpired
 * value is already cached.
 *
 * **Details**
 *
 * It will always invoke the lookup function to construct a new value,
 * overwriting any existing value for that key.
 *
 * @see {@link get} for reusing an unexpired entry before running the lookup
 * @see {@link invalidate} for removing an entry without recomputing it
 *
 * @category combinators
 * @since 4.0.0
 */
export const refresh: {
  /**
   * Forces a refresh of the value associated with the specified key in the cache.
   *
   * **When to use**
   *
   * Use to recompute a scoped cache entry immediately, even when an unexpired
   * value is already cached.
   *
   * **Details**
   *
   * It will always invoke the lookup function to construct a new value,
   * overwriting any existing value for that key.
   *
   * @see {@link get} for reusing an unexpired entry before running the lookup
   * @see {@link invalidate} for removing an entry without recomputing it
   *
   * @category combinators
   * @since 4.0.0
   */
  <Key, A>(key: Key): <E, R>(self: ScopedCache<Key, A, E, R>) => Effect.Effect<A, E, R>
  /**
   * Forces a refresh of the value associated with the specified key in the cache.
   *
   * **When to use**
   *
   * Use to recompute a scoped cache entry immediately, even when an unexpired
   * value is already cached.
   *
   * **Details**
   *
   * It will always invoke the lookup function to construct a new value,
   * overwriting any existing value for that key.
   *
   * @see {@link get} for reusing an unexpired entry before running the lookup
   * @see {@link invalidate} for removing an entry without recomputing it
   *
   * @category combinators
   * @since 4.0.0
   */
  <Key, A, E, R>(self: ScopedCache<Key, A, E, R>, key: Key): Effect.Effect<A, E, R>
} = dual(
  2,
  <Key, A, E, R>(self: ScopedCache<Key, A, E, R>, key: Key): Effect.Effect<A, E, R> =>
    effect.uninterruptibleMask(effect.fnUntraced(function*(restore) {
      if (self.state._tag === "Closed") return yield* effect.interrupt
      const fiber = Fiber.getCurrent()!
      const scope = Scope.makeUnsafe()
      const deferred = Deferred.makeUnsafe</**
       * Forces a refresh of the value associated with the specified key in the cache.
       *
       * **When to use**
       *
       * Use to recompute a scoped cache entry immediately, even when an unexpired
       * value is already cached.
       *
       * **Details**
       *
       * It will always invoke the lookup function to construct a new value,
       * overwriting any existing value for that key.
       *
       * @see {@link get} for reusing an unexpired entry before running the lookup
       * @see {@link invalidate} for removing an entry without recomputing it
       *
       * @category combinators
       * @since 4.0.0
       */
      A, /**
       * Forces a refresh of the value associated with the specified key in the cache.
       *
       * **When to use**
       *
       * Use to recompute a scoped cache entry immediately, even when an unexpired
       * value is already cached.
       *
       * **Details**
       *
       * It will always invoke the lookup function to construct a new value,
       * overwriting any existing value for that key.
       *
       * @see {@link get} for reusing an unexpired entry before running the lookup
       * @see {@link invalidate} for removing an entry without recomputing it
       *
       * @category combinators
       * @since 4.0.0
       */
      E>()
      const entry: Entry<A, E> = {
        scope,
        expiresAt: undefined,
        deferred
      }
      const newEntry = !MutableHashMap.has(self.state.map, key)
      if (newEntry) {
        MutableHashMap.set(self.state.map, key, entry)
        yield* checkCapacity(fiber, self.state.map, self.capacity)
      }
      const exit = yield* effect.exit(restore(Scope.provide(self.lookup(key), scope)))
      Deferred.doneUnsafe(deferred, exit)
      // @ts-ignore async gap
      if (self.state._tag === "Closed") {
        if (!newEntry) {
          yield* Scope.close(scope, effect.exitVoid)
        }
        return yield* effect.interrupt
      }
      const ttl = self.timeToLive(exit, key)
      entry.expiresAt = Duration.isFinite(ttl)
        ? fiber.getRef(effect.ClockRef).currentTimeMillisUnsafe() + Duration.toMillis(ttl)
        : undefined
      if (!newEntry) {
        const oentry = MutableHashMap.get(self.state.map, key)
        MutableHashMap.set(self.state.map, key, entry)
        if (Option.isSome(oentry)) {
          yield* Scope.close(oentry.value.scope, effect.exitVoid)
        }
      }
      return yield* exit
    }))
)

/**
 * Removes every entry from the cache and closes each entry scope.
 *
 * **When to use**
 *
 * Use to clear a scoped cache and release resources owned by all cached entries.
 *
 * **Details**
 *
 * If the cache is closed, the effect is interrupted.
 *
 * @see {@link invalidate} for removing one cached entry
 *
 * @category combinators
 * @since 4.0.0
 */
export const invalidateAll = <Key, A, E, R>(self: ScopedCache<Key, A, E, R>): Effect.Effect<void> =>
  core.withFiber((parent) => {
    if (self.state._tag === "Closed") {
      return effect.interrupt
    }
    return invalidateAllImpl(parent, self.state.map)
  })

const invalidateAllImpl = <Key, A, E>(
  parent: Fiber.Fiber<unknown, unknown>,
  map: MutableHashMap.MutableHashMap<Key, Entry<A, E>>
): Effect.Effect<void> => {
  const fibers = Arr.empty<Fiber.Fiber<unknown, unknown>>()
  for (const [, entry] of map) {
    fibers.push(effect.forkUnsafe(parent as any, Scope.close(entry.scope, effect.exitVoid), true, true))
  }
  MutableHashMap.clear(map)
  return effect.fiberAwaitAll(fibers)
}

/**
 * Retrieves the approximate number of entries in the cache.
 *
 * **When to use**
 *
 * Use to inspect how many entries are currently stored in the scoped cache.
 *
 * **Gotchas**
 *
 * Note that expired entries are counted until they are accessed and removed.
 * The size reflects the current number of entries stored, not the number
 * of valid entries.
 *
 * @category combinators
 * @since 4.0.0
 */
export const size = <Key, A, E, R>(self: ScopedCache<Key, A, E, R>): Effect.Effect<number> =>
  effect.sync(() => self.state._tag === "Closed" ? 0 : MutableHashMap.size(self.state.map))

/**
 * Retrieves all active keys from the cache, automatically filtering out expired entries.
 *
 * **When to use**
 *
 * Use to inspect currently cached unexpired keys without running cache lookups.
 *
 * **Gotchas**
 *
 * Expired entries are removed and their scopes are closed while filtering.
 *
 * @see {@link entries} for retrieving successful cached key-value pairs
 * @see {@link values} for retrieving only successfully cached values
 *
 * @category combinators
 * @since 4.0.0
 */
export const keys = <Key, A, E, R>(self: ScopedCache<Key, A, E, R>): Effect.Effect<Array<Key>> =>
  core.withFiber((fiber) => {
    if (self.state._tag === "Closed") return effect.succeed([])
    const state = self.state
    const now = fiber.getRef(effect.ClockRef).currentTimeMillisUnsafe()
    const fibers = Arr.empty<Fiber.Fiber<unknown, unknown>>()
    const keys: Array<Key> = []
    for (const [key, entry] of state.map) {
      if (entry.expiresAt === undefined || entry.expiresAt > now) {
        keys.push(key)
      } else {
        MutableHashMap.remove(state.map, key)
        fibers.push(effect.forkUnsafe(fiber, Scope.close(entry.scope, effect.exitVoid), true, true))
      }
    }
    return fibers.length === 0 ? effect.succeed(keys) : effect.as(effect.fiberAwaitAll(fibers), keys)
  })

/**
 * Retrieves all successfully cached values from the cache, excluding failed
 * lookups and expired entries.
 *
 * **When to use**
 *
 * Use to inspect currently successful cached values without running cache
 * lookups.
 *
 * **Gotchas**
 *
 * Expired entries are removed and their scopes are closed while filtering.
 *
 * @see {@link entries} for retrieving successful cached key-value pairs
 * @see {@link keys} for retrieving only cached keys
 *
 * @category combinators
 * @since 4.0.0
 */
export const values = <Key, A, E, R>(self: ScopedCache<Key, A, E, R>): Effect.Effect<Array<A>> =>
  effect.map(entries(self), Arr.map(([, value]) => value))

/**
 * Retrieves all key-value pairs from the cache as an array. This function
 * only returns entries with successfully resolved values, filtering out any
 * failed lookups or expired entries.
 *
 * **When to use**
 *
 * Use to inspect the currently successful cached key-value pairs without
 * running cache lookups.
 *
 * **Gotchas**
 *
 * Expired entries are removed and their scopes are closed while filtering.
 *
 * @see {@link keys} for retrieving only cached keys
 * @see {@link values} for retrieving only cached values
 *
 * @category combinators
 * @since 4.0.0
 */
export const entries = <Key, A, E, R>(self: ScopedCache<Key, A, E, R>): Effect.Effect<Array<[Key, A]>> =>
  core.withFiber((fiber) => {
    if (self.state._tag === "Closed") return effect.succeed([])
    const state = self.state
    const now = fiber.getRef(effect.ClockRef).currentTimeMillisUnsafe()
    const fibers = Arr.empty<Fiber.Fiber<unknown, unknown>>()
    const arr: Array<[Key, A]> = []
    for (const [key, entry] of state.map) {
      if (entry.expiresAt === undefined || entry.expiresAt > now) {
        const exit = entry.deferred.effect
        if (core.isExit(exit) && !effect.exitIsFailure(exit)) {
          arr.push([key, exit.value as A])
        }
      } else {
        MutableHashMap.remove(state.map, key)
        fibers.push(effect.forkUnsafe(fiber, Scope.close(entry.scope, effect.exitVoid), true, true))
      }
    }
    return fibers.length === 0
      ? effect.succeed(arr)
      : effect.as(effect.fiberAwaitAll(fibers), arr)
  })
