/**
 * Publisher unit tests for the "replicate-then-publish" protocol:
 *
 * 1. collectReceiverSignatures(): request receiver sigs from peers via libp2p
 * 2. collectParticipantSignatures(): request context graph participant sigs
 * 3. Reordered publish flow: prepare → replicate → collect sigs → on-chain tx
 * 4. Timeout / insufficient signature handling
 */
import { describe, it, expect, beforeEach, beforeAll, afterAll } from 'vitest';
import { OxigraphStore, type Quad } from '@origintrail-official/dkg-storage';
import { EVMChainAdapter } from '@origintrail-official/dkg-chain';
import { TypedEventBus, DKGEvent } from '@origintrail-official/dkg-core';
import { generateEd25519Keypair } from '@origintrail-official/dkg-core';
import { DKGPublisher } from '../src/index.js';
import { ethers } from 'ethers';
import { createEVMAdapter, getSharedContext, createProvider, takeSnapshot, revertSnapshot, createTestContextGraph, seedContextGraphRegistration, HARDHAT_KEYS } from '../../chain/test/evm-test-context.js';
import { mintTokens } from '../../chain/test/hardhat-harness.js';
import { wrapPublisherForTest, buildSeal } from './_helpers/seal.js';

let CONTEXT_GRAPH: string;
let _kav10Address: string;
let _provider: ethers.JsonRpcProvider;
const _author = new ethers.Wallet(HARDHAT_KEYS.CORE_OP);
const ENTITY = 'urn:test:sigcollect:entity:1';

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

// `wrapPublisherForTest` intentionally skips `publishFromSharedMemory`
// (the wrapper can't synthesize the SWM-selection quads from the
// rootEntities argument alone), so explicit seals are required here.
// The seal must be bound to the publish's effective on-chain CG id
// (`publishContextGraphId`), not the local CG label.
async function sealForPublishFromSWM(
  rootEntities: string[],
  triplesByRoot: Quad[],
  onChainCgId: string | bigint,
) {
  const matched = triplesByRoot.filter((quad) => rootEntities.includes(quad.subject));
  return buildSeal({
    quads: matched,
    author: _author,
    contextGraphId: onChainCgId,
    ctx: { provider: _provider, kav10Address: _kav10Address },
  });
}

/**
 * In-process signer peer used by the receiver/participant signature-collection
 * unit tests. The name has nothing to do with mocking — every cryptographic
 * primitive below is **real**:
 *   • `ethers.Wallet.createRandom()` produces a real secp256k1 key.
 *   • `signMessage` runs real EIP-191 prefixed ECDSA signing.
 *   • The returned (r, vs) are byte-for-byte the values an on-chain
 *     `ecrecover` consumes.
 *
 * What is in-process is only the libp2p transport that would normally carry
 * the signing request between peers — the publisher's signing-request
 * responder (`mockPeerResponder` below) calls this class directly instead of
 * round-tripping through libp2p streams. That transport is exercised
 * end-to-end in `packages/agent/test/e2e-*.test.ts`.
 *
 * Renamed from `LocalSignerPeer` so the suite no longer misleads auditors
 * scanning for hidden mocks.
 */
class LocalSignerPeer {
  readonly wallet: ethers.Wallet;
  readonly identityId: bigint;

  constructor(identityId: bigint) {
    this.wallet = ethers.Wallet.createRandom();
    this.identityId = identityId;
  }

  async signReceiverAck(merkleRoot: string, publicByteSize: bigint) {
    const msgHash = ethers.solidityPackedKeccak256(
      ['bytes32', 'uint64'],
      [merkleRoot, publicByteSize],
    );
    const sig = ethers.Signature.from(
      await this.wallet.signMessage(ethers.getBytes(msgHash)),
    );
    return {
      identityId: this.identityId,
      r: ethers.getBytes(sig.r),
      vs: ethers.getBytes(sig.yParityAndS),
    };
  }

  async signParticipantAck(contextGraphId: bigint, merkleRoot: string) {
    const digest = ethers.solidityPackedKeccak256(
      ['uint256', 'bytes32'],
      [contextGraphId, merkleRoot],
    );
    const sig = ethers.Signature.from(
      await this.wallet.signMessage(ethers.getBytes(digest)),
    );
    return {
      identityId: this.identityId,
      r: ethers.getBytes(sig.r),
      vs: ethers.getBytes(sig.yParityAndS),
    };
  }
}

describe('Signature Collection Protocol', () => {
  let store: OxigraphStore;
  let chain: EVMChainAdapter;
  let publisher: DKGPublisher;
  let eventBus: TypedEventBus;
  const publisherWallet = new ethers.Wallet(HARDHAT_KEYS.CORE_OP);
  let snapshotId: string;

  beforeAll(async () => {
    snapshotId = await takeSnapshot();
    const { hubAddress } = getSharedContext();
    const provider = createProvider();
    await mintTokens(provider, hubAddress, HARDHAT_KEYS.DEPLOYER, publisherWallet.address, ethers.parseEther('5000000'));
    if (!_provider) _provider = provider;
    if (!_kav10Address) {
      const c = createEVMAdapter(HARDHAT_KEYS.CORE_OP);
      _kav10Address = await c.getKnowledgeAssetsV10Address();
    }
  });

  afterAll(async () => {
    await revertSnapshot(snapshotId);
  });

  beforeEach(async () => {
    store = new OxigraphStore();
    chain = createEVMAdapter(HARDHAT_KEYS.CORE_OP);
    eventBus = new TypedEventBus();
    const keypair = await generateEd25519Keypair();
    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 },
    });
  });

  describe('collectReceiverSignatures', () => {
    it('collects signatures from mock peers and returns them', async () => {
      const peer1 = new LocalSignerPeer(2n);
      const peer2 = new LocalSignerPeer(3n);

      const merkleRoot = ethers.keccak256(ethers.toUtf8Bytes('test-root'));
      const publicByteSize = 1000n;

      const mockPeerResponder = async (
        _peerId: string,
        merkleRoot: string,
        publicByteSize: bigint,
      ) => {
        const sigs = await Promise.all([
          peer1.signReceiverAck(merkleRoot, publicByteSize),
          peer2.signReceiverAck(merkleRoot, publicByteSize),
        ]);
        return sigs;
      };

      const signatures = await publisher.collectReceiverSignatures({
        merkleRoot,
        publicByteSize,
        peerResponder: mockPeerResponder,
        minimumRequired: 2,
        timeoutMs: 5000,
      });

      expect(signatures).toHaveLength(2);
      expect(signatures[0].identityId).toBe(2n);
      expect(signatures[1].identityId).toBe(3n);
      expect(signatures[0].r).toBeInstanceOf(Uint8Array);
      expect(signatures[0].vs).toBeInstanceOf(Uint8Array);
    });

    it('throws when minimum required signatures not met within timeout', async () => {
      const peer1 = new LocalSignerPeer(2n);
      const merkleRoot = ethers.keccak256(ethers.toUtf8Bytes('timeout-root'));
      const publicByteSize = 500n;

      const mockPeerResponder = async () => {
        return [await peer1.signReceiverAck(merkleRoot, publicByteSize)];
      };

      await expect(
        publisher.collectReceiverSignatures({
          merkleRoot,
          publicByteSize,
          peerResponder: mockPeerResponder,
          minimumRequired: 2,
          timeoutMs: 100,
        }),
      ).rejects.toThrow(/insufficient.*signatures|timeout/i);
    });

    it('deduplicates signatures from the same identityId', async () => {
      const peer1 = new LocalSignerPeer(2n);
      const merkleRoot = ethers.keccak256(ethers.toUtf8Bytes('dedup-root'));
      const publicByteSize = 500n;

      const sig1 = await peer1.signReceiverAck(merkleRoot, publicByteSize);
      const mockPeerResponder = async () => [sig1, sig1];

      const signatures = await publisher.collectReceiverSignatures({
        merkleRoot,
        publicByteSize,
        peerResponder: mockPeerResponder,
        minimumRequired: 1,
        timeoutMs: 5000,
      });

      expect(signatures).toHaveLength(1);
    });
  });

  describe('collectParticipantSignatures', () => {
    it('collects context graph participant signatures', async () => {
      const participant1 = new LocalSignerPeer(10n);
      const participant2 = new LocalSignerPeer(11n);

      const contextGraphId = 42n;
      const merkleRoot = ethers.keccak256(ethers.toUtf8Bytes('ctx-root'));

      const mockResponder = async () => {
        return Promise.all([
          participant1.signParticipantAck(contextGraphId, merkleRoot),
          participant2.signParticipantAck(contextGraphId, merkleRoot),
        ]);
      };

      const signatures = await publisher.collectParticipantSignatures({
        contextGraphId,
        merkleRoot,
        participantResponder: mockResponder,
        minimumRequired: 2,
        timeoutMs: 5000,
      });

      expect(signatures).toHaveLength(2);
      expect(signatures[0].identityId).toBe(10n);
      expect(signatures[1].identityId).toBe(11n);
    });

    it('throws when not enough participant signatures', async () => {
      const participant1 = new LocalSignerPeer(10n);
      const contextGraphId = 42n;
      const merkleRoot = ethers.keccak256(ethers.toUtf8Bytes('ctx-insuf'));

      const mockResponder = async () => {
        return [await participant1.signParticipantAck(contextGraphId, merkleRoot)];
      };

      await expect(
        publisher.collectParticipantSignatures({
          contextGraphId,
          merkleRoot,
          participantResponder: mockResponder,
          minimumRequired: 2,
          timeoutMs: 100,
        }),
      ).rejects.toThrow(/insufficient.*signatures|timeout/i);
    });
  });
});

describe('Reordered Publish Flow (replicate-then-publish)', () => {
  let store: OxigraphStore;
  let chain: EVMChainAdapter;
  let publisher: DKGPublisher;
  let eventBus: TypedEventBus;
  const publisherWallet = new ethers.Wallet(HARDHAT_KEYS.CORE_OP);
  let snapshotId: string;

  beforeAll(async () => {
    snapshotId = await takeSnapshot();
    const { hubAddress } = getSharedContext();
    const provider = createProvider();
    await mintTokens(provider, hubAddress, HARDHAT_KEYS.DEPLOYER, publisherWallet.address, ethers.parseEther('5000000'));

    const cgChain = createEVMAdapter(HARDHAT_KEYS.CORE_OP);
    const cgId = await createTestContextGraph(cgChain);
    CONTEXT_GRAPH = String(cgId);
    if (!_provider) _provider = provider;
    if (!_kav10Address) _kav10Address = await cgChain.getKnowledgeAssetsV10Address();
  });

  afterAll(async () => {
    await revertSnapshot(snapshotId);
  });

  beforeEach(async () => {
    store = new OxigraphStore();
    chain = createEVMAdapter(HARDHAT_KEYS.CORE_OP);
    eventBus = new TypedEventBus();
    const keypair = await generateEd25519Keypair();
    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 },
    });
  });

  it('publish() follows prepare → store → chain order with self-signed V10 ACK', async () => {
    const phases: string[] = [];

    const quads: Quad[] = [
      q(ENTITY, 'http://schema.org/name', '"Reorder Test"'),
    ];

    const result = await publisher.publish({
      contextGraphId: CONTEXT_GRAPH,
      quads,
      onPhase: (phase, event) => {
        phases.push(`${phase}:${event}`);
      },
    });

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

    const prepareIdx = phases.indexOf('prepare:start');
    const storeIdx = phases.indexOf('store:start');
    const chainIdx = phases.indexOf('chain:start');

    expect(prepareIdx).toBeLessThan(storeIdx);
    expect(storeIdx).toBeLessThan(chainIdx);
  });

  it('publish() uses V10 createKnowledgeAssetsV10 path and includes ACK signatures', async () => {
    const quads: Quad[] = [
      q(ENTITY, 'http://schema.org/name', '"V10 Path Test"'),
    ];

    const result = await publisher.publish({
      contextGraphId: CONTEXT_GRAPH,
      quads,
    });

    expect(result.status).toBe('confirmed');
    expect(result.onChainResult).toBeDefined();
    expect(result.onChainResult!.batchId).toBeGreaterThan(0n);
  });

  it('publish() self-signs ACK when no v10ACKProvider (single-node mode)', async () => {
    const quads: Quad[] = [
      q(ENTITY, 'http://schema.org/name', '"Self-sign ACK Test"'),
    ];

    const result = await publisher.publish({
      contextGraphId: CONTEXT_GRAPH,
      quads,
    });

    expect(result.status).toBe('confirmed');
    expect(result.onChainResult).toBeDefined();
    expect(result.onChainResult!.batchId).toBeGreaterThan(0n);
  });

  it('publish() emits PUBLISH_FAILED event when V10 chain call fails', async () => {
    const events: any[] = [];
    eventBus.on(DKGEvent.PUBLISH_FAILED, (data) => events.push(data));

    // With the real EVMChainAdapter we cannot monkey-patch createKnowledgeAssetsV10.
    // Instead, we publish with an invalid/insufficient token amount to trigger a chain rejection.
    // The publisher should catch the error and return tentative status.
    const quads: Quad[] = [
      q(ENTITY, 'http://schema.org/name', '"Fail Test"'),
    ];

    // Use an adapter with a key that has no tokens/stake to provoke chain failure
    const failChain = createEVMAdapter(HARDHAT_KEYS.EXTRA1);
    const keypair = await generateEd25519Keypair();
    const failPublisher = new DKGPublisher({
      store,
      chain: failChain,
      eventBus,
      keypair,
      publisherPrivateKey: HARDHAT_KEYS.EXTRA1,
      publisherNodeIdentityId: BigInt(getSharedContext().coreProfileId),
    });

    const result = await failPublisher.publish({
      contextGraphId: CONTEXT_GRAPH,
      quads,
    });

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

describe('Context Graph Enshrinement with Signatures', () => {
  let store: OxigraphStore;
  let chain: EVMChainAdapter;
  let publisher: DKGPublisher;
  let eventBus: TypedEventBus;
  const publisherWallet = new ethers.Wallet(HARDHAT_KEYS.CORE_OP);
  let snapshotId: string;

  beforeAll(async () => {
    snapshotId = await takeSnapshot();
    const { hubAddress } = getSharedContext();
    const provider = createProvider();
    await mintTokens(provider, hubAddress, HARDHAT_KEYS.DEPLOYER, publisherWallet.address, ethers.parseEther('5000000'));
    if (!_provider) _provider = provider;
    if (!_kav10Address) {
      const c = createEVMAdapter(HARDHAT_KEYS.CORE_OP);
      _kav10Address = await c.getKnowledgeAssetsV10Address();
    }
  });

  afterAll(async () => {
    await revertSnapshot(snapshotId);
  });

  beforeEach(async () => {
    store = new OxigraphStore();
    chain = createEVMAdapter(HARDHAT_KEYS.CORE_OP);
    eventBus = new TypedEventBus();
    const keypair = await generateEd25519Keypair();
    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 },
    });

    const cgResult = await chain.createOnChainContextGraph({
      participantIdentityIds: [1n, 2n],
      requiredSignatures: 1,
    });
    CONTEXT_GRAPH = String(cgResult.contextGraphId);
    await seedContextGraphRegistration(store, CONTEXT_GRAPH);
  });

  it('publishFromSharedMemory registers batch in context graph', async () => {
    const swmQuads = [q(ENTITY, 'http://schema.org/name', '"Context Data"')];
    await publisher.share(CONTEXT_GRAPH, swmQuads, { publisherPeerId: 'test-peer' });

    const participant = new LocalSignerPeer(2n);

    const result = await publisher.publishFromSharedMemory(CONTEXT_GRAPH, {
      rootEntities: [ENTITY],
    }, {
      publishContextGraphId: '1',
      contextGraphSignatures: [
        await participant.signParticipantAck(
          1n,
          ethers.keccak256(ethers.toUtf8Bytes('placeholder')),
        ),
      ],
      precomputedAttestation: await sealForPublishFromSWM([ENTITY], swmQuads, '1'),
    });

    // Test title claims the batch is REGISTERED in the context graph.
    // `toBeDefined` alone was green for any non-null return, including
    // "tentative" (chain rejected) or an empty result. Assert the
    // publish is actually confirmed on-chain AND carries concrete
    // registration evidence: a 66-char tx hash, a positive batchId,
    // and the correct publisher address.
    expect(result.status).toBe('confirmed');
    expect(result.onChainResult).toBeDefined();
    expect(result.onChainResult!.txHash).toMatch(/^0x[0-9a-fA-F]{64}$/);
    expect(result.onChainResult!.batchId).toBeGreaterThan(0n);
    expect(result.onChainResult!.publisherAddress.toLowerCase())
      .toBe(new ethers.Wallet(HARDHAT_KEYS.CORE_OP).address.toLowerCase());
  });

  it('publishToContextGraph available on EVMChainAdapter for atomic path', async () => {
    expect(typeof chain.publishToContextGraph).toBe('function');
  });
});

describe('PublishToContextGraph chain adapter method', () => {
  let snapshotId: string;

  beforeAll(async () => {
    snapshotId = await takeSnapshot();
    const { hubAddress } = getSharedContext();
    const provider = createProvider();
    const pubAddr = new ethers.Wallet(HARDHAT_KEYS.CORE_OP).address;
    await mintTokens(provider, hubAddress, HARDHAT_KEYS.DEPLOYER, pubAddr, ethers.parseEther('5000000'));
  });

  afterAll(async () => {
    await revertSnapshot(snapshotId);
  });

  it('EVMChainAdapter should expose publishToContextGraph', () => {
    const chain = createEVMAdapter(HARDHAT_KEYS.CORE_OP);
    expect(typeof chain.publishToContextGraph).toBe('function');
  });

  it('publishToContextGraph delegates to V10 publishDirect and returns batchId', async () => {
    const store = new OxigraphStore();
    const chain = createEVMAdapter(HARDHAT_KEYS.CORE_OP);
    const eventBus = new TypedEventBus();
    const keypair = await generateEd25519Keypair();
    const _publisherRaw = new DKGPublisher({
      store,
      chain,
      eventBus,
      keypair,
      publisherPrivateKey: HARDHAT_KEYS.CORE_OP,
      publisherNodeIdentityId: BigInt(getSharedContext().coreProfileId),
    });
    const publisher = wrapPublisherForTest(_publisherRaw, {
      author: _author,
      ctx: { provider: _provider, kav10Address: _kav10Address },
    });

    const { contextGraphId } = await chain.createOnChainContextGraph({
      participantIdentityIds: [BigInt(getSharedContext().coreProfileId)],
      requiredSignatures: 1,
    });

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

    expect(result.status).toBe('confirmed');
    expect(result.onChainResult).toBeDefined();
    expect(result.onChainResult!.batchId).toBeGreaterThan(0n);
  });
});

describe('Regression: sorted and deduplicated participant signatures', () => {
  let store: OxigraphStore;
  let chain: EVMChainAdapter;
  let publisher: DKGPublisher;
  let eventBus: TypedEventBus;
  const publisherWallet = new ethers.Wallet(HARDHAT_KEYS.CORE_OP);
  let snapshotId: string;

  beforeAll(async () => {
    snapshotId = await takeSnapshot();
    const { hubAddress } = getSharedContext();
    const provider = createProvider();
    await mintTokens(provider, hubAddress, HARDHAT_KEYS.DEPLOYER, publisherWallet.address, ethers.parseEther('5000000'));
    if (!_provider) _provider = provider;
    if (!_kav10Address) {
      const c = createEVMAdapter(HARDHAT_KEYS.CORE_OP);
      _kav10Address = await c.getKnowledgeAssetsV10Address();
    }
  });

  afterAll(async () => {
    await revertSnapshot(snapshotId);
  });

  beforeEach(async () => {
    store = new OxigraphStore();
    chain = createEVMAdapter(HARDHAT_KEYS.CORE_OP);
    eventBus = new TypedEventBus();
    const keypair = await generateEd25519Keypair();
    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 },
    });
    const cgResult = await chain.createOnChainContextGraph({
      participantIdentityIds: [1n, 3n, 5n],
      requiredSignatures: 1,
    });
    CONTEXT_GRAPH = String(cgResult.contextGraphId);
    await seedContextGraphRegistration(store, CONTEXT_GRAPH);
  });

  it('participant sigs are sorted by identityId before chain call (prevents contract revert)', async () => {
    const swmQuads = [q('urn:test:sort:1', 'http://schema.org/name', '"SortTest"')];
    await publisher.share(CONTEXT_GRAPH, swmQuads, { publisherPeerId: 'test-peer' });

    const peer5 = new LocalSignerPeer(5n);
    const peer1 = new LocalSignerPeer(1n);
    const peer3 = new LocalSignerPeer(3n);
    const root = ethers.keccak256(ethers.toUtf8Bytes('sort-test'));
    const sigs = [
      await peer5.signParticipantAck(1n, root),
      await peer1.signParticipantAck(1n, root),
      await peer3.signParticipantAck(1n, root),
    ];

    const result = await publisher.publishFromSharedMemory(CONTEXT_GRAPH, {
      rootEntities: ['urn:test:sort:1'],
    }, {
      publishContextGraphId: '1',
      contextGraphSignatures: sigs,
      precomputedAttestation: await sealForPublishFromSWM(['urn:test:sort:1'], swmQuads, '1'),
    });

    // Title guarantees "prevents contract revert" — `toBeDefined` was
    // green even when chain rejected and returned 'tentative'. Pin the
    // success invariant: publish must be confirmed and carry a real
    // tx hash + batchId, which only happens when the sort-and-dedup
    // logic produced an ordered participant-sig array the contract
    // accepted.
    expect(result.status).toBe('confirmed');
    expect(result.onChainResult).toBeDefined();
    expect(result.onChainResult!.txHash).toMatch(/^0x[0-9a-fA-F]{64}$/);
    expect(result.onChainResult!.batchId).toBeGreaterThan(0n);
  });

  it('duplicate identityId participant sigs are removed (prevents contract revert)', async () => {
    const swmQuads = [q('urn:test:dedup:1', 'http://schema.org/name', '"DedupTest"')];
    await publisher.share(CONTEXT_GRAPH, swmQuads, { publisherPeerId: 'test-peer' });

    const peer = new LocalSignerPeer(3n);
    const root = ethers.keccak256(ethers.toUtf8Bytes('dedup-test'));
    const sig = await peer.signParticipantAck(1n, root);
    const sigs = [sig, { ...sig }];

    const result = await publisher.publishFromSharedMemory(CONTEXT_GRAPH, {
      rootEntities: ['urn:test:dedup:1'],
    }, {
      publishContextGraphId: '1',
      contextGraphSignatures: sigs,
      precomputedAttestation: await sealForPublishFromSWM(['urn:test:dedup:1'], swmQuads, '1'),
    });

    // Title guarantees "prevents contract revert". A green
    // `toBeDefined` was compatible with the dedup regressing and the
    // chain rejecting — pin confirmed status + real tx evidence
    // instead, so a regression where duplicates slip through and the
    // contract reverts (publish returns 'tentative') fails loudly.
    expect(result.status).toBe('confirmed');
    expect(result.onChainResult).toBeDefined();
    expect(result.onChainResult!.txHash).toMatch(/^0x[0-9a-fA-F]{64}$/);
    expect(result.onChainResult!.batchId).toBeGreaterThan(0n);
  });
});

describe('Regression: complete publish result fields', () => {
  let snapshotId: string;

  beforeAll(async () => {
    snapshotId = await takeSnapshot();
    const { hubAddress } = getSharedContext();
    const provider = createProvider();
    const pubAddr = new ethers.Wallet(HARDHAT_KEYS.CORE_OP).address;
    await mintTokens(provider, hubAddress, HARDHAT_KEYS.DEPLOYER, pubAddr, ethers.parseEther('5000000'));

    const cgChain = createEVMAdapter(HARDHAT_KEYS.CORE_OP);
    const cgId = await createTestContextGraph(cgChain);
    CONTEXT_GRAPH = String(cgId);
  });

  afterAll(async () => {
    await revertSnapshot(snapshotId);
  });

  it('confirmed publish result includes txHash, blockNumber, batchId, publisherAddress', async () => {
    const store = new OxigraphStore();
    const chain = createEVMAdapter(HARDHAT_KEYS.CORE_OP);
    const eventBus = new TypedEventBus();
    const keypair = await generateEd25519Keypair();
    const wallet = new ethers.Wallet(HARDHAT_KEYS.CORE_OP);
    const _publisherRaw = new DKGPublisher({
      store,
      chain,
      eventBus,
      keypair,
      publisherPrivateKey: HARDHAT_KEYS.CORE_OP,
      publisherNodeIdentityId: BigInt(getSharedContext().coreProfileId),
    });
    const publisher = wrapPublisherForTest(_publisherRaw, {
      author: _author,
      ctx: { provider: _provider, kav10Address: _kav10Address },
    });

    const result = await publisher.publish({
      contextGraphId: CONTEXT_GRAPH,
      quads: [q('urn:test:result:1', 'http://schema.org/name', '"ResultTest"')],
    });

    expect(result.status).toBe('confirmed');
    expect(result.onChainResult).toBeDefined();
    expect(result.onChainResult!.txHash).toBeTruthy();
    expect(typeof result.onChainResult!.txHash).toBe('string');
    expect(result.onChainResult!.blockNumber).toBeGreaterThan(0);
    expect(typeof result.onChainResult!.batchId).toBe('bigint');
    expect(result.onChainResult!.batchId).toBeGreaterThan(0n);
    expect(result.onChainResult!.publisherAddress).toBeTruthy();
    expect(result.onChainResult!.publisherAddress.toLowerCase()).toBe(wallet.address.toLowerCase());
  });
});

describe('Regression: fail-fast when chain rejects', () => {
  let snapshotId: string;

  beforeAll(async () => {
    snapshotId = await takeSnapshot();
  });

  afterAll(async () => {
    await revertSnapshot(snapshotId);
  });

  it('publish returns tentative (not crash) when V10 chain call rejects', async () => {
    const store = new OxigraphStore();
    // Use a key with no tokens/profile to provoke a real chain rejection
    const chain = createEVMAdapter(HARDHAT_KEYS.EXTRA1);
    const eventBus = new TypedEventBus();
    const keypair = await generateEd25519Keypair();

    const _pubExtra1 = new DKGPublisher({
      store,
      chain,
      eventBus,
      keypair,
      publisherPrivateKey: HARDHAT_KEYS.EXTRA1,
      publisherNodeIdentityId: BigInt(getSharedContext().coreProfileId),
    });
    const publisher = wrapPublisherForTest(_pubExtra1, {
      author: new ethers.Wallet(HARDHAT_KEYS.EXTRA1),
      ctx: { provider: _provider, kav10Address: _kav10Address },
    });

    const result = await publisher.publish({
      contextGraphId: CONTEXT_GRAPH,
      quads: [q('urn:test:failfast:1', 'http://schema.org/name', '"FailFast"')],
    });

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

  it('publish stores data locally even when chain tx fails', async () => {
    const store = new OxigraphStore();
    // Use a key with no tokens/profile to provoke a real chain rejection
    const chain = createEVMAdapter(HARDHAT_KEYS.EXTRA2);
    const eventBus = new TypedEventBus();
    const keypair = await generateEd25519Keypair();

    const _pubExtra2 = new DKGPublisher({
      store,
      chain,
      eventBus,
      keypair,
      publisherPrivateKey: HARDHAT_KEYS.EXTRA2,
      publisherNodeIdentityId: BigInt(getSharedContext().coreProfileId),
    });
    const publisher = wrapPublisherForTest(_pubExtra2, {
      author: new ethers.Wallet(HARDHAT_KEYS.EXTRA2),
      ctx: { provider: _provider, kav10Address: _kav10Address },
    });

    await publisher.publish({
      contextGraphId: CONTEXT_GRAPH,
      quads: [q('urn:test:localstore:1', 'http://schema.org/name', '"LocalStore"')],
    });

    const queryResult = await store.query(
      `SELECT ?o WHERE { GRAPH <did:dkg:context-graph:${CONTEXT_GRAPH}> { <urn:test:localstore:1> <http://schema.org/name> ?o } }`,
    );
    expect(queryResult.type).toBe('bindings');
    if (queryResult.type === 'bindings') {
      expect(queryResult.bindings.length).toBe(1);
      expect(queryResult.bindings[0]['o']).toBe('"LocalStore"');
    }
  });
});
