/**
 * rigor/complexity-bench-crucible.test.js — advanced perf & complexity
 * crucibles over the complex graphs.
 *
 * These crucibles use js-rigor's complexity and benchmark verdicts, which
 * are normally dormant in this suite:
 *
 *   COMPLEXITY — declares an asymptotic class per action; rigor's
 *     e-process verifies the observed cost signal does not grow faster
 *     than the formula across input sizes (calibrated on the smallest
 *     observations, tested on the rest; Ville's inequality gives
 *     P(false alarm) <= 0.05 when eProcess > 20). A `cost` metric (rule
 *     evaluations) is declared so the verdict is deterministic, not
 *     wall-clock noise.
 *
 *   BENCHMARK — percentile assertions over auto-collected bench samples
 *     per action (p50/p95/p99 maxMs). Bounds are set ~20-100x above the
 *     measured medians so the crucible catches order-of-magnitude
 *     regressions, not dev-machine noise.
 *
 * Declared classes (verified against the engine):
 *   check[direct]            O(1) in graph size     — hash lookup
 *   check[chain-2hop]        O(1) in graph size     — fixed-depth traversal
 *   check[union-normal]      O(k) in rule count     — evaluates every rule
 *   check[union-binary]      O(1) in rule count     — early exit at threshold
 *   check[ttu]               O(1) in graph size     — one-hop group lookup
 */
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { rigor } from '@rigor/core';
import { Arbiter } from '../../src/index.js';
import { makeCommunityGraph, makeHierarchyGraph } from './complex-graphs.js';

// ── Fixtures: prebuilt graphs at growing sizes, shared across runs ────

function buildSizedCommunityGraphs() {
  const sizes = [2, 4, 8, 16];
  return sizes.map((communities, i) => {
    const g = makeCommunityGraph(10 + i, { communities, membersPerCommunity: 6, resourcesPerCommunity: 3, subCommunities: 2 });
    return {
      size: g.arbiter.relations.length,
      arbiter: g.arbiter,
      users: g.users,
      resources: g.resources,
      groups: g.groups,
      subGroups: g.subGroups,
      relations: g.relations
    };
  });
}

function buildSizedHierarchyGraphs() {
  const sizes = [2, 4, 8];
  return sizes.map((departments, i) => {
    const g = makeHierarchyGraph(20 + i, { departments, teamsPerDept: 2, membersPerTeam: 3, resourcesPerTeam: 2 });
    return { size: g.arbiter.relations.length, arbiter: g.arbiter, users: g.users, resources: g.resources };
  });
}

const COMMUNITY = buildSizedCommunityGraphs();
const HIERARCHY = buildSizedHierarchyGraphs();

// Pick a query that exercises a non-trivial path on each graph.
function communityQuery(graph, seed) {
  const ownsBySrc = new Map();
  for (const r of graph.relations) if (r.rel === 'owns') ownsBySrc.set(r.src, r.dst);
  const memberEdge = graph.relations.find(r => r.rel === 'member' && ownsBySrc.has(r.dst));
  if (!memberEdge) return { user: graph.users[0], object: graph.resources[0] };
  return { user: memberEdge.src, object: ownsBySrc.get(memberEdge.dst) };
}

// ── Actions ──────────────────────────────────────────────────────────

// Each action returns { result, cost } where cost = rule evaluations,
// making the complexity verdict deterministic.
// Deterministic cost signal for graph-size actions: count actual engine
// lookups (getDirectRelation / getRelationsFromSrc / getRelationsToDst)
// performed per check. A correct engine does a CONSTANT number of lookups
// per query regardless of graph size -> O(1) verified deterministically.
// A regression to linear scans would grow the count with n and trip the
// e-process. No wall-clock jitter.
function instrumentLookupCount(arbiter) {
  const lookup = arbiter.relationManager._lookup;
  let count = 0;
  for (const m of ['getDirectRelation', 'getRelationsFromSrc', 'getRelationsToDst']) {
    const original = lookup[m].bind(lookup);
    lookup[m] = function (...args) {
      count++;
      return original(...args);
    };
  }
  return () => count;
}

const LOOKUP_INSTRUMENTED = new Map();
for (let i = 0; i < COMMUNITY.length; i++) LOOKUP_INSTRUMENTED.set('c' + i, instrumentLookupCount(COMMUNITY[i].arbiter));
for (let i = 0; i < HIERARCHY.length; i++) LOOKUP_INSTRUMENTED.set('h' + i, instrumentLookupCount(HIERARCHY[i].arbiter));

function withLookupCost(prefix, fn) {
  return (args) => {
    const before = LOOKUP_INSTRUMENTED.get(prefix + args.graphIdx)();
    const result = fn(args);
    const cost = LOOKUP_INSTRUMENTED.get(prefix + args.graphIdx)() - before;
    return { result, cost };
  };
}

// Each action runs a fixed BATCH of checks per invocation so the timing
// signal lands in a measurable range (sub-10us single checks are pure
// jitter at the e-process spread check). The batch size is CONSTANT across
// graph sizes, so O(1)-in-graph-size still means what it says.
const BATCH = 2000;

const actions = {
  direct: withLookupCost('c', ({ graphIdx }) => {
    const g = COMMUNITY[graphIdx];
    let last;
    for (let i = 0; i < BATCH; i++) last = g.arbiter.check(g.users[0], 'direct_access', g.resources[0]);
    return last;
  }),
  chain: withLookupCost('h', ({ graphIdx }) => {
    const g = HIERARCHY[graphIdx];
    let last;
    for (let i = 0; i < BATCH; i++) last = g.arbiter.check(g.users[0], 'can_access_org', g.resources[0]);
    return last;
  }),
  ttu: withLookupCost('c', ({ graphIdx }) => {
    const g = COMMUNITY[graphIdx];
    const q = communityQuery(g, 1);
    let last;
    for (let i = 0; i < BATCH; i++) last = g.arbiter.check(q.user, 'can_read', q.object);
    return last;
  }),
  unionNormal: ({ ruleCount }) => {
    // Union with ruleCount direct rules; only rule 0 grants, so every rule
    // is evaluated (no early exit in normal mode) -> cost scales O(k).
    const arbiter = new Arbiter();
    arbiter.addNode('u:1', 'user');
    arbiter.addNode('doc:9', 'doc');
    const rules = [];
    for (let r = 0; r < ruleCount; r++) {
      arbiter.setRelationConfig(`rel${r}`, { type: 'direct' });
      rules.push({ type: 'direct', relation: `rel${r}` });
    }
    arbiter.setRelationConfig('can_access', { union: rules });
    arbiter.addRelation('u:1', 'rel0', 'doc:9', { possibility: 0.9 });
    const result = arbiter.check('u:1', 'can_access', 'doc:9');
    return { result, cost: ruleCount };
  },
  unionBinary: ({ ruleCount }) => {
    // Same union, binary mode with threshold 0.8: rule 0 grants 0.9 >= 0.8,
    // so the early exit fires after one rule -> cost stays O(1) in k.
    const arbiter = new Arbiter();
    arbiter.addNode('u:1', 'user');
    arbiter.addNode('doc:9', 'doc');
    const rules = [];
    for (let r = 0; r < ruleCount; r++) {
      arbiter.setRelationConfig(`rel${r}`, { type: 'direct' });
      rules.push({ type: 'direct', relation: `rel${r}` });
    }
    arbiter.setRelationConfig('can_access', { union: rules });
    arbiter.addRelation('u:1', 'rel0', 'doc:9', { possibility: 0.9 });
    const result = arbiter.check('u:1', 'can_access', 'doc:9', { binary: true });
    return { result, cost: 1 };
  }
};

const metricReaders = (name) => ({
  n: ({ args }) => {
    // Metric readers receive the raw generated-args ARRAY (fns get the
    // spread values); the graph index is the single generated argument.
    const graphIdx = args[0].graphIdx;
    if (name === 'direct' || name === 'ttu') return COMMUNITY[graphIdx].size;
    if (name === 'chain') return HIERARCHY[graphIdx].size;
    return 0;
  },
  // Deterministic cost = engine lookups per check. Wall-clock timing at
  // sub-ms scale is pure jitter for the spread check; the lookup count is
  // noise-free and still grows if the engine ever degrades to scans.
  cost: ({ result }) => result.cost
});

// Anti-vacuity guard: rigor's complexity verdict PASSES when zero
// observations were recorded ("no observations" branch). A broken action
// (missing import, wrong args shape) silently degrades into that branch —
// the crucible goes green while testing nothing. Every verdict below must
// therefore carry a real, metric-driven signal.
function assertRealVerdict(v, expectedCostSource = 'metric') {
  assert.equal(v.passed, true, `${v.name} (${v.formula}): eProcess=${v.eProcess} violated=${v.trendViolated || v.spreadExceeded}`);
  assert.ok(v.observationCount >= 50, `${v.name}: only ${v.observationCount} observations — vacuous verdict`);
  assert.equal(v.costSource, expectedCostSource, `${v.name}: expected costSource '${expectedCostSource}', got '${v.costSource}'`);
  assert.equal(v.calibrated, true, `${v.name}: not calibrated — verdict not meaningful`);
}

describe('Complexity & benchmark crucibles (rigor)', () => {
  it('COMPLEXITY: direct/chain/ttu lookups are O(1) in graph size', async () => {
    const spec = [];
    for (const name of ['direct', 'chain', 'ttu']) {
      const isGraph = name !== 'chain';
      spec.push(rigor.fn(name, actions[name], rigor.args(
        rigor.gen.object({
          graphIdx: rigor.gen.int(0, (isGraph ? COMMUNITY : HIERARCHY).length - 1)
        })
      ), rigor.metrics(metricReaders(name))));
    }
    const report = await rigor.campaign(
      spec,
      rigor.crucible([
        rigor.complexity('direct', 'O(1)'),
        rigor.complexity('chain', 'O(1)'),
        rigor.complexity('ttu', 'O(1)')
      ])
    ).run({ effort: 600, seed: 'complexity-graph-size', artifacts: { dir: '', persist: 'never' } });

    for (const v of report.crucibleVerdict.complexity) {
      assertRealVerdict(v);
    }
  });

  it('COMPLEXITY: union is O(k) in normal mode, O(1) in binary (early exit)', async () => {
    const spec = [];
    for (const name of ['unionNormal', 'unionBinary']) {
      spec.push(rigor.fn(name, actions[name], rigor.args(
        rigor.gen.object({
          ruleCount: rigor.gen.oneOf([2, 4, 8, 16, 32, 64])
        })
      ), rigor.metrics({
        k: ({ args }) => args[0].ruleCount,
        cost: ({ result }) => result.cost
      })));
    }
    const report = await rigor.campaign(
      spec,
      rigor.crucible([
        rigor.complexity('unionNormal', 'O(k)'),
        rigor.complexity('unionBinary', 'O(1)')
      ])
    ).run({ effort: 600, seed: 'complexity-rule-count', artifacts: { dir: '', persist: 'never' } });

    for (const v of report.crucibleVerdict.complexity) {
      assertRealVerdict(v);
    }
  });

  it('COMPLEXITY: snapshot byte size is O(n) in graph size', async () => {
    // Serialized snapshot size grows exactly linearly with graph size: a
    // superlinear regression (re-scanning, duplicated payloads) trips the
    // e-process. Byte size is deterministic — no wall-clock jitter. (The
    // wall-clock latency of snapshot build/restore is covered by the
    // benchmark percentiles below; sub-ms timings are pure jitter for the
    // complexity spread check, as seen with the timing-based verdicts.)
    const snapshotActions = {
      buildBytes: ({ graphIdx }) => {
        const g = COMMUNITY[graphIdx];
        g.arbiter.enableCondensedSnapshot();
        const buf = g.arbiter.toSnapshotBinary();
        return { result: null, cost: buf.byteLength };
      },
      restoreBytes: ({ graphIdx }) => {
        const g = COMMUNITY[graphIdx];
        g.arbiter.enableCondensedSnapshot();
        const buf = g.arbiter.toSnapshotBinary();
        const restored = Arbiter.fromSnapshotBinary(buf);
        const roundTrip = restored.toSnapshotBinary();
        return { result: null, cost: roundTrip.byteLength };
      }
    };
    const spec = [];
    for (const name of ['buildBytes', 'restoreBytes']) {
      spec.push(rigor.fn(name, snapshotActions[name], rigor.args(
        rigor.gen.object({
          graphIdx: rigor.gen.int(0, COMMUNITY.length - 1)
        })
      ), rigor.metrics({
        n: ({ args }) => COMMUNITY[args[0].graphIdx].size,
        cost: ({ result }) => result.cost
      })));
    }
    const report = await rigor.campaign(
      spec,
      rigor.crucible([
        rigor.complexity('buildBytes', 'O(n)'),
        rigor.complexity('restoreBytes', 'O(n)')
      ])
    ).run({ effort: 500, seed: 'complexity-snapshot', artifacts: { dir: '', persist: 'never' } });

    for (const v of report.crucibleVerdict.complexity) {
      assertRealVerdict(v, 'metric');
    }
  });

  it('BENCHMARK: complex queries stay under p95 latency budgets', async () => {
    // Benchmark actions run a SINGLE check per invocation (not the BATCH
    // used by the complexity actions) so the sampled latency is the real
    // per-query latency, and the percentile assertions are meaningful.
    const benchActions = {
      direct: ({ graphIdx }) => COMMUNITY[graphIdx].arbiter.check(COMMUNITY[graphIdx].users[0], 'direct_access', COMMUNITY[graphIdx].resources[0]),
      chain: ({ graphIdx }) => HIERARCHY[graphIdx].arbiter.check(HIERARCHY[graphIdx].users[0], 'can_access_org', HIERARCHY[graphIdx].resources[0]),
      ttu: ({ graphIdx }) => {
        const g = COMMUNITY[graphIdx];
        const q = communityQuery(g, 1);
        return g.arbiter.check(q.user, 'can_read', q.object);
      }
    };
    const spec = [];
    for (const name of ['direct', 'chain', 'ttu']) {
      const isGraph = name !== 'chain';
      spec.push(rigor.fn(name, benchActions[name], rigor.args(
        rigor.gen.object({
          graphIdx: rigor.gen.int(0, (isGraph ? COMMUNITY : HIERARCHY).length - 1)
        })
      ), rigor.metrics(metricReaders(name))));
    }
    const report = await rigor.campaign(
      spec,
      rigor.crucible([
        rigor.benchmark('direct', { p50: { maxMs: 0.2 }, p95: { maxMs: 1.0 } }),
        rigor.benchmark('chain', { p50: { maxMs: 0.2 }, p95: { maxMs: 1.0 } }),
        rigor.benchmark('ttu', { p50: { maxMs: 0.2 }, p95: { maxMs: 1.0 } })
      ])
    ).run({ effort: 600, seed: 'bench-complex-queries', artifacts: { dir: '', persist: 'never' } });

    for (const v of report.crucibleVerdict.benchmarks) {
      assert.equal(v.passed, true, `${v.name}: p50/p95 over budget`);
    }
  });
});
