/**
 * rigor/batch-loading-parity.test.js — js-rigor property tests for
 * batch construction consistency.
 *
 * Properties verified:
 *
 *   - BATCH PARITY: the same random graph loaded via addRelationsBatch
 *     answers check() IDENTICALLY to the same graph loaded relation by
 *     relation (both for direct and chain configs).
 *   - BATCH DEDUP: duplicate tuples inside a batch honor last-write-wins
 *     exactly like sequential re-adds (the final possibility is the last
 *     one, regardless of order).
 *   - BATCH + MUTATION: after batch loading, subsequent single mutations
 *     (add/remove) behave exactly as on the sequentially-built arbiter.
 */
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { rigor } from '@rigor/core';
import { Arbiter } from '../../src/index.js';

const EPS = 1e-9;
const POS = [0, 0.25, 0.5, 0.75, 1];

function fail(message) {
  throw new Error(message);
}

function buildBase(users, mids) {
  const arbiter = new Arbiter({ fastConstructionMode: true });
  for (let i = 0; i < users; i++) arbiter.addNode(`user:${i}`, 'user');
  for (let i = 0; i < mids; i++) arbiter.addNode(`mid:${i}`, 'group');
  arbiter.addNode('doc:1', 'doc');
  arbiter.setRelationConfig('member_of', { type: 'direct' });
  arbiter.setRelationConfig('viewer', { type: 'direct' });
  arbiter.setRelationConfig('can_read', { type: 'direct', relation: 'viewer' });
  arbiter.setRelationConfig('can_access', {
    type: 'chain',
    steps: [
      { relation: 'member_of', direction: 'out' },
      { relation: 'viewer', direction: 'out' }
    ]
  });
  return arbiter;
}

function randomEdges(users, mids) {
  const edges = [];
  const userKeys = Array.from({ length: users }, (_, i) => `user:${i}`);
  const midKeys = Array.from({ length: mids }, (_, i) => `mid:${i}`);
  const pick = (arr) => arr[Math.floor(Math.random() * arr.length)];
  const count = Math.max(2, users + mids);
  for (let i = 0; i < count; i++) {
    const kind = Math.floor(Math.random() * 3);
    if (kind === 0) {
      edges.push({ srcKey: pick(userKeys), relation: 'viewer', dstKey: 'doc:1', options: { possibility: pick(POS) } });
    } else if (kind === 1 && mids > 0) {
      edges.push({ srcKey: pick(userKeys), relation: 'member_of', dstKey: pick(midKeys), options: { possibility: pick(POS) } });
    } else if (mids > 0) {
      edges.push({ srcKey: pick(midKeys), relation: 'viewer', dstKey: 'doc:1', options: { possibility: pick(POS) } });
    }
  }
  // Fast-construction sequential adds skip duplicate detection (bulk-loading
  // contract: callers supply distinct tuples). Dedup so both loading paths
  // see identical state — last-write-wins on the tuple.
  const seen = new Set();
  const deduped = [];
  for (const e of edges) {
    const key = `${e.srcKey}|${e.relation}|${e.dstKey}`;
    if (seen.has(key)) continue;
    seen.add(key);
    deduped.push(e);
  }
  return deduped;
}

function applyEdgesSequential(arbiter, edges) {
  for (const e of edges) {
    arbiter.addRelation(e.srcKey, e.relation, e.dstKey, e.options);
  }
}

function allChecks(arbiter, users) {
  const results = {};
  for (let i = 0; i < users; i++) {
    results[`u${i}`] = {
      read: arbiter.check(`user:${i}`, 'can_read', 'doc:1').possibility,
      access: arbiter.check(`user:${i}`, 'can_access', 'doc:1').possibility
    };
  }
  return results;
}

describe('Batch loading consistency (rigor)', () => {
  it('BATCH PARITY: batch-loaded graphs answer checks identically to sequential loading', async () => {
    async function check(seedCase) {
      const { users, mids, includeDupes } = seedCase;
      let edges = randomEdges(users, mids);
      if (includeDupes && edges.length > 0) {
        // Duplicate one edge with a different possibility (last-write-wins)
        const dup = { ...edges[0] };
        dup.options = { possibility: POS[Math.floor(Math.random() * POS.length)] };
        edges = [...edges, dup];
      }

      const batched = buildBase(users, mids);
      batched.relationManager.addRelationsBatch(edges);

      const sequential = buildBase(users, mids);
      applyEdgesSequential(sequential, edges);

      const b = allChecks(batched, users);
      const s = allChecks(sequential, users);
      for (const key of Object.keys(b)) {
        if (Math.abs(b[key].read - s[key].read) > EPS || Math.abs(b[key].access - s[key].access) > EPS) {
          fail(`batch parity ${key}: batch=${JSON.stringify(b[key])}, seq=${JSON.stringify(s[key])}`);
        }
      }
      return { edges: edges.length };
    }

    const report = await rigor.campaign(
      [
        rigor.fn('check', check, rigor.args(
          rigor.gen.object({
            users: rigor.gen.int(1, 4),
            mids: rigor.gen.int(0, 4),
            includeDupes: rigor.gen.boolean()
          })
        ))
      ],
      rigor.crucible([
        rigor.invariant('batch-parity', ({ actual }) => actual !== undefined)
      ])
    ).run({ effort: 400, seed: 'batch-parity' , artifacts: { dir: '', persist: 'never' }});

    const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'batch-parity');
    assert.ok(inv);
    assert.equal(inv.passed, true, `BATCH PARITY violated in ${inv.failureCount} cases`);
  });

  it('BATCH + MUTATION: post-batch mutations behave like post-sequential mutations', async () => {
    async function check(seedCase) {
      const { users, mids, removeRel } = seedCase;
      const edges = randomEdges(users, mids);

      const batched = buildBase(users, mids);
      batched.relationManager.addRelationsBatch(edges);
      const sequential = buildBase(users, mids);
      applyEdgesSequential(sequential, edges);

      // Same mutation on both: remove every edge of one relation kind
      for (const e of edges) {
        if (e.relation === removeRel) {
          batched.removeRelation(e.srcKey, e.relation, e.dstKey);
          sequential.removeRelation(e.srcKey, e.relation, e.dstKey);
        }
      }

      const b = allChecks(batched, users);
      const s = allChecks(sequential, users);
      for (const key of Object.keys(b)) {
        if (Math.abs(b[key].read - s[key].read) > EPS || Math.abs(b[key].access - s[key].access) > EPS) {
          fail(`post-mutation parity ${key}: batch=${JSON.stringify(b[key])}, seq=${JSON.stringify(s[key])}`);
        }
      }
      return { removed: removeRel };
    }

    const report = await rigor.campaign(
      [
        rigor.fn('check', check, rigor.args(
          rigor.gen.object({
            users: rigor.gen.int(1, 4),
            mids: rigor.gen.int(0, 4),
            removeRel: rigor.gen.oneOf(['viewer', 'member_of'])
          })
        ))
      ],
      rigor.crucible([
        rigor.invariant('batch-mutation-parity', ({ actual }) => actual !== undefined)
      ])
    ).run({ effort: 400, seed: 'batch-mutation-parity' , artifacts: { dir: '', persist: 'never' }});

    const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'batch-mutation-parity');
    assert.ok(inv);
    assert.equal(inv.passed, true, `BATCH+MUTATION violated in ${inv.failureCount} cases`);
  });
});
