/**
 * E2E tests for the full publish lifecycle aligned with publish-flow.md:
 *
 * 1. Publish with two-level merkle (flat + entityProofs modes)
 * 2. N-Triples (not N-Quads) over P2P
 * 3. Tentative → confirmed via ChainEventPoller
 * 4. confirmByMerkleRoot matching
 * 5. Update flow: replace triples, recompute merkle, chain update
 * 6. Synthetic privateMerkleRoot triple
 */
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
import {
  TypedEventBus,
  generateEd25519Keypair,
  encodePublishRequest,
  decodePublishAck,
  createOperationContext,
  V10MerkleTree as MerkleTree,
  structuredKARootV10,
} from '@origintrail-official/dkg-core';
import { OxigraphStore, type Quad } from '@origintrail-official/dkg-storage';
import { EVMChainAdapter } from '@origintrail-official/dkg-chain';
import { DKGPublisher } from '../src/dkg-publisher.js';
import { PublishHandler } from '../src/publish-handler.js';
import { ChainEventPoller } from '../src/chain-event-poller.js';
import { autoPartition } from '../src/auto-partition.js';
import { computePrivateRootV10, computeTripleHashV10 as computeTripleHash } from '../src/merkle.js';
import type { LegacyV10ACKProvider, V10ACKProviderParams } from '../src/publisher.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 { withSeal as _withSeal, withUpdateSeal as _withUpdateSeal } from './_helpers/seal.js';
import { hardhatACKProvider } from './_helpers/acks.js';
import { makeTestKaAllocator } from './_helpers/ka-allocator.js';

let CONTEXT_GRAPH: string;
let GRAPH: string;
let _kav10Address: string;
let _provider: ethers.JsonRpcProvider;
const _author = new ethers.Wallet(HARDHAT_KEYS.CORE_OP);
const ENTITY = 'did:dkg:agent:QmLifecycle';
const TEST_WALLET = new ethers.Wallet(HARDHAT_KEYS.CORE_OP);

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

// rc.17 uniform per-KA layout: a confirmed publish (and a subsequent
// KA update, which DELETE+REWRITEs the same graph) writes the KA's
// public quads into a PER-KA verifiable-memory named graph
// `did:dkg:context-graph:{cg}/_verifiable_memory/{author}/{number}`, where
// author+number are unpacked from the on-chain kaId (= the update's
// batchId) — NOT the monolithic root data graph. Compute that graph from
// a result's kaId so tests assert against the graph the data lives in.
function verifiableMemoryGraph(kaId: bigint): string {
  const number = kaId & ((1n << 96n) - 1n);
  const author = '0x' + (kaId >> 96n).toString(16).padStart(40, '0');
  return `did:dkg:context-graph:${CONTEXT_GRAPH}/_verifiable_memory/${author}/${number}`;
}

// Phase C made the publisher require a precomputedAttestation for
// every on-chain broadcast. `pubS(p, args)` mints a seal so existing
// tests stay structurally identical.
//
// RC11 / PR1: also threads an in-memory 3-of-N v10ACKProvider since the
// self-signed ACK fallback is deleted — without this, every Hardhat
// publish would fall back to `tentative` for lack of ACKs. Tests that
// pass their own `v10ACKProvider` explicitly (e.g. the staging-quads
// spec test) keep their override.
async function pubS(p: DKGPublisher, args: Parameters<DKGPublisher['publish']>[0]) {
  const sealed = await _withSeal(args, _author, { provider: _provider, kav10Address: _kav10Address });
  return p.publish({
    ...sealed,
    v10ACKProvider: sealed.v10ACKProvider ?? hardhatACKProvider(_kav10Address),
  } as Parameters<DKGPublisher['publish']>[0]);
}
async function updS(p: DKGPublisher, kaId: bigint, args: Parameters<DKGPublisher['update']>[1]) {
  // Greenfield (PR #815): on-chain updates are owner-sealed — the publisher
  // refuses to self-sign and requires a `precomputedUpdateAttestation` over
  // `UpdateAuthorAttestation(kaId, newMerkleRoot, author)`. Mint it here so
  // existing update tests stay structurally identical.
  const sealed = await _withUpdateSeal(kaId, args, _author, { provider: _provider, kav10Address: _kav10Address });
  return p.update(kaId, {
    ...sealed,
    v10ACKProvider: sealed.v10ACKProvider ?? hardhatACKProvider(_kav10Address),
  } as Parameters<DKGPublisher['update']>[1]);
}

let _topSnapshot: string;
beforeAll(async () => {
  _topSnapshot = await takeSnapshot();
  const { hubAddress } = getSharedContext();
  _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);
  // PR #1072: these lifecycle/merkle/update/tentative tests publish PLAINTEXT
  // (no encrypt hook) to exercise publish mechanics — not curated/ciphertext
  // semantics. A curated CG now reverts plaintext publishes
  // (CuratedCGRequiresCiphertextCommitment), so use a PUBLIC CG (accessPolicy 0).
  const cgId = await createTestContextGraph(chain, undefined, 0);
  CONTEXT_GRAPH = String(cgId);
  GRAPH = `did:dkg:context-graph:${CONTEXT_GRAPH}`;
  _kav10Address = await chain.getKnowledgeAssetsLifecycleAddress();
});
afterAll(async () => {
  await revertSnapshot(_topSnapshot);
});

describe('Publish lifecycle (aligned with diagram)', () => {
  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'));
  });
  afterAll(async () => {
    await revertSnapshot(_fileSnapshot);
  });

  it('produces a flat kcMerkleRoot by default (entityProofs=false)', async () => {
    const store = new OxigraphStore();
    const chain = createEVMAdapter(HARDHAT_KEYS.CORE_OP);
    const bus = new TypedEventBus();
    const keypair = await generateEd25519Keypair();

    const publisher = new DKGPublisher({
      kaAllocator: makeTestKaAllocator(),
      store, chain, eventBus: bus, keypair,
      publisherPrivateKey: HARDHAT_KEYS.CORE_OP,
      publisherNodeIdentityId: BigInt(getSharedContext().coreProfileId),
    });

    const triples = [
      q(ENTITY, 'http://schema.org/name', '"LifecycleBot"'),
      q(ENTITY, 'http://schema.org/version', '"1.0"'),
    ];

    const result = await pubS(publisher, {
      contextGraphId: CONTEXT_GRAPH,
      quads: triples,
    });

    expect(result.merkleRoot).toHaveLength(32);
    expect(result.status).toBe('confirmed');

    const hashes = triples.map(computeTripleHash);
    const flatTree = { root: structuredKARootV10(hashes, []).root };
    expect(Buffer.from(result.merkleRoot).toString('hex'))
      .toBe(Buffer.from(flatTree.root).toString('hex'));
  });

  it('always produces flat kcMerkleRoot regardless of options', async () => {
    const store = new OxigraphStore();
    const chain = createEVMAdapter(HARDHAT_KEYS.CORE_OP);
    const bus = new TypedEventBus();
    const keypair = await generateEd25519Keypair();

    const publisher = new DKGPublisher({
      kaAllocator: makeTestKaAllocator(),
      store, chain, eventBus: bus, keypair,
      publisherPrivateKey: HARDHAT_KEYS.CORE_OP,
      publisherNodeIdentityId: BigInt(getSharedContext().coreProfileId),
    });

    // Greenfield (PR #815): one KA per publish. The flat-kcMerkleRoot
    // property is independent of how many triples the single KA carries —
    // the root is always a flat MerkleTree over every triple hash (no
    // per-entity grouping). Exercise it with a single root entity holding
    // four triples.
    const entity = 'did:dkg:agent:QmFlatOptions';
    const triples = [
      q(entity, 'http://schema.org/name', '"EntityA"'),
      q(entity, 'http://schema.org/version', '"1"'),
      q(entity, 'http://schema.org/description', '"EntityB"'),
      q(entity, 'http://schema.org/url', '"2"'),
    ];

    const result = await pubS(publisher, {
      contextGraphId: CONTEXT_GRAPH,
      quads: triples,
    });

    expect(result.merkleRoot).toHaveLength(32);

    const hashes = triples.map(computeTripleHash);
    const flatTree = { root: structuredKARootV10(hashes, []).root };
    expect(Buffer.from(result.merkleRoot).toString('hex'))
      .toBe(Buffer.from(flatTree.root).toString('hex'));
  });

  it('full-pipeline golden: fixed quads produce known merkle root', async () => {
    const store = new OxigraphStore();
    const chain = createEVMAdapter(HARDHAT_KEYS.CORE_OP);
    const bus = new TypedEventBus();
    const keypair = await generateEd25519Keypair();
    const publisher = new DKGPublisher({
      kaAllocator: makeTestKaAllocator(),
      store,
      chain,
      eventBus: bus,
      keypair,
      publisherPrivateKey: HARDHAT_KEYS.CORE_OP,
      publisherNodeIdentityId: BigInt(getSharedContext().coreProfileId),
    });

    // Greenfield (PR #815): one KA per publish. The golden now pins the
    // flat-merkle pipeline output for a single root entity carrying six
    // fixed triples (the old golden combined three entities into one KC,
    // which the single-KA invariant no longer allows). Re-derived via
    // V10MerkleTree(quads.map(computeTripleHashV10)) over the quads below.
    const fixedQuads = [
      q('did:dkg:agent:Entity1', 'http://schema.org/name', '"Entity1"'),
      q('did:dkg:agent:Entity1', 'http://schema.org/version', '"1"'),
      q('did:dkg:agent:Entity1', 'http://schema.org/alternateName', '"Entity2"'),
      q('did:dkg:agent:Entity1', 'http://schema.org/identifier', '"2"'),
      q('did:dkg:agent:Entity1', 'http://schema.org/description', '"Entity3"'),
      q('did:dkg:agent:Entity1', 'http://schema.org/sameAs', '"3"'),
    ];

    const result = await pubS(publisher, {
      contextGraphId: CONTEXT_GRAPH,
      quads: fixedQuads,
    });

    const actualHex = Buffer.from(result.merkleRoot).toString('hex');
    // Structured root over the fixed quads (no blank nodes → no skolemization).
    const expectedHex = Buffer.from(
      structuredKARootV10(fixedQuads.map(computeTripleHash), []).root,
    ).toString('hex');
    expect(actualHex).toBe(expectedHex);
  });

  it('same quads in different order produce the same merkle root', async () => {
    const quads = [
      q(ENTITY, 'http://schema.org/name', '"LifecycleBot"'),
      q(ENTITY, 'http://schema.org/version', '"1.0"'),
      q(ENTITY, 'http://schema.org/description', '"Test"'),
    ];
    const orderA = [quads[0], quads[1], quads[2]];
    const orderB = [quads[2], quads[0], quads[1]];

    const store1 = new OxigraphStore();
    const chain1 = createEVMAdapter(HARDHAT_KEYS.CORE_OP);
    const keypair = await generateEd25519Keypair();
    const publisher1 = new DKGPublisher({
      kaAllocator: makeTestKaAllocator(),
      store: store1,
      chain: chain1,
      eventBus: new TypedEventBus(),
      keypair,
      publisherPrivateKey: HARDHAT_KEYS.CORE_OP,
      publisherNodeIdentityId: BigInt(getSharedContext().coreProfileId),
    });
    const result1 = await pubS(publisher1, {
      contextGraphId: CONTEXT_GRAPH,
      quads: orderA,
    });

    const store2 = new OxigraphStore();
    const chain2 = createEVMAdapter(HARDHAT_KEYS.CORE_OP);
    const publisher2 = new DKGPublisher({
      kaAllocator: makeTestKaAllocator(),
      store: store2,
      chain: chain2,
      eventBus: new TypedEventBus(),
      keypair,
      publisherPrivateKey: HARDHAT_KEYS.CORE_OP,
      publisherNodeIdentityId: BigInt(getSharedContext().coreProfileId),
    });
    const result2 = await pubS(publisher2, {
      contextGraphId: CONTEXT_GRAPH,
      quads: orderB,
    });

    expect(Buffer.from(result1.merkleRoot).toString('hex')).toBe(
      Buffer.from(result2.merkleRoot).toString('hex'),
    );
  });

  it('anchors private merkle root as synthetic leaf in flat KC root', async () => {
    const store = new OxigraphStore();
    const chain = createEVMAdapter(HARDHAT_KEYS.CORE_OP);
    const bus = new TypedEventBus();
    const keypair = await generateEd25519Keypair();

    const publisher = new DKGPublisher({
      kaAllocator: makeTestKaAllocator(),
      store, chain, eventBus: bus, keypair,
      publisherPrivateKey: HARDHAT_KEYS.CORE_OP,
      publisherNodeIdentityId: BigInt(getSharedContext().coreProfileId),
    });

    const result = await pubS(publisher, {
      contextGraphId: CONTEXT_GRAPH,
      quads: [q(ENTITY, 'http://schema.org/name', '"PrivBot"')],
      privateQuads: [q(ENTITY, 'http://ex.org/secret', '"s3cret"')],
      publisherPeerId: '12D3KooWTestPublisher',
    });

    expect(result.kaManifest).toHaveLength(1);
    expect(result.kaManifest[0].privateMerkleRoot).toBeDefined();
    expect(result.kaManifest[0].privateMerkleRoot).toHaveLength(32);

    const publicHashes = [q(ENTITY, 'http://schema.org/name', '"PrivBot"')].map(computeTripleHash);
    const privateRoot = result.kaManifest[0].privateMerkleRoot!;
    const expectedRoot = structuredKARootV10(publicHashes, [privateRoot]).root;
    expect(Buffer.from(result.merkleRoot).toString('hex'))
      .toBe(Buffer.from(expectedRoot).toString('hex'));
  });

  it('sends N-Triples (no graph component) in publish request', () => {
    const triples = [
      q(ENTITY, 'http://schema.org/name', '"NTripBot"'),
    ];

    // Simulate N-Triples serialization (what broadcastPublish does)
    const ntriples = triples.map(t => {
      const obj = t.object.startsWith('"') ? t.object : `<${t.object}>`;
      return `<${t.subject}> <${t.predicate}> ${obj} .`;
    }).join('\n');

    for (const line of ntriples.split('\n')) {
      const parts = line.split(' ');
      expect(parts[parts.length - 1]).toBe('.');
      // <s> <p> <o> .  → 4 tokens (last is dot), no graph before the dot
      const beforeDot = parts.slice(0, -1);
      expect(beforeDot).toHaveLength(3);
    }
  });
});

describe('Publisher ↔ Receiver merkle consistency (regression)', () => {
  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'));
  });
  afterAll(async () => {
    await revertSnapshot(_fileSnapshot);
  });

  it('multi-entity publish: receiver flat merkle matches publisher merkle', async () => {
    const store = new OxigraphStore();
    const chain = createEVMAdapter(HARDHAT_KEYS.CORE_OP);
    const bus = new TypedEventBus();
    const keypair = await generateEd25519Keypair();

    const publisher = new DKGPublisher({
      kaAllocator: makeTestKaAllocator(),
      store, chain, eventBus: bus, keypair,
      publisherPrivateKey: HARDHAT_KEYS.CORE_OP,
      publisherNodeIdentityId: BigInt(getSharedContext().coreProfileId),
    });

    // Greenfield (PR #815): one KA per publish, so the historical
    // single-KC-with-two-entities publish is now split into two
    // single-KA publishes. The receiver-consistency invariant is
    // per-publish: for each KA, the receiver rebuilding a flat
    // MerkleTree from the public quads MUST reproduce the publisher's
    // root — otherwise the finalization handler logs "merkle mismatch"
    // and falls back to full-payload sync.
    const entityA = 'did:dkg:agent:QmEntityAlpha';
    const entityB = 'did:dkg:agent:QmEntityBeta';
    const triplesA = [
      q(entityA, 'http://schema.org/name', '"Alpha"'),
      q(entityA, 'http://schema.org/version', '"1"'),
      q(entityA, 'http://schema.org/description', '"First entity"'),
    ];
    const triplesB = [
      q(entityB, 'http://schema.org/name', '"Beta"'),
      q(entityB, 'http://schema.org/version', '"2"'),
    ];

    for (const triples of [triplesA, triplesB]) {
      const result = await pubS(publisher, {
        contextGraphId: CONTEXT_GRAPH,
        quads: triples,
      });

      expect(result.merkleRoot).toHaveLength(32);
      const publisherHex = Buffer.from(result.merkleRoot).toString('hex');

      const receiverHashes = result.publicQuads!.map(computeTripleHash);
      const receiverRoot = structuredKARootV10(receiverHashes, []).root;
      const receiverHex = Buffer.from(receiverRoot).toString('hex');

      expect(receiverHex).toBe(publisherHex);
    }
  });

  it('single-entity publish: receiver flat merkle matches publisher merkle', async () => {
    const store = new OxigraphStore();
    const chain = createEVMAdapter(HARDHAT_KEYS.CORE_OP);
    const bus = new TypedEventBus();
    const keypair = await generateEd25519Keypair();

    const publisher = new DKGPublisher({
      kaAllocator: makeTestKaAllocator(),
      store, chain, eventBus: bus, keypair,
      publisherPrivateKey: HARDHAT_KEYS.CORE_OP,
      publisherNodeIdentityId: BigInt(getSharedContext().coreProfileId),
    });

    const triples = [
      q(ENTITY, 'http://schema.org/name', '"SingleBot"'),
      q(ENTITY, 'http://schema.org/version', '"3"'),
    ];

    const result = await pubS(publisher, {
      contextGraphId: CONTEXT_GRAPH,
      quads: triples,
    });

    const publisherHex = Buffer.from(result.merkleRoot).toString('hex');
    const receiverHashes = result.publicQuads!.map(computeTripleHash);
    const receiverRoot = structuredKARootV10(receiverHashes, []).root;
    const receiverHex = Buffer.from(receiverRoot).toString('hex');

    expect(receiverHex).toBe(publisherHex);
  });

  it('publish with private quads: receiver flat merkle from public quads matches', async () => {
    const store = new OxigraphStore();
    const chain = createEVMAdapter(HARDHAT_KEYS.CORE_OP);
    const bus = new TypedEventBus();
    const keypair = await generateEd25519Keypair();

    const publisher = new DKGPublisher({
      kaAllocator: makeTestKaAllocator(),
      store, chain, eventBus: bus, keypair,
      publisherPrivateKey: HARDHAT_KEYS.CORE_OP,
      publisherNodeIdentityId: BigInt(getSharedContext().coreProfileId),
    });

    const publicTriples = [
      q(ENTITY, 'http://schema.org/name', '"PrivateTestBot"'),
      q(ENTITY, 'http://schema.org/description', '"Has secrets"'),
    ];
    const privateTriples = [
      q(ENTITY, 'http://ex.org/apiKey', '"sk-secret"'),
    ];

    const result = await pubS(publisher, {
      contextGraphId: CONTEXT_GRAPH,
      publisherPeerId: 'test-peer',
      quads: publicTriples,
      privateQuads: privateTriples,
    });

    const publisherHex = Buffer.from(result.merkleRoot).toString('hex');

    // Receiver sees public quads + private roots from manifest as synthetic leaves
    const receiverHashes = result.publicQuads!.map(computeTripleHash);
    const privateRoots = result.kaManifest
      .filter(m => m.privateMerkleRoot)
      .map(m => m.privateMerkleRoot!);
    const receiverRoot = structuredKARootV10(receiverHashes, privateRoots).root;
    const receiverHex = Buffer.from(receiverRoot).toString('hex');

    expect(receiverHex).toBe(publisherHex);
  });
});

describe('Tentative data and chain event confirmation', () => {
  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'));
  });
  afterAll(async () => {
    await revertSnapshot(_fileSnapshot);
  });

  it('stores data tentatively and confirms via confirmPublish', async () => {
    const store = new OxigraphStore();
    const bus = new TypedEventBus();
    const handler = new PublishHandler(store, bus);
    const publisherAddress = TEST_WALLET.address;

    const triples = [q('did:dkg:agent:QmTentative', 'http://schema.org/name', '"TentBot"')];
    const hashes = triples.map(computeTripleHash);
    const merkleRoot = structuredKARootV10(hashes, []).root;

    const ntriples = triples.map(t =>
      `<${t.subject}> <${t.predicate}> ${t.object} .`,
    ).join('\n');

    const ual = `did:dkg:mock:31337/${publisherAddress}/1`;
    const reqBytes = encodePublishRequest({
      ual,
      nquads: new TextEncoder().encode(ntriples),
      contextGraphId: CONTEXT_GRAPH,
      kas: [{ tokenId: 1, rootEntity: 'did:dkg:agent:QmTentative', privateMerkleRoot: new Uint8Array(0), privateTripleCount: 0 }],
      publisherIdentity: new Uint8Array(32),
      publisherAddress,
      startKAId: 1,
      endKAId: 1,
      chainId: 'mock:31337',
      publisherSignatureR: new Uint8Array(0),
      publisherSignatureVs: new Uint8Array(0),
    });

    const ackData = await handler.handler(reqBytes, 'test-peer' as any);
    const ack = decodePublishAck(ackData);
    expect(ack.accepted).toBe(true);
    expect(handler.hasPendingPublishes).toBe(true);

    const confirmed = await handler.confirmPublish(ual, {
      publisherAddress,
      merkleRoot,
      startKAId: 1n,
      endKAId: 1n,
    });
    expect(confirmed).toBe(true);
    expect(handler.hasPendingPublishes).toBe(false);
  });

  it('rejects PublishRequest when publisher does not own UAL range (on-chain check)', async () => {
    const store = new OxigraphStore();
    const bus = new TypedEventBus();
    const chainAdapter = createEVMAdapter(HARDHAT_KEYS.CORE_OP);
    const handler = new PublishHandler(store, bus, { chainAdapter });

    const publisherAddress = TEST_WALLET.address;
    const triples = [q('did:dkg:agent:QmNoRange', 'http://schema.org/name', '"NoRangeBot"')];
    const ntriples = triples.map(t =>
      `<${t.subject}> <${t.predicate}> ${t.object} .`,
    ).join('\n');

    const ual = `did:dkg:mock:31337/${publisherAddress}/1`;
    const reqBytes = encodePublishRequest({
      ual,
      nquads: new TextEncoder().encode(ntriples),
      contextGraphId: CONTEXT_GRAPH,
      kas: [{ tokenId: 1, rootEntity: 'did:dkg:agent:QmNoRange', privateMerkleRoot: new Uint8Array(0), privateTripleCount: 0 }],
      publisherIdentity: new Uint8Array(32),
      publisherAddress,
      startKAId: 1,
      endKAId: 1,
      chainId: 'mock:31337',
      publisherSignatureR: new Uint8Array(0),
      publisherSignatureVs: new Uint8Array(0),
    });

    const ackData = await handler.handler(reqBytes, 'test-peer' as any);
    const ack = decodePublishAck(ackData);
    expect(ack.accepted).toBe(false);
    expect(ack.rejectionReason).toContain('does not own');
    expect(ack.rejectionReason).toContain('1..1');
  });

  it('rejects confirmation with mismatched publisher address', async () => {
    const store = new OxigraphStore();
    const bus = new TypedEventBus();
    const handler = new PublishHandler(store, bus);
    const publisherAddress = TEST_WALLET.address;
    const imposter = ethers.Wallet.createRandom();

    const triples = [q('did:dkg:agent:QmMisAddr', 'http://schema.org/name', '"MisAddrBot"')];
    const hashes = triples.map(computeTripleHash);
    const merkleRoot = structuredKARootV10(hashes, []).root;

    const ntriples = triples.map(t =>
      `<${t.subject}> <${t.predicate}> ${t.object} .`,
    ).join('\n');

    const ual = `did:dkg:mock:31337/${publisherAddress}/2`;
    const reqBytes = encodePublishRequest({
      ual,
      nquads: new TextEncoder().encode(ntriples),
      contextGraphId: CONTEXT_GRAPH,
      kas: [{ tokenId: 1, rootEntity: 'did:dkg:agent:QmMisAddr', privateMerkleRoot: new Uint8Array(0), privateTripleCount: 0 }],
      publisherIdentity: new Uint8Array(32),
      publisherAddress,
      startKAId: 2,
      endKAId: 2,
      chainId: 'mock:31337',
      publisherSignatureR: new Uint8Array(0),
      publisherSignatureVs: new Uint8Array(0),
    });

    await handler.handler(reqBytes, 'test-peer' as any);

    const confirmed = await handler.confirmPublish(ual, {
      publisherAddress: imposter.address,
      merkleRoot,
      startKAId: 2n,
      endKAId: 2n,
    });
    expect(confirmed).toBe(false);
    expect(handler.hasPendingPublishes).toBe(true);
  });

  it('rejects confirmation with mismatched merkle root', async () => {
    const store = new OxigraphStore();
    const bus = new TypedEventBus();
    const handler = new PublishHandler(store, bus);
    const publisherAddress = TEST_WALLET.address;

    const triples = [q('did:dkg:agent:QmMisRoot', 'http://schema.org/name', '"MisRootBot"')];

    const ntriples = triples.map(t =>
      `<${t.subject}> <${t.predicate}> ${t.object} .`,
    ).join('\n');

    const ual = `did:dkg:mock:31337/${publisherAddress}/3`;
    const reqBytes = encodePublishRequest({
      ual,
      nquads: new TextEncoder().encode(ntriples),
      contextGraphId: CONTEXT_GRAPH,
      kas: [{ tokenId: 1, rootEntity: 'did:dkg:agent:QmMisRoot', privateMerkleRoot: new Uint8Array(0), privateTripleCount: 0 }],
      publisherIdentity: new Uint8Array(32),
      publisherAddress,
      startKAId: 3,
      endKAId: 3,
      chainId: 'mock:31337',
      publisherSignatureR: new Uint8Array(0),
      publisherSignatureVs: new Uint8Array(0),
    });

    await handler.handler(reqBytes, 'test-peer' as any);

    const wrongRoot = new Uint8Array(32).fill(0xff);
    const confirmed = await handler.confirmPublish(ual, {
      publisherAddress,
      merkleRoot: wrongRoot,
      startKAId: 3n,
      endKAId: 3n,
    });
    expect(confirmed).toBe(false);
    expect(handler.hasPendingPublishes).toBe(true);
  });

  it('confirms tentative data via confirmByMerkleRoot', async () => {
    const store = new OxigraphStore();
    const bus = new TypedEventBus();
    const handler = new PublishHandler(store, bus);
    const publisherAddress = TEST_WALLET.address;

    const triples = [q('did:dkg:agent:QmByRoot', 'http://schema.org/name', '"RootBot"')];
    const hashes = triples.map(computeTripleHash);
    const merkleRoot = structuredKARootV10(hashes, []).root;

    const ntriples = triples.map(t =>
      `<${t.subject}> <${t.predicate}> ${t.object} .`,
    ).join('\n');

    const ual = `did:dkg:mock:31337/${publisherAddress}/5`;
    const reqBytes = encodePublishRequest({
      ual,
      nquads: new TextEncoder().encode(ntriples),
      contextGraphId: CONTEXT_GRAPH,
      kas: [{ tokenId: 1, rootEntity: 'did:dkg:agent:QmByRoot', privateMerkleRoot: new Uint8Array(0), privateTripleCount: 0 }],
      publisherIdentity: new Uint8Array(32),
      publisherAddress,
      startKAId: 5,
      endKAId: 5,
      chainId: 'mock:31337',
      publisherSignatureR: new Uint8Array(0),
      publisherSignatureVs: new Uint8Array(0),
    });

    await handler.handler(reqBytes, 'test-peer' as any);
    expect(handler.hasPendingPublishes).toBe(true);

    const ctx = createOperationContext('publish');
    const confirmed = await handler.confirmByMerkleRoot(merkleRoot, {
      publisherAddress,
      startKAId: 5n,
      endKAId: 5n,
      chainId: 'mock:31337',
    }, ctx);

    expect(confirmed).toBe(true);
    expect(handler.hasPendingPublishes).toBe(false);
  });

  it('ChainEventPoller detects events and confirms tentative publishes', async () => {
    const store = new OxigraphStore();
    const bus = new TypedEventBus();
    const handler = new PublishHandler(store, bus);
    const chain = createEVMAdapter(HARDHAT_KEYS.CORE_OP);
    const publisherAddress = TEST_WALLET.address;

    // Publish through the chain to create the on-chain event
    const keypair = await generateEd25519Keypair();
    const publisherStore = new OxigraphStore();
    const publisher = new DKGPublisher({
      kaAllocator: makeTestKaAllocator(),
      store: publisherStore,
      chain,
      eventBus: new TypedEventBus(),
      keypair,
      publisherPrivateKey: HARDHAT_KEYS.CORE_OP,
      publisherNodeIdentityId: BigInt(getSharedContext().coreProfileId),
    });

    const triples = [q('did:dkg:agent:QmPolled', 'http://schema.org/name', '"PollBot"')];
    const publishResult = await pubS(publisher, {
      contextGraphId: CONTEXT_GRAPH,
      quads: triples,
    });

    // Simulate a receiver getting the data via P2P: build the same merkle root
    // The handler computes its own merkle root from the autoPartition of received quads,
    // so we need to ensure the merkle root matches what the chain event carries.
    const hashes = triples.map(computeTripleHash);
    const expectedMerkleRoot = structuredKARootV10(hashes, []).root;

    const ntriples = triples.map(t =>
      `<${t.subject}> <${t.predicate}> ${t.object} .`,
    ).join('\n');

    const onChain = publishResult.onChainResult!;
    const ual = `did:dkg:mock:31337/${onChain.publisherAddress}/${onChain.startKAId}`;
    const reqBytes = encodePublishRequest({
      ual,
      nquads: new TextEncoder().encode(ntriples),
      contextGraphId: CONTEXT_GRAPH,
      kas: [{ tokenId: 1, rootEntity: 'did:dkg:agent:QmPolled', privateMerkleRoot: new Uint8Array(0), privateTripleCount: 0 }],
      publisherIdentity: new Uint8Array(32),
      publisherAddress: onChain.publisherAddress,
      // OT-RFC-43 §9: KA ids are 256-bit packed values — pass the bigint
      // straight through (the wire field is a decimal string; Number() would
      // truncate a packed id to a lossy float).
      startKAId: onChain.startKAId,
      endKAId: onChain.endKAId,
      chainId: 'mock:31337',
      publisherSignatureR: new Uint8Array(0),
      publisherSignatureVs: new Uint8Array(0),
    });

    await handler.handler(reqBytes, 'test-peer' as any);
    expect(handler.hasPendingPublishes).toBe(true);

    const poller = new ChainEventPoller({
      chain,
      publishHandler: handler,
      intervalMs: 100,
    });

    poller.start();
    const deadline = Date.now() + 5000;
    while (handler.hasPendingPublishes && Date.now() < deadline) {
      await new Promise((r) => setTimeout(r, 100));
    }
    await poller.stop();

    expect(handler.hasPendingPublishes).toBe(false);
  }, 10000);

  it('ChainEventPoller invokes onContextGraphCreated for ContextGraphCreated events (V10)', async () => {
    const store = new OxigraphStore();
    const bus = new TypedEventBus();
    const handler = new PublishHandler(store, bus);
    const chain = createEVMAdapter(HARDHAT_KEYS.CORE_OP);

    // V10 path: emits `ContextGraphCreated` from the ContextGraphStorage
    // contract. The V9 `createContextGraph({ name, accessPolicy })` flow
    // (NameClaimed via ContextGraphNameRegistry) was archived together
    // with the rest of the V8/V9 surface — see
    // `packages/chain/src/archive/`.
    const ownerIdentityId = BigInt(getSharedContext().coreProfileId);
    const cgResult = await chain.createOnChainContextGraph({
      accessPolicy: 1,
      publishPolicy: 0,
    });
    expect(cgResult.success).toBe(true);

    const received: Array<{ contextGraphId: string; creator: string; accessPolicy: number }> = [];
    const poller = new ChainEventPoller({
      chain,
      publishHandler: handler,
      intervalMs: 100,
      onContextGraphCreated: async (info) => {
        received.push(info);
      },
    });

    poller.start();
    const deadline = Date.now() + 5000;
    while (received.length === 0 && Date.now() < deadline) {
      await new Promise((r) => setTimeout(r, 100));
    }
    await poller.stop();

    expect(received.length).toBeGreaterThanOrEqual(1);
    const event = received.find(e => e.contextGraphId === cgResult.contextGraphId.toString());
    expect(event).toBeDefined();
    expect(event!.contextGraphId).toBeTruthy();
    expect(event!.creator).toBeTruthy();
  });
});

describe('Update flow', () => {
  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'));
  });
  afterAll(async () => {
    await revertSnapshot(_fileSnapshot);
  });

  it('updates existing KC with new triples and new merkle root (publisher.update now writes per-KA VM — verify read-both returns exactly the updated row)', async () => {
    const store = new OxigraphStore();
    const chain = createEVMAdapter(HARDHAT_KEYS.CORE_OP);
    const bus = new TypedEventBus();
    const keypair = await generateEd25519Keypair();

    const publisher = new DKGPublisher({
      kaAllocator: makeTestKaAllocator(),
      store, chain, eventBus: bus, keypair,
      publisherPrivateKey: HARDHAT_KEYS.CORE_OP,
      publisherNodeIdentityId: BigInt(getSharedContext().coreProfileId),
    });

    const initialTriples = [
      q(ENTITY, 'http://schema.org/name', '"OriginalBot"'),
      q(ENTITY, 'http://schema.org/version', '"1.0"'),
    ];

    const publishResult = await pubS(publisher, {
      contextGraphId: CONTEXT_GRAPH,
      quads: initialTriples,
    });

    expect(publishResult.status).toBe('confirmed');
    const kaId = publishResult.kaId;

    // rc.17 per-KA layout: the confirmed publish wrote the original quads
    // into the per-KA verifiable-memory graph (computed from kaId), and the
    // KA update DELETE+REWRITEs that SAME graph (its batchId === kaId).
    // Assert read-both against that graph, not the monolithic root data graph.
    const vmGraph = verifiableMemoryGraph(kaId);

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

    const updatedTriples = [
      q(ENTITY, 'http://schema.org/name', '"UpdatedBot"'),
      q(ENTITY, 'http://schema.org/version', '"2.0"'),
    ];

    const updateResult = await updS(publisher, kaId, {
      contextGraphId: CONTEXT_GRAPH,
      quads: updatedTriples,
    });

    expect(updateResult.status).toBe('confirmed');
    expect(updateResult.kaId).toBe(kaId);
    expect(Buffer.from(updateResult.merkleRoot).toString('hex'))
      .not.toBe(Buffer.from(publishResult.merkleRoot).toString('hex'));

    const after = await store.query(
      `SELECT ?name WHERE { GRAPH <${vmGraph}> { <${ENTITY}> <http://schema.org/name> ?name } }`,
    );
    expect(after.type).toBe('bindings');
    if (after.type === 'bindings') {
      expect(after.bindings).toHaveLength(1);
      expect(after.bindings[0]['name']).toBe('"UpdatedBot"');
    }
  });

  // RC11 / PR1: depends on a private-data publish confirming on-chain
  // (yielding a real `result1.kaId`) so the subsequent `update` can
  // target it. Private-data publishes now go `tentative` because the
  // publisher intentionally skips peer ACK collection for them
  // (`dkg-publisher.ts:1937`) and the self-signed ACK fallback is
  // gone. Restoring this regression coverage requires teaching the
  // ACK collector to carry `privateRoots` — out of scope for PR1.
  it.skip('update removes old private triples and stores new ones (skipped under RC11 / PR1: see comment above)', async () => {
    const store = new OxigraphStore();
    const chain = createEVMAdapter(HARDHAT_KEYS.CORE_OP);
    const bus = new TypedEventBus();
    const keypair = await generateEd25519Keypair();

    const publisher = new DKGPublisher({
      kaAllocator: makeTestKaAllocator(),
      store, chain, eventBus: bus, keypair,
      publisherPrivateKey: HARDHAT_KEYS.CORE_OP,
      publisherNodeIdentityId: BigInt(getSharedContext().coreProfileId),
    });

    const entity = 'did:dkg:agent:QmPrivUpdate';

    const result1 = await pubS(publisher, {
      contextGraphId: CONTEXT_GRAPH,
      quads: [q(entity, 'http://schema.org/name', '"PrivUpdateBot"')],
      privateQuads: [q(entity, 'http://ex.org/secret', '"old-secret"')],
      publisherPeerId: '12D3KooWTestPublisher',
    });

    await updS(publisher, result1.kaId, {
      contextGraphId: CONTEXT_GRAPH,
      quads: [q(entity, 'http://schema.org/name', '"PrivUpdateBot v2"')],
      privateQuads: [q(entity, 'http://ex.org/secret', '"new-secret"')],
    });

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

  it('update sends new merkle root to chain', async () => {
    const store = new OxigraphStore();
    const chain = createEVMAdapter(HARDHAT_KEYS.CORE_OP);
    const bus = new TypedEventBus();
    const keypair = await generateEd25519Keypair();

    const publisher = new DKGPublisher({
      kaAllocator: makeTestKaAllocator(),
      store, chain, eventBus: bus, keypair,
      publisherPrivateKey: HARDHAT_KEYS.CORE_OP,
      publisherNodeIdentityId: BigInt(getSharedContext().coreProfileId),
    });

    const entity = 'did:dkg:agent:QmChainUpdate';
    const result1 = await pubS(publisher, {
      contextGraphId: CONTEXT_GRAPH,
      quads: [q(entity, 'http://schema.org/name', '"ChainBot v1"')],
    });

    const oldMerkleHex = Buffer.from(result1.merkleRoot).toString('hex');

    const result2 = await updS(publisher, result1.kaId, {
      contextGraphId: CONTEXT_GRAPH,
      quads: [q(entity, 'http://schema.org/name', '"ChainBot v2"')],
    });

    const newMerkleHex = Buffer.from(result2.merkleRoot).toString('hex');

    expect(newMerkleHex).not.toBe(oldMerkleHex);
  });
});

describe('Tentative publish UAL uniqueness', () => {
  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'));
  });
  afterAll(async () => {
    await revertSnapshot(_fileSnapshot);
  });

  it('produces a unique UAL for each tentative publish', async () => {
    const store = new OxigraphStore();
    const chain = createEVMAdapter(HARDHAT_KEYS.CORE_OP);
    const bus = new TypedEventBus();
    const keypair = await generateEd25519Keypair();

    const publisher = new DKGPublisher({
      kaAllocator: makeTestKaAllocator(),
      store, chain, eventBus: bus, keypair,
      publisherPrivateKey: HARDHAT_KEYS.CORE_OP,
      publisherNodeIdentityId: BigInt(getSharedContext().coreProfileId),
    });

    // RC11 / PR3: previously `publisherNodeIdentityId: 0n` was enough
    // to force tentative because the self-signed ACK fallback gated
    // on it. With the fallback deleted (PR1) and the
    // "no-v10ACKProvider" case made loudly-throwing (PR3), the honest
    // paths to tentative are: (a) non-numeric CG id, (b) chain not V10-ready.
    // Use a non-numeric SWM CG label so each iteration goes through
    // `finalizeIntentionalLocalPublish` via the "no on-chain CG id"
    // branch — preserves the UAL-uniqueness invariant this test
    // pins without re-introducing the deleted self-sign path.
    const uals = new Set<string>();
    for (let i = 0; i < 5; i++) {
      const result = await publisher.publish({
        contextGraphId: `swm-tentative-uniqueness-${i}`,
        quads: [{
          subject: `did:dkg:agent:Unique${i}`,
          predicate: 'http://schema.org/name',
          object: `"Entity${i}"`,
          graph: `did:dkg:context-graph:swm-tentative-uniqueness-${i}`,
        }],
      });

      expect(result.status).toBe('tentative');
      expect(result.ual).toBeTruthy();
      expect(result.ual).toContain('/t');
      expect(uals.has(result.ual)).toBe(false);
      uals.add(result.ual);
    }
    expect(uals.size).toBe(5);
  });

  it('includes ual field in confirmed publish results', async () => {
    const store = new OxigraphStore();
    const chain = createEVMAdapter(HARDHAT_KEYS.CORE_OP);
    const bus = new TypedEventBus();
    const keypair = await generateEd25519Keypair();

    const publisher = new DKGPublisher({
      kaAllocator: makeTestKaAllocator(),
      store, chain, eventBus: bus, keypair,
      publisherPrivateKey: HARDHAT_KEYS.CORE_OP,
      publisherNodeIdentityId: BigInt(getSharedContext().coreProfileId),
    });

    const result = await pubS(publisher, {
      contextGraphId: CONTEXT_GRAPH,
      quads: [q(ENTITY, 'http://schema.org/name', '"ConfirmedUAL"')],
    });

    expect(result.status).toBe('confirmed');
    expect(result.ual).toBeTruthy();
    expect(result.ual).toContain('did:dkg:');
  });

  it('confirms folded public+private publishes on-chain when ACKs are available', async () => {
    const store = new OxigraphStore();
    const chain = createEVMAdapter(HARDHAT_KEYS.CORE_OP);
    const bus = new TypedEventBus();
    const keypair = await generateEd25519Keypair();

    const publisher = new DKGPublisher({
      kaAllocator: makeTestKaAllocator(),
      store, chain, eventBus: bus, keypair,
      publisherPrivateKey: HARDHAT_KEYS.CORE_OP,
      publisherNodeIdentityId: BigInt(getSharedContext().coreProfileId),
    });

    const entity = 'did:dkg:agent:FoldedPrivateConfirm';
    const publicQuads = [q(entity, 'http://schema.org/name', '"FoldedPrivateConfirm"')];
    const privateQuads = [q(entity, 'http://dkg.io/ontology/secret', '"hidden"')];
    const expectedPrivateRoot = computePrivateRootV10(privateQuads)!;
    let receivedPrivateRoots: Uint8Array[] | undefined;
    const realProvider = hardhatACKProvider(_kav10Address);
    const v10ACKProvider: Parameters<DKGPublisher['publish']>[0]['v10ACKProvider'] = async (params: V10ACKProviderParams) => {
      receivedPrivateRoots = params.ackMode.kind === 'folded-private'
        ? params.ackMode.privateMerkleRoots.map((root) => new Uint8Array(root))
        : undefined;
      return realProvider(params);
    };

    const result = await pubS(publisher, {
      contextGraphId: CONTEXT_GRAPH,
      publisherPeerId: '12D3KooWPrivateConfirm',
      quads: publicQuads,
      privateQuads,
      v10ACKProvider,
    });

    expect(result.status).toBe('confirmed');
    expect(result.kaId > 0n).toBe(true);
    expect(result.kaManifest[0]?.privateTripleCount).toBe(1);
    expect(result.kaManifest[0]?.privateMerkleRoot).toBeDefined();
    expect(receivedPrivateRoots).toHaveLength(1);
    expect(receivedPrivateRoots![0]).toEqual(expectedPrivateRoot);
  });

  it('rejects encrypted private publishes before ACK collection even without a catalog floor', async () => {
    const store = new OxigraphStore();
    const chain = createEVMAdapter(HARDHAT_KEYS.CORE_OP);
    const curatedCg = String(await createTestContextGraph(chain, undefined, 1, 0));
    const curatedGraph = `did:dkg:context-graph:${curatedCg}`;
    const bus = new TypedEventBus();
    const keypair = await generateEd25519Keypair();

    const publisher = new DKGPublisher({
      kaAllocator: makeTestKaAllocator(),
      store, chain, eventBus: bus, keypair,
      publisherPrivateKey: HARDHAT_KEYS.CORE_OP,
      publisherNodeIdentityId: BigInt(getSharedContext().coreProfileId),
    });

    const entity = 'did:dkg:agent:CuratedFoldedPrivateUnsupported';
    let ackCalled = false;
    let encryptCalled = false;

    await expect(pubS(publisher, {
      contextGraphId: curatedCg,
      publisherPeerId: '12D3KooWCuratedPrivateUnsupported',
      quads: [
        q(entity, 'http://schema.org/name', '"CuratedFoldedPrivateUnsupported"', curatedGraph),
      ],
      privateQuads: [
        q(entity, 'http://dkg.io/ontology/secret', '"hidden"', curatedGraph),
      ],
      encryptInlinePayload: async (plaintext) => {
        encryptCalled = true;
        return plaintext;
      },
      v10ACKProvider: async () => {
        ackCalled = true;
        return [];
      },
    })).rejects.toThrow(/Encrypted inline publishes with privateQuads are not supported/);

    expect(encryptCalled).toBe(false);
    expect(ackCalled).toBe(false);
  });

  it('stores distinct KC metadata for each tentative publish', async () => {
    const store = new OxigraphStore();
    const chain = createEVMAdapter(HARDHAT_KEYS.CORE_OP);
    const bus = new TypedEventBus();
    const keypair = await generateEd25519Keypair();

    const publisher = new DKGPublisher({
      kaAllocator: makeTestKaAllocator(),
      store, chain, eventBus: bus, keypair,
      publisherPrivateKey: HARDHAT_KEYS.CORE_OP,
      publisherNodeIdentityId: BigInt(getSharedContext().coreProfileId),
    });

    // RC11 / PR3: same trigger swap as above (use a non-numeric CG
    // label to force `finalizeIntentionalLocalPublish` via the
    // "no on-chain CG id" branch).
    for (let i = 0; i < 3; i++) {
      const cgLabel = `swm-tentative-meta-${i}`;
      await publisher.publish({
        contextGraphId: cgLabel,
        quads: [{
          subject: `did:dkg:agent:Meta${i}`,
          predicate: 'http://schema.org/name',
          object: `"MetaEntity${i}"`,
          graph: `did:dkg:context-graph:${cgLabel}`,
        }],
      });
    }

    // RFC ka-metadata-trim: KC rows no longer carry `rdf:type
    // dkg:KnowledgeCollection` — a KC is identified by its `dkg:status` row
    // (the same predicate the daemon counters use).
    const result = await store.query(
      `SELECT (COUNT(DISTINCT ?kc) AS ?c) WHERE {
        GRAPH ?g { ?kc <http://dkg.io/ontology/status> ?s }
      }`,
    );

    expect(result.type).toBe('bindings');
    if (result.type === 'bindings') {
      const raw = result.bindings[0]['c'];
      const count = parseInt(raw.match(/^"?(\d+)/)?.[1] ?? '0', 10);
      expect(count).toBe(3);
    }
  });

  // RC11 / PR-A (Codex review fix on #671, comment 3301913220): the
  // `finalizeIntentionalLocalPublish` helper that handles the three
  // intentional-local branches MUST preserve the same provenance and
  // meta-graph-remap behaviour the pre-PR2 catch-block had. Without
  // this, intentional-local publishes silently drop their author
  // attribution and (when the caller supplies a `targetMetaGraphUri`)
  // write their `_meta` triples into the wrong graph. These two
  // regressions pin the fix.
  //
  // RFC ka-metadata-trim Phase 1: the `dkg:Publication` / `dkg:authoredBy`
  // mirror was dropped (zero readers); author attribution is now carried
  // solely by `prov:wasAttributedTo` on the KC row, which this test pins.
  //
  // These tests use the non-on-chain-CG intentional-local branch. That branch
  // does not resolve an on-chain publisher signer by context graph id, so this
  // fixture supplies the author address through the precomputed attestation
  // lane that production callers already use.
  it('intentional-local tentative publish (no-chain branch) attributes the author via prov:wasAttributedTo (RC11 / PR-A)', async () => {
    const store = new OxigraphStore();
    const chain = createEVMAdapter(HARDHAT_KEYS.CORE_OP);
    const bus = new TypedEventBus();
    const keypair = await generateEd25519Keypair();

    const publisher = new DKGPublisher({
      kaAllocator: makeTestKaAllocator(),
      store, chain, eventBus: bus, keypair,
      publisherPrivateKey: HARDHAT_KEYS.CORE_OP,
      publisherNodeIdentityId: BigInt(getSharedContext().coreProfileId),
    });

    const localContextGraph = 'local-provenance-cg';
    const localGraph = `did:dkg:context-graph:${localContextGraph}`;
    const result = await publisher.publish(await _withSeal({
      contextGraphId: localContextGraph,
      publisherPeerId: '12D3KooWTestProvenance',
      quads: [
        q('did:dkg:agent:ProvenanceProbe', 'http://schema.org/name', '"ProvenanceProbe"', localGraph),
      ],
      privateQuads: [
        q('did:dkg:agent:ProvenanceProbe', 'http://dkg.io/ontology/secret', '"hidden"', localGraph),
      ],
    }, _author, { provider: _provider, kav10Address: _kav10Address }));

    expect(result.status).toBe('tentative');

    // RFC ka-metadata-trim: no dkg:Publication subjects are emitted anymore.
    const pubResult = await store.query(
      `SELECT ?pub WHERE {
        GRAPH ?g { ?pub a <http://dkg.io/ontology/Publication> }
      }`,
    );
    expect(pubResult.type).toBe('bindings');
    if (pubResult.type === 'bindings') {
      expect(pubResult.bindings.length).toBe(0);
    }

    // Author attribution survives on the KC row as prov:wasAttributedTo
    // (agent DID with lowercased EVM address).
    const authorResult = await store.query(
      `SELECT ?author WHERE {
        GRAPH ?g {
          ?kc <http://dkg.io/ontology/status> ?s .
          ?kc <http://www.w3.org/ns/prov#wasAttributedTo> ?author .
        }
      }`,
    );
    expect(authorResult.type).toBe('bindings');
    if (authorResult.type === 'bindings') {
      expect(authorResult.bindings.length).toBeGreaterThan(0);
      const expectedAddr = new ethers.Wallet(HARDHAT_KEYS.CORE_OP).address;
      const authors = authorResult.bindings.map((b) => b['author'].replace(/^"|"$/g, ''));
      expect(authors).toContain(`did:dkg:agent:${expectedAddr.toLowerCase()}`);
    }
  });

  it('intentional-local tentative publish (no-chain branch) remaps _meta quads to options.targetMetaGraphUri (RC11 / PR-A)', async () => {
    const store = new OxigraphStore();
    const chain = createEVMAdapter(HARDHAT_KEYS.CORE_OP);
    const bus = new TypedEventBus();
    const keypair = await generateEd25519Keypair();

    const publisher = new DKGPublisher({
      kaAllocator: makeTestKaAllocator(),
      store, chain, eventBus: bus, keypair,
      publisherPrivateKey: HARDHAT_KEYS.CORE_OP,
      publisherNodeIdentityId: BigInt(getSharedContext().coreProfileId),
    });

    const localContextGraph = 'local-meta-remap-cg';
    const defaultMetaGraph = `did:dkg:context-graph:${localContextGraph}/_meta`;
    const customMetaGraph = `did:dkg:context-graph:${localContextGraph}/sub-channel/_shared_memory_meta`;

    const result = await publisher.publish({
      contextGraphId: localContextGraph,
      publisherPeerId: '12D3KooWTestMetaRemap',
      quads: [
        q('did:dkg:agent:MetaRemap', 'http://schema.org/name', '"MetaRemap"', `did:dkg:context-graph:${localContextGraph}`),
      ],
      privateQuads: [
        q('did:dkg:agent:MetaRemap', 'http://dkg.io/ontology/secret', '"hidden"', `did:dkg:context-graph:${localContextGraph}`),
      ],
      targetMetaGraphUri: customMetaGraph,
    });

    expect(result.status).toBe('tentative');

    const inCustom = await store.query(
      `SELECT (COUNT(*) AS ?c) WHERE {
        GRAPH <${customMetaGraph}> { ?s ?p ?o }
      }`,
    );
    const inDefault = await store.query(
      `SELECT (COUNT(*) AS ?c) WHERE {
        GRAPH <${defaultMetaGraph}> { ?s ?p ?o }
      }`,
    );
    const customCount = inCustom.type === 'bindings'
      ? parseInt(inCustom.bindings[0]['c'].match(/^"?(\d+)/)?.[1] ?? '0', 10)
      : 0;
    const defaultCount = inDefault.type === 'bindings'
      ? parseInt(inDefault.bindings[0]['c'].match(/^"?(\d+)/)?.[1] ?? '0', 10)
      : 0;

    // The `_meta` quads must land in the caller-supplied target, not
    // the default `_meta` graph — this is the remap PR2's first cut
    // dropped and Codex flagged.
    expect(customCount).toBeGreaterThan(0);
    expect(defaultCount).toBe(0);
  });

  it('publish invokes onPhase for every major step', async () => {
    const store = new OxigraphStore();
    const chain = createEVMAdapter(HARDHAT_KEYS.CORE_OP);
    const bus = new TypedEventBus();
    const keypair = await generateEd25519Keypair();

    const publisher = new DKGPublisher({
      kaAllocator: makeTestKaAllocator(),
      store, chain, eventBus: bus, keypair,
      publisherPrivateKey: HARDHAT_KEYS.CORE_OP,
      publisherNodeIdentityId: BigInt(getSharedContext().coreProfileId),
    });

    const phases: [string, 'start' | 'end'][] = [];
    const onPhase = (phase: string, status: 'start' | 'end') => phases.push([phase, status]);

    await pubS(publisher, {
      contextGraphId: CONTEXT_GRAPH,
      quads: [q(ENTITY, 'http://schema.org/name', '"PhaseTest"')],
      onPhase,
    });

    const phaseNames = [...new Set(phases.filter(([, s]) => s === 'start').map(([p]) => p))];
    expect(phaseNames).toContain('prepare');
    expect(phaseNames).toContain('store');
    expect(phaseNames).toContain('chain');
    expect(phaseNames).toContain('prepare:ensureContextGraph');
    expect(phaseNames).toContain('prepare:partition');
    expect(phaseNames).toContain('prepare:manifest');
    expect(phaseNames).toContain('prepare:validate');
    expect(phaseNames).toContain('prepare:merkle');
  });

  it('update invokes onPhase for prepare → chain → store', async () => {
    const store = new OxigraphStore();
    const chain = createEVMAdapter(HARDHAT_KEYS.CORE_OP);
    const bus = new TypedEventBus();
    const keypair = await generateEd25519Keypair();

    const publisher = new DKGPublisher({
      kaAllocator: makeTestKaAllocator(),
      store, chain, eventBus: bus, keypair,
      publisherPrivateKey: HARDHAT_KEYS.CORE_OP,
      publisherNodeIdentityId: BigInt(getSharedContext().coreProfileId),
    });

    const pub = await pubS(publisher, {
      contextGraphId: CONTEXT_GRAPH,
      quads: [q(ENTITY, 'http://schema.org/name', '"Original"')],
    });

    const phases: [string, 'start' | 'end'][] = [];
    const onPhase = (phase: string, status: 'start' | 'end') => phases.push([phase, status]);

    await updS(publisher, pub.kaId, {
      contextGraphId: CONTEXT_GRAPH,
      quads: [q(ENTITY, 'http://schema.org/name', '"Updated"')],
      onPhase,
    });

    const started = phases.filter(([, s]) => s === 'start').map(([p]) => p);
    expect(started).toContain('prepare');
    expect(started).toContain('chain');
    expect(started).toContain('store');
    expect(started).toContain('prepare:partition');
    expect(started).toContain('prepare:manifest');
    expect(started).toContain('prepare:merkle');
    expect(started).toContain('chain:submit');

    // update should NOT have prepare:ensureContextGraph or prepare:validate
    expect(started).not.toContain('prepare:ensureContextGraph');
    expect(started).not.toContain('prepare:validate');
  });

  it('V10: staging quads are passed inline to v10ACKProvider (spec §9.0)', async () => {
    const store = new OxigraphStore();
    const chain = createEVMAdapter(HARDHAT_KEYS.CORE_OP);
    const bus = new TypedEventBus();
    const keypair = await generateEd25519Keypair();

    const publisher = new DKGPublisher({
      kaAllocator: makeTestKaAllocator(),
      store, chain, eventBus: bus, keypair,
      publisherPrivateKey: HARDHAT_KEYS.CORE_OP,
      publisherNodeIdentityId: BigInt(getSharedContext().coreProfileId),
    });

    let receivedStagingQuads: Uint8Array | undefined;
    let receivedMerkleLeafCount = 0;
    // RC11 / PR3: the test originally returned `[]` from the spy and
    // relied on the deleted self-signed ACK fallback to keep the
    // publish from throwing. With PR1 + PR2 a stub ACK provider that
    // returns no ACKs will trigger the loud
    // "V10 ACKs required for on-chain publish" guard. Wrap the spy
    // around the real `hardhatACKProvider` so the chain submit
    // succeeds — we still get to inspect everything the publisher
    // hands to the provider on the way through.
    const realProvider = hardhatACKProvider(_kav10Address);
    const v10ACKProvider: Parameters<DKGPublisher['publish']>[0]['v10ACKProvider'] = async (params: V10ACKProviderParams) => {
      receivedStagingQuads = params.stagingQuads;
      receivedMerkleLeafCount = params.merkleLeafCount;
      return realProvider(params);
    };

    const phases: [string, 'start' | 'end'][] = [];
    const onPhase = (phase: string, status: 'start' | 'end') => phases.push([phase, status]);

    await pubS(publisher, {
      contextGraphId: CONTEXT_GRAPH,
      quads: [q(ENTITY, 'http://schema.org/name', '"V10 Staging Test"')],
      v10ACKProvider,
      onPhase,
    });

    expect(receivedStagingQuads).toBeDefined();
    expect(receivedStagingQuads!.length).toBeGreaterThan(0);
    expect(receivedMerkleLeafCount).toBeGreaterThan(0);
    const decoded = new TextDecoder().decode(receivedStagingQuads!);
    expect(decoded).toContain('V10 Staging Test');

    const started = phases.filter(([, s]) => s === 'start').map(([p]) => p);
    expect(started).toContain('collect_v10_acks');
  });

  it('V10: public fromSharedMemory option omits staging quads for strict SWM ACKs', async () => {
    const store = new OxigraphStore();
    const chain = createEVMAdapter(HARDHAT_KEYS.CORE_OP);
    const bus = new TypedEventBus();
    const keypair = await generateEd25519Keypair();

    const publisher = new DKGPublisher({
      kaAllocator: makeTestKaAllocator(),
      store, chain, eventBus: bus, keypair,
      publisherPrivateKey: HARDHAT_KEYS.CORE_OP,
      publisherNodeIdentityId: BigInt(getSharedContext().coreProfileId),
    });

    let receivedStagingQuads: Uint8Array | undefined;
    const realProvider = hardhatACKProvider(_kav10Address);
    const v10ACKProvider: Parameters<DKGPublisher['publish']>[0]['v10ACKProvider'] = async (params: V10ACKProviderParams) => {
      receivedStagingQuads = params.stagingQuads;
      expect(params.ackMode.kind).toBe('public');
      return realProvider(params);
    };

    const submitted = q(ENTITY, 'http://schema.org/name', '"fromSharedMemory Strict SWM"');
    const result = await pubS(publisher, {
      contextGraphId: CONTEXT_GRAPH,
      quads: [submitted],
      fromSharedMemory: true,
      v10ACKProvider,
    });

    expect(result.status).toBe('confirmed');
    expect(receivedStagingQuads).toBeUndefined();
  });

  it('V10: public publishes still support legacy positional v10ACKProvider callbacks', async () => {
    const store = new OxigraphStore();
    const chain = createEVMAdapter(HARDHAT_KEYS.CORE_OP);
    const bus = new TypedEventBus();
    const keypair = await generateEd25519Keypair();

    const publisher = new DKGPublisher({
      kaAllocator: makeTestKaAllocator(),
      store, chain, eventBus: bus, keypair,
      publisherPrivateKey: HARDHAT_KEYS.CORE_OP,
      publisherNodeIdentityId: BigInt(getSharedContext().coreProfileId),
    });

    let receivedContextGraphId: string | undefined;
    let receivedKaCount = 0;
    let receivedRootEntities: string[] = [];
    let receivedStagingQuads: Uint8Array | undefined;
    let receivedMerkleLeafCount = 0;
    const realProvider = hardhatACKProvider(_kav10Address);
    const v10ACKProvider: LegacyV10ACKProvider = async (
      merkleRoot,
      contextGraphId,
      kaCount,
      rootEntities,
      publicByteSize,
      stagingQuads,
      epochs,
      tokenAmount,
      swmGraphId,
      subGraphName,
      merkleLeafCount,
      isEncryptedPayload,
      catalogCommitment,
    ) => {
      receivedContextGraphId = contextGraphId;
      receivedKaCount = kaCount;
      receivedRootEntities = [...rootEntities];
      receivedStagingQuads = stagingQuads;
      receivedMerkleLeafCount = merkleLeafCount;
      expect(isEncryptedPayload).toBeUndefined();
      expect(catalogCommitment).toBeUndefined();
      return realProvider({
        merkleRoot,
        contextGraphId,
        kaCount,
        rootEntities,
        publicByteSize,
        stagingQuads,
        epochs,
        tokenAmount,
        swmGraphId,
        subGraphName,
        merkleLeafCount,
        ackMode: { kind: 'public' },
      });
    };

    const result = await pubS(publisher, {
      contextGraphId: CONTEXT_GRAPH,
      quads: [q(ENTITY, 'http://schema.org/name', '"Legacy ACK Provider"')],
      v10ACKProvider,
    });

    expect(result.status).toBe('confirmed');
    expect(receivedContextGraphId).toBe(CONTEXT_GRAPH);
    expect(receivedKaCount).toBe(1);
    expect(receivedRootEntities).toEqual([ENTITY]);
    expect(receivedStagingQuads).toBeDefined();
    expect(receivedMerkleLeafCount).toBeGreaterThan(0);
  });

  // -------------------------------------------------------------------------
  // T-OVERRIDE: per-publish `publisherNodeIdentityIdOverride` regression suite
  //
  // Round-3 review feedback (PR #436, @branarakic): the override must
  // influence the EARLY on-chain attempt gate.
  //
  //   "This late gate now treats an explicit override as enough to attempt
  //    the chain path, but the earlier `willAttemptOnChainPublish`
  //    calculation still requires `this.publisherNodeIdentityId > 0n`
  //    before resolving `publisherSigner` or precomputing `tokenAmount`.
  //    A daemon with persistent identity `0n` and `publisherNodeIdentityIdOverride`
  //    set ... reaches this chain branch with `publisherSigner === undefined`
  //    and fails with `PublisherWalletRequiredError`."
  //
  // The fix hoists `hasAttributionOverride` into the early gate while
  // intentionally KEEPING self-ACK tied to the daemon identity (per the
  // reviewer's "while keeping self-ACK tied to the daemon identity"
  // guidance). Tests 1+2 below pin both halves of that fix.
  // -------------------------------------------------------------------------
  it('T-OVERRIDE: mode (d) — daemon has identity, override=0n confirms on-chain with no attribution', async () => {
    // Primary mode-(d) e2e: a real running daemon (identity=coreProfileId)
    // explicitly publishes with `override=0n` to mean "no node attribution
    // for THIS publish". The chain accepts it (T-VAL skips epoch storage),
    // self-ACK signs as the daemon's real id, status becomes confirmed.
    const store = new OxigraphStore();
    const chain = createEVMAdapter(HARDHAT_KEYS.CORE_OP);
    const bus = new TypedEventBus();
    const keypair = await generateEd25519Keypair();
    const realCoreId = BigInt(getSharedContext().coreProfileId);

    const publisher = new DKGPublisher({
      kaAllocator: makeTestKaAllocator(),
      store, chain, eventBus: bus, keypair,
      publisherPrivateKey: HARDHAT_KEYS.CORE_OP,
      publisherNodeIdentityId: realCoreId,
    });

    const result = await pubS(publisher, {
      contextGraphId: CONTEXT_GRAPH,
      quads: [q('did:dkg:agent:OverrideMode-D', 'http://schema.org/name', '"OverrideD"')],
      publisherNodeIdentityIdOverride: 0n,
    });

    expect(result.status).toBe('confirmed');
    expect(result.onChainResult).toBeDefined();
    expect(result.onChainResult!.txHash).toBeTruthy();
  });

  it('T-OVERRIDE: daemon identityId=0 + override does NOT throw PublisherWalletRequiredError', async () => {
    // Direct regression of the round-3 review thread. Pre-fix path:
    //   willAttemptOnChainPublish = `this.publisherNodeIdentityId > 0n` = false
    //   → publisherSigner never resolves
    //   → late gate (which sees the override) enters the chain branch
    //   → throws `PublisherWalletRequiredError`.
    // Post-fix: early gate honors the override, publisherSigner resolves,
    // the publish call MUST resolve cleanly without throwing
    // PublisherWalletRequiredError specifically.
    //
    // RC11 / PR3: switched from a numeric CG with an unregistered
    // override id (7n, would revert chain-side with "publisherNodeIdentityId
    // not in sharding table") to a non-numeric CG label so the
    // intentional-local-only branch catches it without engaging the
    // chain at all. The regression we're pinning is "the early
    // publisherSigner-resolution gate honors the override and so
    // does not crash" — which still triggers as long as the publish
    // returns without throwing PublisherWalletRequiredError.
    const store = new OxigraphStore();
    const chain = createEVMAdapter(HARDHAT_KEYS.CORE_OP);
    const bus = new TypedEventBus();
    const keypair = await generateEd25519Keypair();

    const publisher = new DKGPublisher({
      kaAllocator: makeTestKaAllocator(),
      store, chain, eventBus: bus, keypair,
      publisherPrivateKey: HARDHAT_KEYS.CORE_OP,
      publisherNodeIdentityId: 0n,
    });

    await expect(
      publisher.publish({
        contextGraphId: 'swm-override-early-gate',
        quads: [{
          subject: 'did:dkg:agent:OverrideEarlyGate',
          predicate: 'http://schema.org/name',
          object: '"OverrideEG"',
          graph: 'did:dkg:context-graph:swm-override-early-gate',
        }],
        publisherNodeIdentityIdOverride: 7n,
      }),
    ).resolves.toMatchObject({ status: expect.any(String) });
  });

  it('T-OVERRIDE: daemon identityId=0 with NO override stays tentative (legacy behavior preserved)', async () => {
    // Sanity check that the gate change didn't accidentally enable
    // on-chain publishes for the legacy "daemon has no identity, no
    // override" path. RC11 / PR3: same non-numeric-CG trigger swap
    // as the sibling tests above.
    const store = new OxigraphStore();
    const chain = createEVMAdapter(HARDHAT_KEYS.CORE_OP);
    const bus = new TypedEventBus();
    const keypair = await generateEd25519Keypair();

    const publisher = new DKGPublisher({
      kaAllocator: makeTestKaAllocator(),
      store, chain, eventBus: bus, keypair,
      publisherPrivateKey: HARDHAT_KEYS.CORE_OP,
      publisherNodeIdentityId: 0n,
    });

    const result = await publisher.publish({
      contextGraphId: 'swm-override-no-override',
      quads: [{
        subject: 'did:dkg:agent:OverrideNoOverride',
        predicate: 'http://schema.org/name',
        object: '"OverrideNone"',
        graph: 'did:dkg:context-graph:swm-override-no-override',
      }],
    });

    expect(result.status).toBe('tentative');
    expect(result.ual).toContain('/t');
  });
});
