/**
 * @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"

/**
 * @since 4.0.0
 * @category Models
 */
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
}

/**
 * @since 4.0.0
 * @category Models
 */
export type State<K, A, E> = {
  readonly _tag: "Open"
  readonly map: MutableHashMap.MutableHashMap<K, Entry<A, E>>
} | {
  readonly _tag: "Closed"
}

/**
 * Represents a cache entry containing a deferred value and optional expiration time.
 * This is used internally by the cache implementation to track cached values and their lifetimes.
 *
 * @since 4.0.0
 * @category Models
 */
export interface Entry<A, E> {
  expiresAt: number | undefined
  readonly deferred: Deferred.Deferred<A, E>
  readonly scope: Scope.Closeable
}

/**
 * @since 4.0.0
 * @category Constructors
 */
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
    )
  })

/**
 * @since 4.0.0
 * @category Constructors
 */
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

/**
 * @since 4.0.0
 * @category Combinators
 */
export const get: {
  /**
   * @since 4.0.0
   * @category Combinators
   */
  <Key, A>(key: Key): <E, R>(self: ScopedCache<Key, A, E, R>) => Effect.Effect<A, E, R>
  /**
   * @since 4.0.0
   * @category Combinators
   */
  <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</**
         * @since 4.0.0
         * @category Combinators
         */
        A, /**
         * @since 4.0.0
         * @category Combinators
         */
        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)
}

/**
 * @since 4.0.0
 * @category Combinators
 */
export const getOption: {
  /**
   * @since 4.0.0
   * @category Combinators
   */
  <Key, A>(key: Key): <E, R>(self: ScopedCache<Key, A, E, R>) => Effect.Effect<Option.Option<A>, E>
  /**
   * @since 4.0.0
   * @category Combinators
   */
  <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.
 *
 * @since 4.0.0
 * @category Combinators
 */
export const getSuccess: {
  /**
   * Retrieves the value associated with the specified key from the cache, only if
   * it contains a resolved successful value.
   *
   * @since 4.0.0
   * @category Combinators
   */
  <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.
   *
   * @since 4.0.0
   * @category Combinators
   */
  <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 the value associated with the specified key in the cache. This will
 * overwrite any existing value for that key, skipping the lookup function.
 *
 * @since 4.0.0
 * @category Combinators
 */
export const set: {
  /**
   * Sets the value associated with the specified key in the cache. This will
   * overwrite any existing value for that key, skipping the lookup function.
   *
   * @since 4.0.0
   * @category Combinators
   */
  <Key, A>(key: Key, value: A): <E, R>(self: ScopedCache<Key, A, E, R>) => Effect.Effect<void>
  /**
   * Sets the value associated with the specified key in the cache. This will
   * overwrite any existing value for that key, skipping the lookup function.
   *
   * @since 4.0.0
   * @category Combinators
   */
  <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 the value associated with the specified key in the cache. This will
         * overwrite any existing value for that key, skipping the lookup function.
         *
         * @since 4.0.0
         * @category Combinators
         */
        A, /**
         * Sets the value associated with the specified key in the cache. This will
         * overwrite any existing value for that key, skipping the lookup function.
         *
         * @since 4.0.0
         * @category Combinators
         */
        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 if the cache contains an entry for the specified key.
 *
 * @since 4.0.0
 * @category Combinators
 */
export const has: {
  /**
   * Checks if the cache contains an entry for the specified key.
   *
   * @since 4.0.0
   * @category Combinators
   */
  <Key, A>(key: Key): <E, R>(self: ScopedCache<Key, A, E, R>) => Effect.Effect<boolean>
  /**
   * Checks if the cache contains an entry for the specified key.
   *
   * @since 4.0.0
   * @category Combinators
   */
  <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))
    )
)

/**
 * Invalidates the entry associated with the specified key in the cache.
 *
 * @since 4.0.0
 * @category Combinators
 */
export const invalidate: {
  /**
   * Invalidates the entry associated with the specified key in the cache.
   *
   * @since 4.0.0
   * @category Combinators
   */
  <Key, A>(key: Key): <E, R>(self: ScopedCache<Key, A, E, R>) => Effect.Effect<void>
  /**
   * Invalidates the entry associated with the specified key in the cache.
   *
   * @since 4.0.0
   * @category Combinators
   */
  <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)
    })
  ))

/**
 * Conditionally invalidates the entry associated with the specified key in the cache
 * if the predicate returns true for the cached value.
 *
 * @since 4.0.0
 * @category Combinators
 */
export const invalidateWhen: {
  /**
   * Conditionally invalidates the entry associated with the specified key in the cache
   * if the predicate returns true for the cached value.
   *
   * @since 4.0.0
   * @category Combinators
   */
  <Key, A>(key: Key, f: Predicate.Predicate<A>): <E, R>(self: ScopedCache<Key, A, E, R>) => Effect.Effect<boolean>
  /**
   * Conditionally invalidates the entry associated with the specified key in the cache
   * if the predicate returns true for the cached value.
   *
   * @since 4.0.0
   * @category Combinators
   */
  <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.
 *
 * It will always invoke the lookup function to construct a new value,
 * overwriting any existing value for that key.
 *
 * @since 4.0.0
 * @category Combinators
 */
export const refresh: {
  /**
   * Forces a refresh of the value associated with the specified key in the cache.
   *
   * It will always invoke the lookup function to construct a new value,
   * overwriting any existing value for that key.
   *
   * @since 4.0.0
   * @category Combinators
   */
  <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.
   *
   * It will always invoke the lookup function to construct a new value,
   * overwriting any existing value for that key.
   *
   * @since 4.0.0
   * @category Combinators
   */
  <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.
       *
       * It will always invoke the lookup function to construct a new value,
       * overwriting any existing value for that key.
       *
       * @since 4.0.0
       * @category Combinators
       */
      A, /**
       * Forces a refresh of the value associated with the specified key in the cache.
       *
       * It will always invoke the lookup function to construct a new value,
       * overwriting any existing value for that key.
       *
       * @since 4.0.0
       * @category Combinators
       */
      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
    }))
)

/**
 * Invalidates all entries in the cache.
 *
 * @since 4.0.0
 * @category Combinators
 */
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.
 *
 * 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.
 *
 * @since 4.0.0
 * @category Combinators
 */
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.
 *
 * @since 4.0.0
 * @category Combinators
 */
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.
 *
 * @since 4.0.0
 * @category Combinators
 */
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 iterable. This function
 * only returns entries with successfully resolved values, filtering out any
 * failed lookups or expired entries.
 *
 * @since 4.0.0
 * @category Combinators
 */
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)
  })
