import { describe, it, expect, beforeEach, beforeAll, afterAll, afterEach } from 'vitest';
import { OxigraphStore, type Quad } from '@origintrail-official/dkg-storage';
import { GraphManager } from '@origintrail-official/dkg-storage';
import { EVMChainAdapter } from '@origintrail-official/dkg-chain';
import { TypedEventBus, encodeKAUpdateRequest, decodeKAUpdateRequest } from '@origintrail-official/dkg-core';
import { generateEd25519Keypair } from '@origintrail-official/dkg-core';
import { DKGPublisher, UpdateHandler, autoPartition, computePublicRootV10 as computePublicRoot, computeKARootV10 as computeKARoot, computeKCRootV10 as computeKCRoot, computeFlatKCRootV10 as computeFlatKCRoot, toHex, resolveUalByBatchId, updateMetaMerkleRoot } from '../src/index.js';
import { parseSimpleNQuads } from '../src/publish-handler.js';
import { ethers } from 'ethers';
import { createEVMAdapter, getSharedContext, createProvider, takeSnapshot, revertSnapshot, createTestContextGraph, HARDHAT_KEYS } from '../../chain/test/evm-test-context.js';
import { mintTokens } from '../../chain/test/hardhat-harness.js';
import { wrapPublisherForTest } from './_helpers/seal.js';

let CONTEXT_GRAPH: string = 'test-update';
let _kav10Address: string;
let _provider: ethers.JsonRpcProvider;
const _author = new ethers.Wallet(HARDHAT_KEYS.CORE_OP);
let DATA_GRAPH: string;
const ENTITY_A = 'urn:test:entity:a';
const ENTITY_B = 'urn:test:entity:b';

function q(s: string, p: string, o: string, g = ''): Quad {
  return { subject: s, predicate: p, object: o, graph: g };
}

function quadsToNQuads(quads: Quad[], graph: string): Uint8Array {
  const str = quads
    .map((qd) => `<${qd.subject}> <${qd.predicate}> ${qd.object.startsWith('"') ? qd.object : `<${qd.object}>`} <${graph}> .`)
    .join('\n');
  return new TextEncoder().encode(str);
}

function computeGossipMerkleRoot(quads: Quad[], manifest: { rootEntity: string; privateMerkleRoot?: Uint8Array }[]): Uint8Array {
  const partitioned = autoPartition(quads);
  const kaRoots: Uint8Array[] = [];
  for (const m of manifest) {
    const entityQuads = partitioned.get(m.rootEntity) ?? [];
    const pubRoot = computePublicRoot(entityQuads);
    const privRoot = m.privateMerkleRoot?.length ? new Uint8Array(m.privateMerkleRoot) : undefined;
    kaRoots.push(computeKARoot(pubRoot, privRoot));
  }
  return computeKCRoot(kaRoots);
}

/** Build a gossip message that matches an on-chain update (same quads → same merkle root). */
function buildGossipMessage(opts: {
  contextGraphId: string;
  batchId: bigint;
  quads: Quad[];
  manifest: { rootEntity: string; privateTripleCount: number }[];
  publisherPeerId: string;
  publisherAddress: string;
  txHash: string;
  blockNumber: number;
}) {
  const gossipRoot = computeGossipMerkleRoot(opts.quads, opts.manifest);
  return encodeKAUpdateRequest({
    contextGraphId: opts.contextGraphId,
    batchId: opts.batchId,
    nquads: quadsToNQuads(opts.quads, `did:dkg:context-graph:${opts.contextGraphId}`),
    manifest: opts.manifest,
    publisherPeerId: opts.publisherPeerId,
    publisherAddress: opts.publisherAddress,
    txHash: opts.txHash,
    blockNumber: BigInt(opts.blockNumber),
    newMerkleRoot: gossipRoot,
    timestampMs: BigInt(Date.now()),
  });
}

describe('KAUpdateRequest encode/decode', () => {
  it('round-trips a KAUpdateRequest message', () => {
    const original = {
      contextGraphId: CONTEXT_GRAPH,
      batchId: 42n,
      nquads: new TextEncoder().encode('<urn:a> <urn:b> "c" .'),
      manifest: [{ rootEntity: ENTITY_A, privateTripleCount: 0 }],
      publisherPeerId: '12D3KooWTest',
      publisherAddress: '0xABCDEF',
      txHash: '0x1234',
      blockNumber: 100n,
      newMerkleRoot: new Uint8Array([1, 2, 3]),
      timestampMs: BigInt(Date.now()),
    };

    const encoded = encodeKAUpdateRequest(original);
    expect(encoded).toBeInstanceOf(Uint8Array);

    const decoded = decodeKAUpdateRequest(encoded);
    expect(decoded.contextGraphId).toBe(CONTEXT_GRAPH);
    expect(decoded.batchId).toBe(42n);
    expect(decoded.publisherPeerId).toBe('12D3KooWTest');
    expect(decoded.publisherAddress).toBe('0xABCDEF');
    expect(decoded.txHash).toBe('0x1234');
    expect(decoded.blockNumber).toBe(100n);
    expect(decoded.manifest.length).toBe(1);
    expect(decoded.manifest[0].rootEntity).toBe(ENTITY_A);
  });

  it('preserves precision for large uint64 values above 2^53', () => {
    const largeBatchId = (1n << 53n) + 7n;
    const largeBlock = (1n << 60n) + 42n;
    const original = {
      contextGraphId: CONTEXT_GRAPH,
      batchId: largeBatchId,
      nquads: new Uint8Array(),
      manifest: [],
      publisherPeerId: 'peer',
      publisherAddress: '0x1',
      txHash: '0xabc',
      blockNumber: largeBlock,
      newMerkleRoot: new Uint8Array(),
      timestampMs: 0n,
    };

    const decoded = decodeKAUpdateRequest(encodeKAUpdateRequest(original));
    expect(decoded.batchId).toBe(largeBatchId);
    expect(decoded.blockNumber).toBe(largeBlock);
  });
});

describe('UpdateHandler', () => {
  let store: OxigraphStore;
  let chain: EVMChainAdapter;
  let publisher: DKGPublisher;
  let handler: UpdateHandler;
  const wallet = new ethers.Wallet(HARDHAT_KEYS.CORE_OP);

  let _fileSnapshot: string;
  beforeAll(async () => {
    _fileSnapshot = await takeSnapshot();
    const { hubAddress } = getSharedContext();
    const provider = createProvider();
    const coreOp = new ethers.Wallet(HARDHAT_KEYS.CORE_OP);
    await mintTokens(provider, hubAddress, HARDHAT_KEYS.DEPLOYER, coreOp.address, ethers.parseEther('50000000'));
    const chain = createEVMAdapter(HARDHAT_KEYS.CORE_OP);
    const cgId = await createTestContextGraph(chain);
    CONTEXT_GRAPH = String(cgId);
    DATA_GRAPH = `did:dkg:context-graph:${CONTEXT_GRAPH}`;
    _provider = createProvider();
    _kav10Address = await chain.getKnowledgeAssetsV10Address();
  });
  afterAll(async () => {
    await revertSnapshot(_fileSnapshot);
  });

  let _testSnapshot: string;
  beforeEach(async () => {
    _testSnapshot = await takeSnapshot();
    store = new OxigraphStore();
    chain = createEVMAdapter(HARDHAT_KEYS.CORE_OP);
    const keypair = await generateEd25519Keypair();
    const eventBus = new TypedEventBus();
    publisher = new DKGPublisher({
      store,
      chain,
      eventBus,
      keypair,
      publisherPrivateKey: HARDHAT_KEYS.CORE_OP,
      publisherNodeIdentityId: BigInt(getSharedContext().coreProfileId),
    });
    publisher = wrapPublisherForTest(publisher, {
      author: _author,
      ctx: { provider: _provider, kav10Address: _kav10Address },
    });
    handler = new UpdateHandler(store, chain, eventBus);
  });
  afterEach(async () => {
    await revertSnapshot(_testSnapshot);
  });

  it('applies a verified KA update: deletes old triples, inserts new ones', async () => {
    const original = await publisher.publish({
      contextGraphId: CONTEXT_GRAPH,
      quads: [
        q(ENTITY_A, 'http://schema.org/name', '"Original"'),
        q(ENTITY_A, 'http://schema.org/description', '"Will be replaced"'),
      ],
    });
    expect(original.status).toBe('confirmed');

    const updateQuads = [q(ENTITY_A, 'http://schema.org/name', '"Updated via update()"')];
    const updateResult = await publisher.update(original.kcId, {
      contextGraphId: CONTEXT_GRAPH,
      quads: updateQuads,
    });
    expect(updateResult.onChainResult).toBeDefined();

    // Gossip must send the SAME quads as the publisher's update to match the on-chain merkle root
    const message = buildGossipMessage({
      contextGraphId: CONTEXT_GRAPH,
      batchId: original.kcId,
      quads: updateQuads,
      manifest: [{ rootEntity: ENTITY_A, privateTripleCount: 0 }],
      publisherPeerId: '12D3KooWPeerA',
      publisherAddress: wallet.address,
      txHash: updateResult.onChainResult!.txHash,
      blockNumber: updateResult.onChainResult!.blockNumber,
    });

    await handler.handle(message, '12D3KooWPeerA');

    const nameResult = await store.query(
      `SELECT ?o WHERE { GRAPH <${DATA_GRAPH}> { <${ENTITY_A}> <http://schema.org/name> ?o } }`,
    );
    expect(nameResult.type).toBe('bindings');
    if (nameResult.type === 'bindings') {
      expect(nameResult.bindings.length).toBe(1);
      expect(nameResult.bindings[0]['o']).toBe('"Updated via update()"');
    }

    const descResult = await store.query(
      `ASK { GRAPH <${DATA_GRAPH}> { <${ENTITY_A}> <http://schema.org/description> ?o } }`,
    );
    expect(descResult.type).toBe('boolean');
    if (descResult.type === 'boolean') {
      expect(descResult.value).toBe(false);
    }
  });

  // Regression test for https://github.com/OriginTrail/dkg-v9/issues/31
  //
  // Summary: the `update` protocol does not carry `privateMerkleRoot`
  // through the receive-side root reconstruction. Specifically,
  // `packages/publisher/src/update-handler.ts` computes:
  //
  //     const computedRoot = computeFlatKCRoot(quads, []);   // ← empty!
  //
  // while `packages/publisher/src/dkg-publisher.ts#update()` signs and
  // submits:
  //
  //     const kcMerkleRoot = computeFlatKCRoot(allSkolemizedQuads, updatePrivateRoots);
  //
  // When `updatePrivateRoots` is non-empty (i.e. the update touches a KA
  // with private quads), the two roots diverge. The on-chain tx stores
  // the root that includes private commitments, so chain verification
  // returns *that* root as `verifiedMerkleRoot`. The handler then
  // compares `computedRoot` (no private) against `verifiedMerkleRoot`
  // (with private) and silently rejects the update with
  // `"merkle root mismatch for batchId=... (tampered payload)"`.
  //
  // Observable symptom: peers silently miss updates to KAs that have
  // private commitments. The publisher succeeds, but every other node's
  // data graph stays stale forever.
  //
  // This test reproduces the exact scenario the issue describes:
  //   1. publish a KC with private quads (so privateMerkleRoot ≠ undefined)
  //   2. update that KC with new public + new private quads
  //   3. hand the update's gossip message to UpdateHandler on a fresh store
  //   4. assert the update actually took effect on the receiver
  //
  // If the bug is still present, step 4 fails because the receiver's
  // data graph still holds the original quads, not the updated ones.
  it('issue #31 — a valid update carrying privateMerkleRoot must apply on the receiver', async () => {
    // Step 1. Publish with private quads. The manifest will have a
    // non-undefined privateMerkleRoot, so the on-chain root factors in
    // the private commitment.
    const original = await publisher.publish({
      contextGraphId: CONTEXT_GRAPH,
      quads: [q(ENTITY_A, 'http://schema.org/name', '"Original-pub"')],
      privateQuads: [q(ENTITY_A, 'http://schema.org/ssn', '"before-update-private"')],
      publisherPeerId: '12D3KooWPeerA',
    });
    expect(original.status).toBe('confirmed');
    expect(
      original.kaManifest[0]?.privateMerkleRoot,
      'precondition: publish with privateQuads must produce a privateMerkleRoot in the manifest',
    ).toBeDefined();

    // Step 2. Update with new public and new private quads. The
    // publisher's `update()` computes the new kcMerkleRoot with the
    // private root included and signs that over to the chain.
    const updatePublic = [q(ENTITY_A, 'http://schema.org/name', '"UPDATED-pub"')];
    const updatePrivate = [q(ENTITY_A, 'http://schema.org/ssn', '"after-update-private"')];
    const updateResult = await publisher.update(original.kcId, {
      contextGraphId: CONTEXT_GRAPH,
      quads: updatePublic,
      privateQuads: updatePrivate,
      publisherPeerId: '12D3KooWPeerA',
    });
    expect(updateResult.status).toBe('confirmed');
    const updatedPrivateRoot = updateResult.kaManifest[0]?.privateMerkleRoot;
    expect(
      updatedPrivateRoot,
      'precondition: publisher.update must retain a privateMerkleRoot in its returned manifest',
    ).toBeDefined();

    // Step 3. Simulate the gossip the publisher node broadcasts (see
    // dkg-agent.ts `update()` which serializes `result.kaManifest`
    // including `privateMerkleRoot` and sets `newMerkleRoot` to the
    // publisher's computed `result.merkleRoot`). We replay that exact
    // message shape against a freshly constructed handler+store that
    // has NO prior knowledge of the update — the only path by which
    // this receiver learns about the update is the gossip payload, so
    // any bug in the receive-side root recomputation is observable
    // here as "the update never landed".
    const receiverStore = new OxigraphStore();
    const receiverEventBus = new TypedEventBus();
    const receiverHandler = new UpdateHandler(receiverStore, chain, receiverEventBus);

    // The receiver must know the original quads to detect whether the
    // update actually replaced them or was silently dropped. In the
    // real wire protocol the receiver would have already synced the
    // original KC via the sync protocol before the update arrived.
    const dataGraph = DATA_GRAPH;
    await receiverStore.insert([
      { subject: ENTITY_A, predicate: 'http://schema.org/name', object: '"Original-pub"', graph: dataGraph },
    ]);

    const message = encodeKAUpdateRequest({
      contextGraphId: CONTEXT_GRAPH,
      batchId: original.kcId,
      nquads: quadsToNQuads(updatePublic, dataGraph),
      manifest: [{
        rootEntity: ENTITY_A,
        privateMerkleRoot: updatedPrivateRoot!,
        privateTripleCount: updatePrivate.length,
      }],
      publisherPeerId: '12D3KooWPeerA',
      publisherAddress: wallet.address,
      txHash: updateResult.onChainResult!.txHash,
      blockNumber: BigInt(updateResult.onChainResult!.blockNumber),
      newMerkleRoot: updateResult.merkleRoot,
      timestampMs: BigInt(Date.now()),
    });

    await receiverHandler.handle(message, '12D3KooWPeerA');

    // Step 4. The update must be visible on the receiver. If issue #31
    // is still open, receiverStore still contains "Original-pub", not
    // "UPDATED-pub", because the handler recomputed the root with an
    // empty private root list and bailed out at the merkle mismatch.
    const nameResult = await receiverStore.query(
      `SELECT ?o WHERE { GRAPH <${dataGraph}> { <${ENTITY_A}> <http://schema.org/name> ?o } }`,
    );
    expect(nameResult.type).toBe('bindings');
    if (nameResult.type === 'bindings') {
      expect(
        nameResult.bindings.length,
        'receiver should have exactly one name after update',
      ).toBe(1);
      expect(
        nameResult.bindings[0]['o'],
        'Bug https://github.com/OriginTrail/dkg-v9/issues/31: ' +
          'update handler rejects valid updates that carry private commitments. ' +
          'Fix in packages/publisher/src/update-handler.ts — pass the manifest private roots ' +
          'to computeFlatKCRoot instead of `[]`.',
      ).toBe('"UPDATED-pub"');
    }
  });

  it('rejects update when chain verification fails (wrong publisher)', async () => {
    const original = await publisher.publish({
      contextGraphId: CONTEXT_GRAPH,
      quads: [q(ENTITY_A, 'http://schema.org/name', '"Original"')],
    });

    const updateQuads = [q(ENTITY_A, 'http://schema.org/name', '"Updated"')];
    const updateResult = await publisher.update(original.kcId, {
      contextGraphId: CONTEXT_GRAPH,
      quads: updateQuads,
    });

    const message = buildGossipMessage({
      contextGraphId: CONTEXT_GRAPH,
      batchId: original.kcId,
      quads: updateQuads,
      manifest: [{ rootEntity: ENTITY_A, privateTripleCount: 0 }],
      publisherPeerId: '12D3KooWAttacker',
      publisherAddress: '0xWrongAddress',
      txHash: updateResult.onChainResult!.txHash,
      blockNumber: updateResult.onChainResult!.blockNumber,
    });

    await handler.handle(message, '12D3KooWAttacker');

    const nameResult = await store.query(
      `SELECT ?o WHERE { GRAPH <${DATA_GRAPH}> { <${ENTITY_A}> <http://schema.org/name> ?o } }`,
    );
    expect(nameResult.type).toBe('bindings');
    if (nameResult.type === 'bindings') {
      expect(nameResult.bindings[0]['o']).toBe('"Updated"');
    }
  });

  it('rejects gossip with tampered quads (payload root != on-chain root)', async () => {
    const original = await publisher.publish({
      contextGraphId: CONTEXT_GRAPH,
      quads: [q(ENTITY_A, 'http://schema.org/name', '"Original"')],
    });

    const updateQuads = [q(ENTITY_A, 'http://schema.org/name', '"Legit update"')];
    const updateResult = await publisher.update(original.kcId, {
      contextGraphId: CONTEXT_GRAPH,
      quads: updateQuads,
    });

    // Attacker sends different quads but reuses the valid txHash.
    // The on-chain merkle root matches the legit update, not the tampered payload.
    const tamperedQuads = [q(ENTITY_A, 'http://schema.org/name', '"Tampered content"')];
    const message = buildGossipMessage({
      contextGraphId: CONTEXT_GRAPH,
      batchId: original.kcId,
      quads: tamperedQuads,
      manifest: [{ rootEntity: ENTITY_A, privateTripleCount: 0 }],
      publisherPeerId: '12D3KooWPeerA',
      publisherAddress: wallet.address,
      txHash: updateResult.onChainResult!.txHash,
      blockNumber: updateResult.onChainResult!.blockNumber,
    });

    await handler.handle(message, '12D3KooWPeerA');

    const nameResult = await store.query(
      `SELECT ?o WHERE { GRAPH <${DATA_GRAPH}> { <${ENTITY_A}> <http://schema.org/name> ?o } }`,
    );
    expect(nameResult.type).toBe('bindings');
    if (nameResult.type === 'bindings') {
      expect(nameResult.bindings[0]['o']).toBe('"Legit update"');
    }
  });

  it('rejects update with unauthenticated extra roots in payload', async () => {
    const original = await publisher.publish({
      contextGraphId: CONTEXT_GRAPH,
      quads: [q(ENTITY_A, 'http://schema.org/name', '"Original"')],
    });

    const updateQuads = [q(ENTITY_A, 'http://schema.org/name', '"Updated"')];
    const updateResult = await publisher.update(original.kcId, {
      contextGraphId: CONTEXT_GRAPH,
      quads: updateQuads,
    });

    const quadsWithExtra = [
      q(ENTITY_A, 'http://schema.org/name', '"Legit"'),
      q('urn:test:injected', 'http://schema.org/name', '"Injected data"'),
    ];
    const manifestOnlyA = [{ rootEntity: ENTITY_A, privateTripleCount: 0 }];
    const gossipRoot = computeGossipMerkleRoot(quadsWithExtra, manifestOnlyA);

    const message = encodeKAUpdateRequest({
      contextGraphId: CONTEXT_GRAPH,
      batchId: original.kcId,
      nquads: quadsToNQuads(quadsWithExtra, DATA_GRAPH),
      manifest: manifestOnlyA,
      publisherPeerId: '12D3KooWPeerA',
      publisherAddress: wallet.address,
      txHash: updateResult.onChainResult!.txHash,
      blockNumber: BigInt(updateResult.onChainResult!.blockNumber),
      newMerkleRoot: gossipRoot,
      timestampMs: BigInt(Date.now()),
    });

    await handler.handle(message, '12D3KooWPeerA');

    const injectedResult = await store.query(
      `ASK { GRAPH <${DATA_GRAPH}> { <urn:test:injected> <http://schema.org/name> ?o } }`,
    );
    expect(injectedResult.type).toBe('boolean');
    if (injectedResult.type === 'boolean') {
      expect(injectedResult.value).toBe(false);
    }
  });

  it('uses chain-verified block number for ordering, not gossip-supplied value', async () => {
    const original = await publisher.publish({
      contextGraphId: CONTEXT_GRAPH,
      quads: [q(ENTITY_A, 'http://schema.org/name', '"Original"')],
    });

    const update1Quads = [q(ENTITY_A, 'http://schema.org/name', '"Update 1"')];
    const update1 = await publisher.update(original.kcId, {
      contextGraphId: CONTEXT_GRAPH,
      quads: update1Quads,
    });
    const chainBlock1 = update1.onChainResult!.blockNumber;

    // Send gossip for update1 with a forged high block number.
    // The handler should use the chain-verified block (chainBlock1), not 999999.
    const msg1 = encodeKAUpdateRequest({
      contextGraphId: CONTEXT_GRAPH,
      batchId: original.kcId,
      nquads: quadsToNQuads(update1Quads, DATA_GRAPH),
      manifest: [{ rootEntity: ENTITY_A, privateTripleCount: 0 }],
      publisherPeerId: '12D3KooWPeerA',
      publisherAddress: wallet.address,
      txHash: update1.onChainResult!.txHash,
      blockNumber: 999999n, // forged!
      newMerkleRoot: computeGossipMerkleRoot(update1Quads, [{ rootEntity: ENTITY_A }]),
      timestampMs: BigInt(Date.now()),
    });

    await handler.handle(msg1, '12D3KooWPeerA');

    // Verify update1 was applied
    let result = await store.query(
      `SELECT ?o WHERE { GRAPH <${DATA_GRAPH}> { <${ENTITY_A}> <http://schema.org/name> ?o } }`,
    );
    expect(result.type).toBe('bindings');
    if (result.type === 'bindings') {
      expect(result.bindings[0]['o']).toBe('"Update 1"');
    }

    // Now do update2 — its chain block will be chainBlock1 + N (strictly higher)
    const update2Quads = [q(ENTITY_A, 'http://schema.org/name', '"Update 2"')];
    const update2 = await publisher.update(original.kcId, {
      contextGraphId: CONTEXT_GRAPH,
      quads: update2Quads,
    });
    expect(update2.onChainResult!.blockNumber).toBeGreaterThan(chainBlock1);

    // If the handler had stored 999999 (gossip value), this would be rejected.
    // Since it stored chainBlock1 (chain-verified), update2's higher block passes.
    const msg2 = buildGossipMessage({
      contextGraphId: CONTEXT_GRAPH,
      batchId: original.kcId,
      quads: update2Quads,
      manifest: [{ rootEntity: ENTITY_A, privateTripleCount: 0 }],
      publisherPeerId: '12D3KooWPeerA',
      publisherAddress: wallet.address,
      txHash: update2.onChainResult!.txHash,
      blockNumber: update2.onChainResult!.blockNumber,
    });

    await handler.handle(msg2, '12D3KooWPeerA');

    result = await store.query(
      `SELECT ?o WHERE { GRAPH <${DATA_GRAPH}> { <${ENTITY_A}> <http://schema.org/name> ?o } }`,
    );
    expect(result.type).toBe('bindings');
    if (result.type === 'bindings') {
      expect(result.bindings[0]['o']).toBe('"Update 2"');
    }
  });

  it('rejects stale update with lower chain-verified block number', async () => {
    const original = await publisher.publish({
      contextGraphId: CONTEXT_GRAPH,
      quads: [q(ENTITY_A, 'http://schema.org/name', '"Original"')],
    });

    // Do two updates: update1 at block B1, update2 at block B2 > B1
    const update1Quads = [q(ENTITY_A, 'http://schema.org/name', '"Update 1"')];
    const update1 = await publisher.update(original.kcId, {
      contextGraphId: CONTEXT_GRAPH,
      quads: update1Quads,
    });

    const update2Quads = [q(ENTITY_A, 'http://schema.org/name', '"Update 2"')];
    const update2 = await publisher.update(original.kcId, {
      contextGraphId: CONTEXT_GRAPH,
      quads: update2Quads,
    });

    // Apply update2's gossip first (newer block)
    const msg2 = buildGossipMessage({
      contextGraphId: CONTEXT_GRAPH,
      batchId: original.kcId,
      quads: update2Quads,
      manifest: [{ rootEntity: ENTITY_A, privateTripleCount: 0 }],
      publisherPeerId: '12D3KooWPeerA',
      publisherAddress: wallet.address,
      txHash: update2.onChainResult!.txHash,
      blockNumber: update2.onChainResult!.blockNumber,
    });
    await handler.handle(msg2, '12D3KooWPeerA');

    // Now try to apply update1's gossip (older block) — should be rejected
    const msg1 = buildGossipMessage({
      contextGraphId: CONTEXT_GRAPH,
      batchId: original.kcId,
      quads: update1Quads,
      manifest: [{ rootEntity: ENTITY_A, privateTripleCount: 0 }],
      publisherPeerId: '12D3KooWPeerA',
      publisherAddress: wallet.address,
      txHash: update1.onChainResult!.txHash,
      blockNumber: update1.onChainResult!.blockNumber,
    });
    await handler.handle(msg1, '12D3KooWPeerA');

    const nameResult = await store.query(
      `SELECT ?o WHERE { GRAPH <${DATA_GRAPH}> { <${ENTITY_A}> <http://schema.org/name> ?o } }`,
    );
    expect(nameResult.type).toBe('bindings');
    if (nameResult.type === 'bindings') {
      expect(nameResult.bindings.length).toBe(1);
      expect(nameResult.bindings[0]['o']).toBe('"Update 2"');
    }
  });

  it('rejects replayed update (same chain block height)', async () => {
    const original = await publisher.publish({
      contextGraphId: CONTEXT_GRAPH,
      quads: [q(ENTITY_A, 'http://schema.org/name', '"Original"')],
    });

    const updateQuads = [q(ENTITY_A, 'http://schema.org/name', '"Updated"')];
    const updateResult = await publisher.update(original.kcId, {
      contextGraphId: CONTEXT_GRAPH,
      quads: updateQuads,
    });

    const message = buildGossipMessage({
      contextGraphId: CONTEXT_GRAPH,
      batchId: original.kcId,
      quads: updateQuads,
      manifest: [{ rootEntity: ENTITY_A, privateTripleCount: 0 }],
      publisherPeerId: '12D3KooWPeerA',
      publisherAddress: wallet.address,
      txHash: updateResult.onChainResult!.txHash,
      blockNumber: updateResult.onChainResult!.blockNumber,
    });

    await handler.handle(message, '12D3KooWPeerA');

    // Replay same message — should be rejected (same block height)
    await handler.handle(message, '12D3KooWPeerA');

    const nameResult = await store.query(
      `SELECT ?o WHERE { GRAPH <${DATA_GRAPH}> { <${ENTITY_A}> <http://schema.org/name> ?o } }`,
    );
    expect(nameResult.type).toBe('bindings');
    if (nameResult.type === 'bindings') {
      expect(nameResult.bindings.length).toBe(1);
      expect(nameResult.bindings[0]['o']).toBe('"Updated"');
    }
  });

  it('publisher.update() returns onChainResult with txHash and blockNumber', async () => {
    const original = await publisher.publish({
      contextGraphId: CONTEXT_GRAPH,
      quads: [q(ENTITY_A, 'http://schema.org/name', '"Original"')],
    });

    const result = await publisher.update(original.kcId, {
      contextGraphId: CONTEXT_GRAPH,
      quads: [q(ENTITY_A, 'http://schema.org/name', '"Updated"')],
    });

    expect(result.status).toBe('confirmed');
    expect(result.onChainResult).toBeDefined();
    expect(result.onChainResult!.txHash).toBeTruthy();
    expect(result.onChainResult!.blockNumber).toBeGreaterThan(0);
    expect(result.onChainResult!.publisherAddress).toBe(wallet.address);
    expect(result.onChainResult!.batchId).toBe(original.kcId);
  });

  it('publisher.update() locally replaces triples in the data graph', async () => {
    const original = await publisher.publish({
      contextGraphId: CONTEXT_GRAPH,
      quads: [
        q(ENTITY_A, 'http://schema.org/name', '"Original"'),
        q(ENTITY_A, 'http://schema.org/description', '"OldDesc"'),
      ],
    });

    await publisher.update(original.kcId, {
      contextGraphId: CONTEXT_GRAPH,
      quads: [q(ENTITY_A, 'http://schema.org/name', '"Updated"')],
    });

    const nameResult = await store.query(
      `SELECT ?o WHERE { GRAPH <${DATA_GRAPH}> { <${ENTITY_A}> <http://schema.org/name> ?o } }`,
    );
    expect(nameResult.type).toBe('bindings');
    if (nameResult.type === 'bindings') {
      expect(nameResult.bindings.length).toBe(1);
      expect(nameResult.bindings[0]['o']).toBe('"Updated"');
    }

    const descResult = await store.query(
      `ASK { GRAPH <${DATA_GRAPH}> { <${ENTITY_A}> <http://schema.org/description> ?o } }`,
    );
    expect(descResult.type).toBe('boolean');
    if (descResult.type === 'boolean') {
      expect(descResult.value).toBe(false);
    }
  });

  it('handles multi-entity updates', async () => {
    const original = await publisher.publish({
      contextGraphId: CONTEXT_GRAPH,
      quads: [
        q(ENTITY_A, 'http://schema.org/name', '"A"'),
        q(ENTITY_B, 'http://schema.org/name', '"B"'),
      ],
    });

    const updateQuads = [
      q(ENTITY_A, 'http://schema.org/name', '"A-updated"'),
      q(ENTITY_B, 'http://schema.org/name', '"B-updated"'),
    ];
    const updateResult = await publisher.update(original.kcId, {
      contextGraphId: CONTEXT_GRAPH,
      quads: updateQuads,
    });

    const gossipManifest = [
      { rootEntity: ENTITY_A, privateTripleCount: 0 },
      { rootEntity: ENTITY_B, privateTripleCount: 0 },
    ];

    const message = buildGossipMessage({
      contextGraphId: CONTEXT_GRAPH,
      batchId: original.kcId,
      quads: updateQuads,
      manifest: gossipManifest,
      publisherPeerId: '12D3KooWPeerA',
      publisherAddress: wallet.address,
      txHash: updateResult.onChainResult!.txHash,
      blockNumber: updateResult.onChainResult!.blockNumber,
    });

    await handler.handle(message, '12D3KooWPeerA');

    for (const [entity, expected] of [[ENTITY_A, 'A-updated'], [ENTITY_B, 'B-updated']] as const) {
      const result = await store.query(
        `SELECT ?o WHERE { GRAPH <${DATA_GRAPH}> { <${entity}> <http://schema.org/name> ?o } }`,
      );
      expect(result.type).toBe('bindings');
      if (result.type === 'bindings') {
        expect(result.bindings.length).toBe(1);
        expect(result.bindings[0]['o']).toContain(expected);
      }
    }
  });

  it('verifyKAUpdate returns on-chain merkle root and block number', async () => {
    const original = await publisher.publish({
      contextGraphId: CONTEXT_GRAPH,
      quads: [q(ENTITY_A, 'http://schema.org/name', '"Original"')],
    });

    const updateQuads = [q(ENTITY_A, 'http://schema.org/name', '"Updated"')];
    const updateResult = await publisher.update(original.kcId, {
      contextGraphId: CONTEXT_GRAPH,
      quads: updateQuads,
    });

    const verification = await chain.verifyKAUpdate(
      updateResult.onChainResult!.txHash,
      original.kcId,
      wallet.address,
    );

    expect(verification.verified).toBe(true);
    expect(verification.onChainMerkleRoot).toBeInstanceOf(Uint8Array);
    expect(verification.onChainMerkleRoot!.length).toBe(32);
    expect(verification.blockNumber).toBeGreaterThan(0);
  });

  it('verifyKAUpdate rejects wrong publisher', async () => {
    const original = await publisher.publish({
      contextGraphId: CONTEXT_GRAPH,
      quads: [q(ENTITY_A, 'http://schema.org/name', '"Original"')],
    });

    await publisher.update(original.kcId, {
      contextGraphId: CONTEXT_GRAPH,
      quads: [q(ENTITY_A, 'http://schema.org/name', '"Updated"')],
    });

    const verification = await chain.verifyKAUpdate(
      '0xfaketx',
      original.kcId,
      '0xWrongPublisher',
    );

    expect(verification.verified).toBe(false);
    expect(verification.onChainMerkleRoot).toBeUndefined();
  });

  it('rejects cross-contextGraph replay (batch bound to different contextGraph)', async () => {
    const original = await publisher.publish({
      contextGraphId: CONTEXT_GRAPH,
      quads: [q(ENTITY_A, 'http://schema.org/name', '"Original"')],
    });

    const updateQuads = [q(ENTITY_A, 'http://schema.org/name', '"Updated"')];
    const updateResult = await publisher.update(original.kcId, {
      contextGraphId: CONTEXT_GRAPH,
      quads: updateQuads,
    });

    // First, apply on the correct contextGraph to bind the batch
    const legitMsg = buildGossipMessage({
      contextGraphId: CONTEXT_GRAPH,
      batchId: original.kcId,
      quads: updateQuads,
      manifest: [{ rootEntity: ENTITY_A, privateTripleCount: 0 }],
      publisherPeerId: '12D3KooWPeerA',
      publisherAddress: wallet.address,
      txHash: updateResult.onChainResult!.txHash,
      blockNumber: updateResult.onChainResult!.blockNumber,
    });
    await handler.handle(legitMsg, '12D3KooWPeerA');

    // Now attempt to replay the same batch on a different contextGraph
    const crossContextGraphMsg = buildGossipMessage({
      contextGraphId: 'other-contextGraph',
      batchId: original.kcId,
      quads: updateQuads,
      manifest: [{ rootEntity: ENTITY_A, privateTripleCount: 0 }],
      publisherPeerId: '12D3KooWPeerA',
      publisherAddress: wallet.address,
      txHash: updateResult.onChainResult!.txHash,
      blockNumber: updateResult.onChainResult!.blockNumber,
    });
    await handler.handle(crossContextGraphMsg, '12D3KooWPeerA');

    // The other contextGraph's graph should be empty
    const otherGraph = 'did:dkg:context-graph:other-contextGraph';
    const result = await store.query(
      `ASK { GRAPH <${otherGraph}> { <${ENTITY_A}> ?p ?o } }`,
    );
    expect(result.type).toBe('boolean');
    if (result.type === 'boolean') {
      expect(result.value).toBe(false);
    }
  });

  it('publisher.update() returns failed status when chain tx fails', async () => {
    // Attempt to update a non-existent batch (batchId=999 doesn't exist in mock)
    const result = await publisher.update(999n, {
      contextGraphId: CONTEXT_GRAPH,
      quads: [q(ENTITY_A, 'http://schema.org/name', '"Should fail"')],
    });

    expect(result.status).toBe('failed');
    expect(result.onChainResult).toBeUndefined();
  });

  it('publisher.update() updates the merkle root in _meta graph', async () => {
    const META_GRAPH = `did:dkg:context-graph:${CONTEXT_GRAPH}/_meta`;
    const DKG = 'http://dkg.io/ontology/';

    const original = await publisher.publish({
      contextGraphId: CONTEXT_GRAPH,
      quads: [
        q(ENTITY_A, 'http://schema.org/name', '"Original"'),
        q(ENTITY_A, 'http://schema.org/description', '"Will be updated"'),
      ],
    });
    expect(original.status).toBe('confirmed');
    const originalRootHex = toHex(original.merkleRoot);

    const oldRootResult = await store.query(
      `SELECT ?root WHERE { GRAPH <${META_GRAPH}> { ?ual <${DKG}merkleRoot> ?root . ?ual <${DKG}batchId> "${original.kcId}"^^<http://www.w3.org/2001/XMLSchema#integer> } }`,
    );
    expect(oldRootResult.type).toBe('bindings');
    if (oldRootResult.type === 'bindings') {
      expect(oldRootResult.bindings.length).toBe(1);
      expect(oldRootResult.bindings[0]['root']).toContain(originalRootHex);
    }

    const updateResult = await publisher.update(original.kcId, {
      contextGraphId: CONTEXT_GRAPH,
      quads: [q(ENTITY_A, 'http://schema.org/name', '"Updated"')],
    });
    expect(updateResult.status).toBe('confirmed');
    const updatedRootHex = toHex(updateResult.merkleRoot);
    expect(updatedRootHex).not.toBe(originalRootHex);

    const newRootResult = await store.query(
      `SELECT ?root WHERE { GRAPH <${META_GRAPH}> { ?ual <${DKG}merkleRoot> ?root . ?ual <${DKG}batchId> "${original.kcId}"^^<http://www.w3.org/2001/XMLSchema#integer> } }`,
    );
    expect(newRootResult.type).toBe('bindings');
    if (newRootResult.type === 'bindings') {
      expect(newRootResult.bindings.length).toBe(1);
      expect(newRootResult.bindings[0]['root']).toContain(updatedRootHex);
      expect(newRootResult.bindings[0]['root']).not.toContain(originalRootHex);
    }
  });

  it('UpdateHandler.handle() updates the merkle root in _meta graph on gossip receiver', async () => {
    const META_GRAPH = `did:dkg:context-graph:${CONTEXT_GRAPH}/_meta`;
    const DKG = 'http://dkg.io/ontology/';

    const original = await publisher.publish({
      contextGraphId: CONTEXT_GRAPH,
      quads: [q(ENTITY_A, 'http://schema.org/name', '"Original"')],
    });
    expect(original.status).toBe('confirmed');
    const originalRootHex = toHex(original.merkleRoot);

    const receiverStore = new OxigraphStore();
    const allQuads = await store.query(
      `SELECT ?s ?p ?o ?g WHERE { GRAPH ?g { ?s ?p ?o } }`,
    );
    if (allQuads.type === 'bindings') {
      const quadsToInsert = allQuads.bindings.map((b: Record<string, string>) => ({
        subject: b['s'], predicate: b['p'], object: b['o'], graph: b['g'],
      }));
      await receiverStore.insert(quadsToInsert);
    }
    const receiverHandler = new UpdateHandler(receiverStore, chain, new TypedEventBus());

    const preResult = await receiverStore.query(
      `SELECT ?root WHERE { GRAPH <${META_GRAPH}> { ?ual <${DKG}merkleRoot> ?root . ?ual <${DKG}batchId> "${original.kcId}"^^<http://www.w3.org/2001/XMLSchema#integer> } }`,
    );
    expect(preResult.type).toBe('bindings');
    if (preResult.type === 'bindings') {
      expect(preResult.bindings.length).toBe(1);
      expect(preResult.bindings[0]['root']).toContain(originalRootHex);
    }

    const updateQuads = [q(ENTITY_A, 'http://schema.org/name', '"Updated via gossip"')];
    const updateResult = await publisher.update(original.kcId, {
      contextGraphId: CONTEXT_GRAPH,
      quads: updateQuads,
    });

    const message = buildGossipMessage({
      contextGraphId: CONTEXT_GRAPH,
      batchId: original.kcId,
      quads: updateQuads,
      manifest: [{ rootEntity: ENTITY_A, privateTripleCount: 0 }],
      publisherPeerId: '12D3KooWPeerA',
      publisherAddress: wallet.address,
      txHash: updateResult.onChainResult!.txHash,
      blockNumber: updateResult.onChainResult!.blockNumber,
    });

    await receiverHandler.handle(message, '12D3KooWPeerA');

    const newRootResult = await receiverStore.query(
      `SELECT ?root WHERE { GRAPH <${META_GRAPH}> { ?ual <${DKG}merkleRoot> ?root . ?ual <${DKG}batchId> "${original.kcId}"^^<http://www.w3.org/2001/XMLSchema#integer> } }`,
    );
    expect(newRootResult.type).toBe('bindings');
    if (newRootResult.type === 'bindings') {
      expect(newRootResult.bindings.length).toBe(1);
      const newRootHex = toHex(computeFlatKCRoot(updateQuads, []));
      expect(newRootResult.bindings[0]['root']).toContain(newRootHex);
      expect(newRootResult.bindings[0]['root']).not.toContain(originalRootHex);
    }
  });

  it('updateMetaMerkleRoot fails loudly for unsafe contextGraphId values', async () => {
    const DKG = 'http://dkg.io/ontology/';
    const META_GRAPH = `did:dkg:context-graph:${CONTEXT_GRAPH}/_meta`;

    const original = await publisher.publish({
      contextGraphId: CONTEXT_GRAPH,
      quads: [q(ENTITY_A, 'http://schema.org/name', '"SafeData"')],
    });
    const originalRootHex = toHex(original.merkleRoot);

    const maliciousIds = ['test> } DELETE WHERE { ?s ?p ?o', 'foo"bar', 'test<injection'];
    const fakeRoot = new Uint8Array(32).fill(0xff);
    const gm = new GraphManager(store);

    for (const bad of maliciousIds) {
      await expect(
        updateMetaMerkleRoot(store, gm, bad, original.kcId, fakeRoot),
      ).rejects.toThrow('Unsafe contextGraphId for SPARQL graph IRI');
    }

    const rootAfter = await store.query(
      `SELECT ?root WHERE { GRAPH <${META_GRAPH}> { ?ual <${DKG}merkleRoot> ?root . ?ual <${DKG}batchId> "${original.kcId}"^^<http://www.w3.org/2001/XMLSchema#integer> } }`,
    );
    expect(rootAfter.type).toBe('bindings');
    if (rootAfter.type === 'bindings') {
      expect(rootAfter.bindings.length).toBe(1);
      expect(rootAfter.bindings[0]['root']).toContain(originalRootHex);
    }
  });

  it('resolveUalByBatchId uses bigint string representation (no Number precision loss)', async () => {
    const META_GRAPH = `did:dkg:context-graph:${CONTEXT_GRAPH}/_meta`;
    const DKG = 'http://dkg.io/ontology/';
    const XSD = 'http://www.w3.org/2001/XMLSchema#';

    const original = await publisher.publish({
      contextGraphId: CONTEXT_GRAPH,
      quads: [q(ENTITY_A, 'http://schema.org/name', '"BigIntTest"')],
    });

    const ual = await resolveUalByBatchId(store, META_GRAPH, original.kcId);
    expect(ual).toBeDefined();
    expect(ual).toContain('did:dkg:');

    const largeBatchId = (1n << 60n) + 1337n;
    const largeUal = 'did:dkg:mock:31337/0x1234567890abcdef1234567890abcdef12345678/large-kc';
    await store.insert([{
      subject: largeUal,
      predicate: `${DKG}batchId`,
      object: `"${largeBatchId}"^^<${XSD}integer>`,
      graph: META_GRAPH,
    }]);
    const largeResolved = await resolveUalByBatchId(store, META_GRAPH, largeBatchId);
    expect(largeResolved).toBe(largeUal);

    const roundedViaNumber = BigInt(Number(largeBatchId));
    expect(roundedViaNumber).not.toBe(largeBatchId);
    const roundedLookup = await resolveUalByBatchId(store, META_GRAPH, roundedViaNumber);
    expect(roundedLookup).toBeUndefined();

    const noMatch = await resolveUalByBatchId(store, META_GRAPH, 999999n);
    expect(noMatch).toBeUndefined();
  });
});
