// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========

import type { PathSpec } from '../../types.ts'

export interface FileCache {
  get(key: string): Promise<Uint8Array | null>
  set(
    key: string,
    data: Uint8Array,
    options?: { fingerprint?: string | null; ttl?: number | null },
  ): Promise<void>
  add(
    key: string,
    data: Uint8Array,
    options?: { fingerprint?: string | null; ttl?: number | null },
  ): Promise<boolean>
  remove(key: string): Promise<void>
  // The path-unknown counterpart to remove(): a mutation that names no
  // path (an account CLI writing to its service by id) cannot say which
  // entries went stale, only which mount's keyspace did. Stores that own
  // their keyspace remotely push the filter down rather than enumerating.
  evictPrefix(prefix: string): Promise<void>
  /**
   * Drop the given keys, synchronously.
   *
   * The load-time counterpart to {@link FileCache.remove}: `fromState`
   * is sync, so it cannot await a per-path removal — and a fire-and-
   * forget `void remove(...)` would let a read issued right after load
   * be served the very snapshot bytes the caller asked to bypass (and
   * would surface a rejection as an unhandled one). Stores whose state
   * cannot be mutated synchronously (redis) implement this as a
   * documented no-op. Mirrors Python `FileCacheMixin.evict_paths`.
   */
  evictPaths(paths: Iterable<string>): void
  exists(key: string | PathSpec): Promise<boolean>
  isFresh(key: string, remoteFingerprint: string): Promise<boolean>
  clear(): Promise<void>
  allCached(keys: readonly string[]): Promise<boolean>
  multiGet(keys: readonly string[]): Promise<(Uint8Array | null)[]>
  // Cached bytes / entry count; null (or absent) for stores that don't
  // track them client-side (e.g. redis owns its own keyspace).
  readonly cacheSize: number | null
  readonly cacheEntries?: number | null
  readonly cacheLimit: number
  maxDrainBytes: number | null
  // Present only on stores that support background drains (mirrors the
  // Python RAM cache's _drain_tasks); applyIo skips draining without it.
  readonly drainTasks?: Map<string, Promise<void>>
}

/**
 * Max bytes a background drain may buffer to fill the cache.
 *
 * null (the default) derives the budget from `cacheLimit`. An explicit
 * value limits speculative background reads further and may not exceed
 * the cache's intended capacity. Mirrors Python `drain_budget`.
 */
export function drainBudget(cache: FileCache): number {
  validateMaxDrainBytes(cache.cacheLimit, cache.maxDrainBytes)
  return cache.maxDrainBytes ?? cache.cacheLimit
}

export function validateMaxDrainBytes(cacheLimit: number, maxDrainBytes: number | null): void {
  if (!Number.isSafeInteger(cacheLimit) || cacheLimit < 0) {
    throw new RangeError('cacheLimit must be a non-negative safe integer')
  }
  if (maxDrainBytes === null) return
  if (!Number.isSafeInteger(maxDrainBytes) || maxDrainBytes < 0) {
    throw new RangeError('maxDrainBytes must be a non-negative safe integer')
  }
  if (maxDrainBytes > cacheLimit) {
    throw new RangeError('maxDrainBytes cannot exceed cacheLimit')
  }
}
