/**
 * DecisionCache — accidental-state port for authorization decision caching.
 *
 * Wraps the directCheckCache + ruleResultCache + reverse-indexes
 * + dependencyIndex + TTL settings that the engine uses to short-circuit
 * repeated authorization decisions. Implementing this port gives test
 * code an explicit, dependency-injectable seam — and frees the engine
 * from the rule that "function signature lies" (RF-03 closure).
 *
 * The port exposes ONLY the surface used by the engine:
 *   - getDirect(key) / setDirect(key, result)
 *   - getRule(ruleKey) / setRule(ruleKey, result)
 *   - invalidateByRelation(relation)
 *   - invalidateByNodeKey(nodeKey)        // for NodeManager cache leak fix
 *   - invalidateAll()
 *
 * All TTL / clock concerns move into the port implementation so the
 * AuthorizationChecker no longer reads wall-clock from its hot path.
 *
 * The default `ArbiterDecisionCache` implementation forwards every call
 * to the corresponding field on the Arbiter instance — preserving the
 * existing behavior of every test that inspects `arbiter.directCheckCache`
 * directly while moving the read site out of AuthorizationChecker.
 */
export class DecisionCache {
  /**
   * @param {object} arbiter  The Arbiter instance whose caches we wrap.
   *                          `null` produces a fully inert cache (every
   *                          get returns undefined, every set is a no-op)
   *                          — useful for tests that want to disable caching
   *                          without constructing a full Arbiter.
   * @param {object} [options]
   * @param {(unit?:string) => number} [options.clock]  Time source for TTL
   *                          checks. Defaults to Date.now (ms) but tests
   *                          can inject a fake clock.
   */
  constructor(arbiter, options = {}) {
    this.arbiter = arbiter;
    // Precedence: an explicit cache-level clock wins; else the arbiter's
    // injected clock (options.clock on the Arbiter); else the wall clock.
    this.clock = options.clock || ((arbiter && typeof arbiter.clock === 'function') ? arbiter.clock.bind(arbiter) : (() => Date.now()));
    this._enabled = !!arbiter;
  }

  /** True if this cache can store anything. */
  get enabled() {
    if (!this._enabled) return false;
    const a = this.arbiter;
    return !a.disableCaching;
  }

  /** True if direct (single-step) caching is active. */
  get directEnabled() {
    if (!this.enabled) return false;
    return !!(this.arbiter.directCheckCache) && !this.arbiter.disableDirectCaching;
  }

  /** True if rule-result caching is active. */
  get ruleEnabled() {
    if (!this.enabled) return false;
    return !!this.arbiter.ruleResultCache;
  }

  /**
   * Look up a previously-cached direct-check result.
   * Returns undefined on miss / disabled.
   * Distinguishes "expired" from "miss" via the second tuple element.
   *
   * @returns {[result, status]}  status is 'hit' | 'expired' | 'miss' | 'disabled'
   */
  peekDirect(cacheKey) {
    if (!this.directEnabled) return [undefined, 'disabled'];
    const entry = this.arbiter.directCheckCache.get(cacheKey);
    if (!entry) return [undefined, 'miss'];
    if (this.clock() - entry.timestamp >= this.arbiter.directCheckCacheTTL) {
      return [entry.result, 'expired'];
    }
    return [entry.result, 'hit'];
  }

  /**
   * Simplified form: returns just the result on hit, undefined otherwise.
   */
  getDirect(cacheKey) {
    const [result, status] = this.peekDirect(cacheKey);
    return status === 'hit' ? result : undefined;
  }

  /**
   * Store a direct-check result. Caller is responsible for key construction
   * (so the cache key derivation logic stays in AuthorizationChecker where
   * the schema lives).
   */
  setDirect(cacheKey, result) {
    if (!this.directEnabled) return;
    this.arbiter.directCheckCache.set(cacheKey, {
      result,
      timestamp: this.clock()
    });
  }

  /**
   * Look up a previously-cached rule-evaluation result.
   * Returns undefined on miss / disabled / expired.
   */
  getRule(ruleCacheKey) {
    if (!this.ruleEnabled) return undefined;
    const entry = this.arbiter.ruleResultCache.get(ruleCacheKey);
    if (!entry) return undefined;
    // A graph mutation since the entry was computed invalidates it (stale
    // derived result). _graphVersion increments on every relation mutation.
    if (entry.graphVersion !== undefined && entry.graphVersion !== (this.arbiter._graphVersion ?? 0)) {
      return undefined;
    }
    if (this.clock() - entry.timestamp >= this.arbiter.ruleResultCacheTTL) {
      return undefined;
    }
    return entry.result;
  }

  setRule(ruleCacheKey, result) {
    if (!this.ruleEnabled) return;
    this.arbiter.ruleResultCache.set(ruleCacheKey, {
      result,
      timestamp: this.clock(),
      graphVersion: this.arbiter._graphVersion ?? 0
    });
  }

  /**
   * Mark a cache key as belonging to a particular relation so that
   * future invalidateByRelation(relation) calls can find it.
   */
  trackRuleKeyForRelation(relation, cacheKey) {
    const a = this.arbiter;
    if (!a.ruleResultCache || !cacheKey) return;
    let set = a.ruleResultCacheKeysByRelation.get(relation);
    if (!set) {
      set = new Set();
      a.ruleResultCacheKeysByRelation.set(relation, set);
    }
    set.add(cacheKey);
    a.ruleResultCacheRelationByKey.set(cacheKey, relation);
  }

  /**
   * Invalidate every direct + rule-result cache entry that could
   * have been affected by a relation change. This mirrors the old
   * Arbiter._invalidateDirectCheckCache + invalidateRuleResultCacheByRelation
   * pair, fused into one port-level call.
   */
  invalidateByRelation(relation) {
    const a = this.arbiter;
    if (!a) return;
    if (a.directCheckCache) {
      a._invalidateDirectCheckCache?.(/*srcKey*/ null, relation, /*dstKey*/ null);
    }
    a.invalidateRuleResultCacheByRelation?.(relation);
  }

  /**
   * Invalidate every direct-check entry whose composite key contains
   * a given node id. Replaces the leaky `cacheKey.includes(String(nodeId))`
   * walk that NodeManager used to perform. Now the cache file is the
   * only place that knows its own storage layout.
   *
   * Requires the cache implementation to expose either a key iterator
   * (`keys()`) or a pattern-invalidate method (`invalidateByPattern`).
   * The local @tenere/hyperbolic-lru@1.0.3 provides both. The previous
   * npm hyperbolic-lru@1.0.2 exposed neither, making this method a no-op.
   */
  invalidateByNodeKey(nodeKey) {
    const a = this.arbiter;
    if (!a?.directCheckCache) return;
    const nodeId = a.nodeIdByKey?.get(nodeKey);
    if (nodeId === undefined) return;
    // Keys are rolling hashes (not pipe-delimited strings), so node-level
    // invalidation uses the per-node key index populated at cache-set time.
    a._invalidateDirectCheckCacheByNode?.(nodeId);
  }

  /**
   * Flush every cache. Equivalent to the old
   * `disableCaching = true` test path.
   */
  invalidateAll() {
    const a = this.arbiter;
    if (!a) return;
    a.directCheckCache?.clear?.();
    a.directCheckCacheKeysByRelation?.clear?.();
    a.directCheckCacheKeysByNode?.clear?.();
    a.directKeyToRelations?.clear?.();
    a.invalidateAllRuleResultCache?.();
  }
}

/**
 * NullDecisionCache — every operation is a no-op. Use in tests that want
 * a pure decision flow with no accidental state at all.
 */
export class NullDecisionCache {
  constructor() { this._enabled = true; }
  get enabled() { return false; }
  get directEnabled() { return false; }
  get ruleEnabled() { return false; }
  getDirect() { return undefined; }
  setDirect() {}
  getRule() { return undefined; }
  setRule() {}
  trackRuleKeyForRelation() {}
  invalidateByRelation() {}
  invalidateByNodeKey() {}
  invalidateAll() {}
}

export default DecisionCache;