/**
 * decision-cache-port.test.js — unit tests for the DecisionCache port.
 *
 * Verifies:
 *  - Default DecisionCache(arbiter) forwards to arbiter's cache fields
 *  - TTL behavior (via injected clock)
 *  - disableCaching / disableDirectCaching gates are honored
 *  - NullDecisionCache is fully inert
 *  - peekDirect distinguishes hit / expired / miss / disabled
 *  - invalidateByNodeKey removes entries that include the node id
 *  - invalidateByRelation clears dependent rule-result entries
 */
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { DecisionCache, NullDecisionCache } from '../../src/authorization/DecisionCache.js';
import { Arbiter } from '../../src/index.js';

describe('DecisionCache', () => {
  describe('NullDecisionCache', () => {
    it('every operation is a no-op', () => {
      const c = new NullDecisionCache();
      assert.equal(c.enabled, false);
      assert.equal(c.directEnabled, false);
      assert.equal(c.ruleEnabled, false);
      assert.equal(c.getDirect('k'), undefined);
      assert.equal(c.getRule('k'), undefined);
      c.setDirect('k', { x: 1 });
      c.setRule('k', { x: 2 });
      c.invalidateByRelation('rel');
      c.invalidateByNodeKey('node');
      c.invalidateAll();
      // No throws.
      assert.ok(true);
    });
  });

  describe('default DecisionCache(arbiter) forwarding', () => {
    it('reads + writes direct cache through arbiter fields', () => {
      const arbiter = new Arbiter({ embeddingDimensions: 64 });
      const cache = new DecisionCache(arbiter);
      assert.equal(cache.enabled, true);
      assert.equal(cache.directEnabled, true);

      cache.setDirect('key-a', { result: 'A', possibility: 1 });
      const hit = cache.getDirect('key-a');
      assert.deepEqual(hit, { result: 'A', possibility: 1 });
    });

    it('reads + writes rule cache through arbiter fields', () => {
      const arbiter = new Arbiter({ embeddingDimensions: 64 });
      const cache = new DecisionCache(arbiter);
      cache.setRule('rkey-1', { reason: 'allow_rule_matched' });
      const hit = cache.getRule('rkey-1');
      assert.deepEqual(hit, { reason: 'allow_rule_matched' });
    });

    it('honors disableCaching', () => {
      const arbiter = new Arbiter({ embeddingDimensions: 64, disableCaching: true });
      const cache = new DecisionCache(arbiter);
      assert.equal(cache.enabled, false);
      assert.equal(cache.directEnabled, false);
      assert.equal(cache.ruleEnabled, false);
      cache.setDirect('k', { x: 1 });
      assert.equal(cache.getDirect('k'), undefined);
    });

    it('honors disableDirectCaching (but not rule cache)', () => {
      const arbiter = new Arbiter({ embeddingDimensions: 64, disableDirectCaching: true });
      const cache = new DecisionCache(arbiter);
      assert.equal(cache.enabled, true);
      assert.equal(cache.directEnabled, false);
      assert.equal(cache.ruleEnabled, true);
    });
  });

  describe('peekDirect status taxonomy', () => {
    it('returns "miss" on absent key', () => {
      const arbiter = new Arbiter({ embeddingDimensions: 64 });
      const cache = new DecisionCache(arbiter);
      const [result, status] = cache.peekDirect('never-set');
      assert.equal(result, undefined);
      assert.equal(status, 'miss');
    });

    it('returns "hit" within TTL', () => {
      const arbiter = new Arbiter({ embeddingDimensions: 64, directCheckCacheTTL: 60_000 });
      const cache = new DecisionCache(arbiter);
      cache.setDirect('k', { reason: 'allow_rule_matched' });
      const [result, status] = cache.peekDirect('k');
      assert.equal(status, 'hit');
      assert.deepEqual(result, { reason: 'allow_rule_matched' });
    });

    it('returns "expired" past TTL when entry still present', () => {
      let now = 1_000_000;
      const clock = () => now;
      const arbiter = new Arbiter({ embeddingDimensions: 64, directCheckCacheTTL: 1_000 });
      const cache = new DecisionCache(arbiter, { clock });
      cache.setDirect('k', { reason: 'stale' });
      // Advance clock past TTL
      now += 2_000;
      const [result, status] = cache.peekDirect('k');
      assert.equal(status, 'expired');
      assert.deepEqual(result, { reason: 'stale' });
    });

    it('returns "disabled" when caching is off', () => {
      const arbiter = new Arbiter({ embeddingDimensions: 64, disableDirectCaching: true });
      const cache = new DecisionCache(arbiter);
      const [, status] = cache.peekDirect('k');
      assert.equal(status, 'disabled');
    });
  });

describe('invalidateByNodeKey', () => {
    // RF-04 closure — the invalidation now actually works because
    // the upgraded @tenere/hyperbolic-lru@1.0.3 exposes
    // invalidateByPattern(). The port delegates to that method
    // (with a digit-boundary regex to avoid partial-number matches).
    it('removes entries whose composite key contains the node id', () => {
      const arbiter = new Arbiter({ embeddingDimensions: 64 });
      const cache = new DecisionCache(arbiter);
      arbiter.addNode('user:alice', 'user');
      arbiter.addNode('user:bob', 'user');
      arbiter.addNode('user:carol', 'user');
      const aliceId = arbiter.resolveNodeId('user:alice');
      const bobId = arbiter.resolveNodeId('user:bob');
      const carolId = arbiter.resolveNodeId('user:carol');

      // Keys are rolling hashes; node-level invalidation uses the per-node
      // key index, so every injected key must be tracked.
      const keyAB = arbiter.keyManager.createCompositeKey(aliceId, 'member_of', bobId);
      const keyBA = arbiter.keyManager.createCompositeKey(bobId, 'member_of', aliceId);
      const keyCB = arbiter.keyManager.createCompositeKey(carolId, 'member_of', bobId);
      cache.setDirect(keyAB, { reason: 'a-b' });
      arbiter._trackDirectCheckKey('member_of', aliceId, bobId, keyAB);
      cache.setDirect(keyBA, { reason: 'b-a' });
      arbiter._trackDirectCheckKey('member_of', bobId, aliceId, keyBA);
      cache.setDirect(keyCB, { reason: 'c-b' });
      arbiter._trackDirectCheckKey('member_of', carolId, bobId, keyCB);
      cache.setDirect('unrelated-key', { reason: 'u' });

      cache.invalidateByNodeKey('user:alice');

      // Both alice-involving entries cleared; the others remain.
      assert.equal(cache.getDirect(keyAB), undefined);
      assert.equal(cache.getDirect(keyBA), undefined);
      assert.ok(cache.getDirect(keyCB));
      assert.ok(cache.getDirect('unrelated-key'));
    });
  });

  describe('invalidateByRelation', () => {
    it('clears rule-result entries tracked for the relation', () => {
      const arbiter = new Arbiter({ embeddingDimensions: 64 });
      const cache = new DecisionCache(arbiter);
      cache.setRule('rkey-a', { possibility: 1 });
      cache.trackRuleKeyForRelation('member_of', 'rkey-a');

      cache.invalidateByRelation('member_of');

      assert.equal(cache.getRule('rkey-a'), undefined);
    });
  });

  describe('DecisionCache is wired into Arbiter', () => {
    it('arbiter.decisionCache is a DecisionCache instance', () => {
      const arbiter = new Arbiter({ embeddingDimensions: 64 });
      assert.ok(arbiter.decisionCache);
      assert.ok(arbiter.decisionCache instanceof DecisionCache);
    });

    it('AuthorizationChecker receives the same DecisionCache', () => {
      const arbiter = new Arbiter({ embeddingDimensions: 64 });
      assert.equal(arbiter.authChecker.decisionCache, arbiter.decisionCache);
    });

    it('NodeManager receives the same DecisionCache', () => {
      const arbiter = new Arbiter({ embeddingDimensions: 64 });
      assert.equal(arbiter.nodeManager.decisionCache, arbiter.decisionCache);
    });

    it('Arbiter accepts an injected DecisionCache (NullDecisionCache for testing)', () => {
      const cache = new NullDecisionCache();
      const arbiter = new Arbiter({ embeddingDimensions: 64, decisionCache: cache });
      assert.equal(arbiter.decisionCache, cache);
      assert.equal(arbiter.authChecker.decisionCache, cache);
      assert.equal(arbiter.nodeManager.decisionCache, cache);
    });

    it('NodeManager.updateNodeData invalidates via the port when given a nodeId-bearing key', () => {
      const arbiter = new Arbiter({ embeddingDimensions: 64 });
      arbiter.addNode('user:alice', 'user');
      arbiter.addNode('user:bob', 'user');
      const aliceId = arbiter.resolveNodeId('user:alice');
      const bobId = arbiter.resolveNodeId('user:bob');
      // Pre-populate cache with keys touching alice (tracked so node-level
      // invalidation can find them)
      const k1 = arbiter.keyManager.createCompositeKey(aliceId, 'member_of', bobId);
      const k2 = arbiter.keyManager.createCompositeKey(bobId, 'member_of', aliceId);
      const k3 = arbiter.keyManager.createCompositeKey(aliceId, 'other', bobId);
      arbiter.decisionCache.setDirect(k1, { reason: 'stale' });
      arbiter._trackDirectCheckKey('member_of', aliceId, bobId, k1);
      arbiter.decisionCache.setDirect(k2, { reason: 'stale' });
      arbiter._trackDirectCheckKey('member_of', bobId, aliceId, k2);
      arbiter.decisionCache.setDirect(k3, { reason: 'stale' });
      arbiter._trackDirectCheckKey('other', aliceId, bobId, k3);
      arbiter.decisionCache.setDirect('unrelated', { reason: 'keep' });

      // Trigger invalidation through the NodeManager path
      arbiter.nodeManager.updateNodeData('user:alice', { foo: 'bar' });

      // All alice-bearing keys should be gone; unrelated key remains.
      assert.equal(arbiter.decisionCache.getDirect(k1), undefined);
      assert.equal(arbiter.decisionCache.getDirect(k2), undefined);
      assert.equal(arbiter.decisionCache.getDirect(k3), undefined);
      assert.ok(arbiter.decisionCache.getDirect('unrelated'));
    });
  });
});