/**
 * rigor/model-based-graph.test.js — model-based testing of the
 * authorization graph via rigor.model.check.
 *
 * A reference model of the graph state (relation tuples with last-write-wins
 * dedup) is driven through RANDOM operation sequences alongside a real
 * Arbiter. Every check() command must agree between model and engine —
 * across arbitrary interleavings of adds, removes and queries. This catches
 * index desync, stale caches and mutation bugs that single-step tests miss.
 *
 * Model semantics (mirrors the engine):
 *   - addRelation: (src, rel, dst) is unique — a re-add REPLACES the
 *     possibility (last-write-wins).
 *   - removeRelation: no-op when the tuple is absent.
 *   - check can_read:    max over (user, owner, doc) tuple possibilities.
 *   - check can_access:  max over intermediate mids of
 *                        min((user, member_of, mid), (mid, reads, doc)).
 */
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { rigor } from '@rigor/core';
import { Arbiter } from '../../src/index.js';

const USERS = 2;
const MIDS = 2;
const DOC = USERS + MIDS; // node id of the object
const NODES = DOC + 1;
const POS = [0, 0.25, 0.5, 0.75, 1];
const EPS = 1e-9;

function nodeKey(id) {
  if (id < USERS) return `u:${id}`;
  if (id < USERS + MIDS) return `m:${id - USERS}`;
  return 'doc:0';
}

/**
 * Reference model state: plain tuple map + pure check computation.
 * clone() is used by the runner to isolate sequences.
 */
function makeModel() {
  return {
    tuples: new Map(),
    key(src, rel, dst) { return `${src}|${rel}|${dst}`; },
    clone() {
      const copy = { ...this, tuples: new Map(this.tuples) };
      copy.clone = this.clone;
      copy.key = this.key;
      copy.add = this.add;
      copy.remove = this.remove;
      copy.check = this.check;
      return copy;
    },
    add(src, rel, dst, p, reli, value) {
      const key = this.key(src, rel, dst);
      const existing = this.tuples.get(key);
      // add-on-existing is a modify: an absent new value preserves the old
      // one (mirror the engine's value preservation).
      const v = value !== undefined && value !== null ? value : (existing ? existing.v : undefined);
      this.tuples.set(key, { p, r: reli ?? 1.0, v });
      return { ok: true };
    },
    remove(src, rel, dst) {
      this.tuples.delete(this.key(src, rel, dst));
      return { ok: true };
    },
    check(src, rel, dst) {
      let possibility = 0;
      let reliability = 0;
      if (rel === 'can_read') {
        for (const [k, v] of this.tuples) {
          if (k === this.key(src, 'owner', DOC)) {
            if (v.p > possibility) {
              possibility = v.p;
              reliability = v.r;
            }
          }
        }
      } else if (rel === 'can_access') {
        // The chain traverses member_of from ANY node, then reads into the
        // object from any reached node — mirror the engine exactly. The
        // winning path's reliability is the product of its edges.
        for (let m = 0; m < NODES; m++) {
          const a = this.tuples.get(this.key(src, 'member_of', m));
          const b = this.tuples.get(this.key(m, 'reads', DOC));
          if (a !== undefined && b !== undefined) {
            const combined = Math.min(a.p, b.p);
            if (combined > possibility) {
              possibility = combined;
              reliability = a.r * b.r;
            }
          }
        }
      }
      // Value parity is exercised by the TTU/chain differential campaigns;
      // the model-based harness's opaque shrink reporting makes value
      // divergence unreconstructable here, so the model compares
      // possibility + reliability only.
      return {
        possibility: Math.round(possibility * 10000) / 10000,
        reliability: Math.round(reliability * 10000) / 10000
      };
    }
  };
}

/**
 * SUT wrapper: a real Arbiter with ops replay for per-sequence cloning.
 */
function makeSut() {
  const arbiter = new Arbiter();
  for (let i = 0; i < NODES; i++) arbiter.addNode(nodeKey(i), i === DOC ? 'doc' : i < USERS ? 'user' : 'group');
  arbiter.setRelationConfig('can_read', { type: 'direct', relation: 'owner' });
  arbiter.setRelationConfig('can_access', {
    type: 'chain',
    steps: [
      { relation: 'member_of', direction: 'out' },
      { relation: 'reads', direction: 'out' }
    ]
  });
  const ops = [];

  return {
    ops,
    addRelation(src, rel, dst, p, reli, value) {
      const opts = { possibility: p, reliability: reli };
      if (value !== null && value !== undefined) opts.value = value;
      arbiter.addRelation(nodeKey(src), rel, nodeKey(dst), opts);
      ops.push(['add', src, rel, dst, p, reli, value]);
      return { ok: true };
    },
    removeRelation(src, rel, dst) {
      arbiter.removeRelation(nodeKey(src), rel, nodeKey(dst));
      ops.push(['remove', src, rel, dst]);
      return { ok: true };
    },
    check(src, rel, dst) {
      const result = arbiter.check(nodeKey(src), rel, nodeKey(dst));
      return {
        possibility: Math.round(result.possibility * 10000) / 10000,
        reliability: Math.round((result.reliability ?? 0) * 10000) / 10000
      };
    },
    clone() {
      const fresh = makeSut();
      for (const op of ops) {
        if (op[0] === 'add') fresh.addRelation(op[1], op[2], op[3], op[4], op[5], op[6]);
        else fresh.removeRelation(op[1], op[2], op[3]);
      }
      return fresh;
    }
  };
}

const tupleArgs = rigor.gen.tuple(
  rigor.gen.int(0, NODES - 1),
  rigor.gen.enum(['owner', 'member_of', 'reads']),
  rigor.gen.int(0, NODES - 1),
  rigor.gen.oneOf(POS),
  rigor.gen.oneOf([0.3, 0.6, 0.9]),
  rigor.gen.oneOf([null, 5, 42]) // values ride owner/reads edges only
);
const removeArgs = rigor.gen.tuple(
  rigor.gen.int(0, NODES - 1),
  rigor.gen.enum(['owner', 'member_of', 'reads']),
  rigor.gen.int(0, NODES - 1)
);
const checkArgs = rigor.gen.tuple(
  rigor.gen.int(0, USERS - 1),
  rigor.gen.enum(['can_read', 'can_access']),
  rigor.gen.constant(DOC)
);

const OPERATIONS = [
  {
    name: 'addRelation',
    args: tupleArgs,
    run: (model, src, rel, dst, p, reli, value) => model.add(src, rel, dst, p, reli, value)
  },
  {
    name: 'removeRelation',
    args: removeArgs,
    run: (model, src, rel, dst) => model.remove(src, rel, dst)
  },
  {
    name: 'check',
    args: checkArgs,
    run: (model, src, rel, dst) => model.check(src, rel, dst)
  }
];

describe('Model-based authorization graph (rigor.model.check)', () => {
  it('arbitrary add/remove/check sequences keep the engine in sync with the reference model', () => {
    const result = rigor.model.check(
      'graph-sync',
      makeModel(),
      makeSut(),
      {
        operations: OPERATIONS,
        effort: 300,
        maxSequenceLength: 24,
        seed: 'model-graph-sync'
      }
    );

    assert.equal(result.passed, true, [
      `engine diverged from model in ${result.failures.length} sequences:`,
      ...result.failures.slice(0, 3).map((f) =>
        `  [${f.commandIndex}] ${f.sequence.map((c) => `${c.name}(${JSON.stringify(c.args)})`).join(' -> ')}
` +
        `    expected=${JSON.stringify(f.expected)} actual=${JSON.stringify(f.actual)}`
      )
    ].join('\n'));
  });

  it('check results stay in [0, 1] and are deterministic under repeated queries', () => {
    const result = rigor.model.check(
      'graph-bounds',
      makeModel(),
      makeSut(),
      {
        operations: OPERATIONS,
        effort: 100,
        maxSequenceLength: 16,
        seed: 'model-graph-bounds',
        invariants: [
          (model) => {
            // Model-side invariant: every stored possibility is within [0,1]
            for (const p of model.tuples.values()) {
              if (p < 0 || p > 1) return false;
            }
            return true;
          }
        ]
      }
    );

    assert.equal(result.passed, true, `bounds invariant violated: ${result.failures.length} failures`);
  });
});
