/** Backend stores opaque strings only — never sees T, never parses JSON. */
export interface CacheBackend {
  get(key: string): Promise<string | undefined>
  set(key: string, value: string, ttlSeconds?: number): Promise<void>
  del(key: string): Promise<void>
}

export interface SetOptions {
  /** Whole seconds. */
  ttl?: number
}

/** The typed value cache, layered above any backend. */
export interface CacheStore {
  get<T>(key: string): Promise<T | undefined>
  set<T>(key: string, value: T | undefined, opts?: SetOptions): Promise<void>
  del(key: string): Promise<void>
  getOrSet<T>(key: string, loader: () => Promise<T>, opts?: SetOptions): Promise<T>
}

/**
 * Thin prefix builder. Bump the version segment to bulk-invalidate a keyspace
 * (e.g. `namespace('tsi:v1')` → change to `tsi:v2` and old keys miss).
 */
export function namespace(prefix: string): (key: string) => string {
  return (key: string) => `${prefix}:${key}`
}

export function createCache(backend: CacheBackend): CacheStore {
  const inFlight = new Map<string, Promise<unknown>>()

  return {
    async get<T>(key: string): Promise<T | undefined> {
      const raw = await backend.get(key)
      if (raw === undefined) return undefined
      try {
        return JSON.parse(raw) as T
      } catch {
        return undefined
      }
    },

    async set<T>(key: string, value: T | undefined, opts?: SetOptions): Promise<void> {
      if (value === undefined) {
        await backend.del(key)
        return
      }
      await backend.set(key, JSON.stringify(value), opts?.ttl)
    },

    async del(key: string): Promise<void> {
      await backend.del(key)
    },

    /**
     * Read-through with per-instance single-flight coalescing. Concurrent cold
     * callers on the same key share one loader invocation. In-flight entries are
     * cleared in `finally` so a rejected loader does not poison the key.
     *
     * Coalescing is per runtime instance only — not across isolates or processes.
     */
    async getOrSet<T>(
      key: string,
      loader: () => Promise<T>,
      opts?: SetOptions,
    ): Promise<T> {
      const existing = inFlight.get(key) as Promise<T> | undefined
      if (existing) return existing

      const cached = await this.get<T>(key)
      if (cached !== undefined) return cached

      const racing = inFlight.get(key) as Promise<T> | undefined
      if (racing) return racing

      const flight = (async () => {
        try {
          const value = await loader()
          await this.set(key, value, opts)
          return value
        } finally {
          inFlight.delete(key)
        }
      })()

      inFlight.set(key, flight)
      return flight
    },
  }
}
