import { test } from 'node:test';
import assert from 'node:assert/strict';
import { Arbiter } from '../../src/index.js';

const REASONS = new Set([
  'direct_match', 'no_relation', 'threshold_not_met', 'missing_node',
  'no_config', 'cycle', 'deny', 'insufficient_confidence'
]);
const BINARY_REASONS = new Set([
  'allow', 'deny', 'insufficient_confidence', 'cycle', 'missing_node', 'no_config'
]);
const VALIDITY_LABELS = ['finite_sample', 'anytime', 'conformal', 'approximate', 'heuristic', 'unknown'];

function validNumber(x) {
  return typeof x === 'number' && Number.isFinite(x) && x >= 0 && x <= 1;
}

function validValidityBlock(v, includeMeta) {
  assert.ok(v && typeof v === 'object', 'validity must be an object');
  assert.ok(VALIDITY_LABELS.includes(v.label), `validity.label in enum, got ${v.label}`);
  assert.equal(typeof v.operator, 'string');
  assert.equal(typeof v.regime, 'string');
  if (includeMeta) {
    assert.equal(typeof v.conflictMass, 'number', 'full validity carries conflictMass');
    assert.equal(typeof v.nonMaxitive, 'boolean', 'full validity carries nonMaxitive');
    assert.ok(Array.isArray(v.sources), 'full validity carries sources');
  }
}

function assertCanonical(result, { includeMeta = false, binary = false } = {}) {
  assert.ok(result && typeof result === 'object', 'result must be an object');
  assert.ok(validNumber(result.possibility), `possibility in [0,1], got ${result.possibility}`);
  assert.ok(
    binary ? BINARY_REASONS.has(result.reason) : REASONS.has(result.reason),
    `reason in enum, got ${result.reason}`
  );
  assert.ok(validNumber(result.reliability), `reliability in [0,1], got ${result.reliability}`);
  validValidityBlock(result.validity, includeMeta);
  if (result.possibility === 0) {
    assert.equal(result.reliability, 0, 'denied decision leaks no reliability');
  }
  if (binary) {
    assert.equal(result.binary, true, 'binary mode marks the result');
  }
  // Result must be JSON-serializable (no circulars / no functions).
  const roundTrip = JSON.parse(JSON.stringify(result));
  assert.deepEqual(roundTrip, JSON.parse(JSON.stringify(roundTrip)), 'result round-trips');
  // No enumerable key may be undefined.
  for (const [k, v] of Object.entries(result)) {
    if (v !== undefined) continue;
    assert.fail(`result.${k} is undefined`);
  }
}

function makeEngine() {
  const arbiter = new Arbiter();
  arbiter.addNode('u:1', 'user');
  arbiter.addNode('u:2', 'user');
  arbiter.addNode('g:2', 'group');
  arbiter.addNode('doc:9', 'doc');
  arbiter.setRelationConfig('owner', { type: 'direct' });
  arbiter.setRelationConfig('member_of', { type: 'direct' });
  arbiter.addRelation('u:1', 'owner', 'doc:9', { possibility: 0.9 });
  arbiter.addRelation('u:1', 'member_of', 'g:2', { possibility: 0.7 });
  arbiter.addRelation('u:2', 'member_of', 'g:2', { possibility: 0.8 });
  arbiter.addRelation('g:2', 'owner', 'doc:9', { possibility: 1.0 });
  return arbiter;
}

test('contract: every outcome class carries the canonical result shape', () => {
  const a = makeEngine();
  const outcomes = {
    allowed: () => a.check('u:1', 'owner', 'doc:9'),
    denied_no_relation: () => a.check('u:2', 'owner', 'doc:9'),
    missing_node: () => a.check('ghost', 'owner', 'doc:9'),
    no_config: () => a.check('u:1', 'undeclared_rel', 'doc:9')
  };
  for (const [name, fn] of Object.entries(outcomes)) {
    const plain = fn();
    assertCanonical(plain, {});
    const meta = a.check('u:1', 'owner', 'doc:9', { includeMeta: true });
    assertCanonical(meta, { includeMeta: true });
    assert.ok(meta.meta && typeof meta.meta === 'object', `${name}: includeMeta attaches meta`);
  }
});

test('contract: deny carries zero reliability but never leaks relation reliability', () => {
  const a = makeEngine();
  a.addRelation('u:2', 'owner', 'doc:9', { possibility: 0.6, reliability: 0.95 });
  const denied = a.check('u:2', 'owner', 'doc:9', { includeMeta: true });
  assertCanonical(denied, { includeMeta: true });
});

test('contract: includeMeta vs plain differ only in meta richness, never semantics', () => {
  const a = makeEngine();
  const plain = a.check('u:1', 'owner', 'doc:9');
  const meta = a.check('u:1', 'owner', 'doc:9', { includeMeta: true });
  assert.equal(plain.possibility, meta.possibility);
  assert.equal(plain.reason, meta.reason);
  assert.equal(plain.reliability, meta.reliability);
  assert.ok(meta.meta, 'includeMeta attaches meta');
});

test('contract: binary mode marks results and keeps the canonical shape', () => {
  const a = makeEngine();
  a.enableCondensedSnapshot();
  const allowed = a.check('u:1', 'owner', 'doc:9', { binary: true });
  assertCanonical(allowed, { binary: true });
  const denied = a.check('u:2', 'owner', 'doc:9', { binary: true });
  assertCanonical(denied, { binary: true });
});

test('contract: snapshot round-trip preserves check outcomes', () => {
  const a = makeEngine();
  a.enableCondensedSnapshot();
  const buf = a.toSnapshotBinary();
  const restored = Arbiter.fromSnapshotBinary(buf);
  const before = a.check('u:1', 'owner', 'doc:9', { includeMeta: true });
  const after = restored.check('u:1', 'owner', 'doc:9', { includeMeta: true });
  assert.equal(before.possibility, after.possibility);
  assert.equal(before.reason, after.reason);
  assert.equal(before.reliability, after.reliability);
});

test('contract: explain enriches meta but keeps the canonical shape', () => {
  const a = makeEngine();
  const plain = a.check('u:1', 'owner', 'doc:9');
  const explained = a.check('u:1', 'owner', 'doc:9', { explain: true });
  assertCanonical(explained, { includeMeta: true });
  assert.equal(explained.possibility, plain.possibility);
  assert.equal(explained.reason, plain.reason);
  assert.ok(explained.meta.allow, 'explain carries allow block');
});

test('contract: isReachable returns boolean | null (null = deferred to rules)', () => {
  const a = makeEngine();
  a.initializeReachabilityChecker();
  for (let i = 0; i < 8; i++) {
    const r = a.isReachable('u:1', 'doc:9');
    assert.ok(r === true || r === false || r === null, `isReachable is boolean|null, got ${r}`);
  }
  a.enableCondensedSnapshot();
  const r2 = a.isReachable('u:1', 'doc:9');
  assert.ok(r2 === true || r2 === false || r2 === null, `binary isReachable is boolean|null, got ${r2}`);
});

test('contract: node/relation management is idempotent for reads', () => {
  const a = makeEngine();
  assert.equal(typeof a.getNodeData('u:1'), 'object');
  assert.equal(typeof a.resolveNodeId('u:1'), 'number');
  assert.equal(a.resolveNodeId('ghost'), undefined);
  const before = a.check('u:1', 'owner', 'doc:9');
  a.addRelation('u:1', 'owner', 'doc:9', { possibility: 0.95 }); // re-declare, not a crash
  const after = a.check('u:1', 'owner', 'doc:9');
  assert.equal(typeof after.possibility, 'number');
});
