/**
 * Cross-movement tool result cache.
 *
 * Lives for the duration of a single piece run. The first movement that calls
 * a cacheable tool with a given key stores the result; later movements that
 * issue the same call get the cached body back wrapped in a header that names
 * the original observer movement. The intent is to stop investigate→plan→
 * execute pipelines from re-fetching the same observations purely to refill
 * their own context.
 *
 * Phases (see docs/plans/2026-05-01-workspace-memory.md):
 *   1 — Read cache, no invalidation
 *   2 — Edit/Write/Bash invalidation
 *   3 — Structured WorkspaceMemory mirror
 *   4 — Extended to Grep / Glob / WebFetch / Office tools via volatility
 */

/**
 * Cache volatility class — drives which invalidation events apply to an
 * entry. Set per tool by the cache router.
 *
 *   'file'   — bound to specific workspace files (Read, Office tools).
 *              Evicted by `invalidatePath(p)` if `touchedPaths.includes(p)`,
 *              or by `invalidateAllFiles()`.
 *   'search' — bound to a directory scope but enumerating which files matter
 *              is impractical (Grep, Glob). Evicted by ANY `invalidatePath`
 *              or `invalidateAllFiles` call — the safer hammer.
 *   'url'    — independent of workspace state (WebFetch). Never auto-evicted.
 */
export type CacheVolatility = 'file' | 'search' | 'url';

export interface ToolCacheEntry {
  key: string;
  toolName: string;
  resultText: string;
  createdAt: string;       // ISO 8601
  sourceMovement: string;
  /**
   * Workspace paths whose state this entry depends on.
   * - 'file' entries: the specific files Read consumed.
   * - 'search' entries: the search-scope hint (path arg or workspace root);
   *   invalidation is broader than this list.
   * - 'url' entries: empty.
   */
  touchedPaths: string[];
  volatility: CacheVolatility;
}

export class ToolResultCache {
  private readonly entries = new Map<string, ToolCacheEntry>();

  get(key: string): ToolCacheEntry | undefined {
    return this.entries.get(key);
  }

  has(key: string): boolean {
    return this.entries.has(key);
  }

  /** First write wins so the original sourceMovement / createdAt are preserved. */
  set(entry: ToolCacheEntry): void {
    if (!this.entries.has(entry.key)) {
      this.entries.set(entry.key, entry);
    }
  }

  size(): number {
    return this.entries.size;
  }

  /**
   * Drop entries affected by a single-path mutation:
   *   - 'file' entry with touchedPaths.includes(path) → evicted
   *   - 'search' entry → evicted unconditionally (we can't reason about
   *     whether the mutated path was inside the search scope cheaply)
   *   - 'url' entry → kept
   * Returns the eviction count.
   */
  invalidatePath(path: string): number {
    let evicted = 0;
    for (const [key, entry] of this.entries) {
      const evict = entry.volatility === 'search'
        || (entry.volatility === 'file' && entry.touchedPaths.includes(path));
      if (evict) {
        this.entries.delete(key);
        evicted++;
      }
    }
    return evicted;
  }

  /**
   * Drop every entry whose result depends on workspace state — both 'file'
   * and 'search' entries. 'url' entries survive. Used after Bash, which can
   * mutate anything we cannot enumerate.
   */
  invalidateAllFiles(): number {
    let evicted = 0;
    for (const [key, entry] of this.entries) {
      if (entry.volatility === 'file' || entry.volatility === 'search') {
        this.entries.delete(key);
        evicted++;
      }
    }
    return evicted;
  }

  /** Format a cache hit so the LLM can see the result is recycled. */
  static formatHit(entry: ToolCacheEntry, displayLabel: string): string {
    return `[cached: ${displayLabel} from movement ${entry.sourceMovement} at ${entry.createdAt}]\n${entry.resultText}`;
  }
}
