/**
 * ChainEventPoller edge-case coverage (spec §5.1 / §6).
 *
 * NO BLOCKCHAIN MOCKS. Every test uses the real `EVMChainAdapter`
 * wired to the shared Hardhat node spun up by
 * `packages/chain/test/hardhat-global-setup.ts` (port 9546 for publisher).
 * Events are produced by real V10 contract calls
 * (`createOnChainContextGraph` → `ContextGraphCreated` on
 * `ContextGraphStorage`). Block ranges are advanced using real
 * `hardhat_mine` RPC so cursor / MAX_RANGE behaviour is exercised
 * against genuine on-chain block numbers.
 *
 * This file was migrated from the V9 NameClaimed flow (which depended on
 * the now-archived `ContextGraphNameRegistry.createContextGraph` surface)
 * to the V10 `createOnChainContextGraph` path that emits
 * `ContextGraphCreated`. The poller dispatches both NameClaimed and
 * ContextGraphCreated to `onContextGraphCreated`, so this rewire
 * preserves the original behavioural assertions verbatim.
 *
 * This file covers:
 *   - cursor persistence across restart (load + advance)
 *   - load() errors are non-fatal
 *   - head-seed skip for old events when no pending publishes
 *   - early-block events NOT skipped when hasPendingPublishes (scan
 *     must cover the full history — losing them is a durability bug)
 *   - MAX_RANGE (9000-block) capping → multiple polls needed for large gaps
 *   - callback failures must NOT abort the poll (fault isolation)
 *   - double-start() is a no-op (no duplicate timers)
 *   - stop() is idempotent and clears the interval
 *
 * ======================================================================
 * SPEC-GAP SG-6 (carried forward from the original V9 migration):
 *   The real `EVMChainAdapter.listenForEvents()` yields only the event
 *   types the deployed contracts emit (`KCCreated`,
 *   `ContextGraphExpanded`, `ContextGraphCreated`, ...). It does NOT
 *   yield `KnowledgeCollectionUpdated`, `AllowListUpdated`,
 *   `ProfileCreated`, or `ProfileUpdated` even though
 *   `ChainEventPoller.poll()` declares callback slots for all four.
 *   Those four callback paths are dead code in production. A dedicated
 *   regression test below pins this gap so the fix (extending
 *   `listenForEvents` with the missing branches) cannot silently slip.
 * ======================================================================
 */
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { ethers } from 'ethers';
import { OxigraphStore } from '@origintrail-official/dkg-storage';
import { TypedEventBus } from '@origintrail-official/dkg-core';
import {
  createEVMAdapter,
  getSharedContext,
  createProvider,
  takeSnapshot,
  revertSnapshot,
  HARDHAT_KEYS,
} from '../../chain/test/evm-test-context.js';
import { mintTokens } from '../../chain/test/hardhat-harness.js';
import { ChainEventPoller, type CursorPersistence } from '../src/chain-event-poller.js';
import { PublishHandler } from '../src/publish-handler.js';

class InMemoryCursor implements CursorPersistence {
  public saved: number[] = [];
  constructor(public loaded?: number) {}
  async load(): Promise<number | undefined> { return this.loaded; }
  async save(n: number): Promise<void> { this.saved.push(n); }
}

async function pollOnce(_poller: ChainEventPoller, timeoutMs = 300): Promise<void> {
  // Poller kicks off a first poll synchronously inside start(); we just
  // give the microtask queue + event loop enough headroom for one or
  // more polls against the real RPC.
  await new Promise((r) => setTimeout(r, timeoutMs));
}

/**
 * Mine `count` empty blocks on Hardhat so `chain.getBlockNumber()`
 * returns `head + count`. Used to create large gaps between the poll
 * cursor and the chain head without paying for real transactions.
 */
async function mineBlocks(count: number): Promise<void> {
  const provider = createProvider();
  await provider.send('hardhat_mine', ['0x' + count.toString(16)]);
}

/**
 * Create a V10 on-chain context graph via the ContextGraphs contract.
 * Returns `{ contextGraphId, blockNumber }` mirroring the shape the
 * pre-V10 NameClaimed helper used, so the test bodies need no further
 * adjustments. `ContextGraphCreated` is the event the poller will
 * surface through `onContextGraphCreated`.
 */
async function createV10Cg(chain: ReturnType<typeof createEVMAdapter>): Promise<{ contextGraphId: string; blockNumber: number }> {
  const id = BigInt(getSharedContext().coreProfileId);
  const result = await chain.createOnChainContextGraph({
    participantIdentityIds: [id],
    requiredSignatures: 1,
    publishPolicy: 0,
  });
  if (!result.success || result.contextGraphId === 0n) {
    throw new Error(`createV10Cg failed: ${JSON.stringify(result)}`);
  }
  return { contextGraphId: result.contextGraphId.toString(), blockNumber: result.blockNumber! };
}

let _fileSnapshot: string;
beforeAll(async () => {
  _fileSnapshot = await takeSnapshot();
  // Fund CORE_OP with enough TRAC to cover V10 createOnChainContextGraph
  // gas across all tests in this file (each call is its own real tx).
  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);
});

describe('ChainEventPoller — cursor persistence', () => {
  it('restores cursor from persistence on start()', async () => {
    const chain = createEVMAdapter(HARDHAT_KEYS.CORE_OP);
    const provider = createProvider();
    const handler = new PublishHandler(new OxigraphStore(), new TypedEventBus());

    // Emit a real ContextGraphCreated event; capture the exact block number.
    const result = await createV10Cg(chain);
    const eventBlock = result.blockNumber;

    // Persist a cursor one block before the event so restore semantics are
    // observable: the first poll must start at `eventBlock` and pick up
    // the ContextGraphCreated that would otherwise be missed if the cursor
    // reset to 0 (slow / redundant) or to head (skips the event).
    const cursor = new InMemoryCursor(eventBlock - 1);
    const received: Array<{ id: string; blockNumber: number }> = [];

    const poller = new ChainEventPoller({
      chain,
      publishHandler: handler,
      intervalMs: 50,
      cursorPersistence: cursor,
      onContextGraphCreated: async (e) => {
        received.push({ id: e.contextGraphId, blockNumber: e.blockNumber });
      },
    });

    await poller.start();
    await pollOnce(poller);
    poller.stop();

    // Our event must be among the restored-cursor scan results (other
    // tests may have created CGs too — we only care that OURS is picked).
    const mine = received.find((r) => r.blockNumber === eventBlock);
    expect(mine, `expected ContextGraphCreated at block ${eventBlock}; received=${JSON.stringify(received)}`).toBeDefined();

    // Cursor must have advanced past the event block (not regressed).
    expect(cursor.saved.length).toBeGreaterThan(0);
    expect(cursor.saved.at(-1)).toBeGreaterThanOrEqual(eventBlock);
    const head = await provider.getBlockNumber();
    expect(cursor.saved.at(-1)).toBeLessThanOrEqual(head);
  }, 30_000);

  it('persists advancing cursor to survive restart', async () => {
    const chain = createEVMAdapter(HARDHAT_KEYS.CORE_OP);
    const handler = new PublishHandler(new OxigraphStore(), new TypedEventBus());
    const cursor = new InMemoryCursor();

    // Take a snapshot of current head, then emit an event.
    const result = await createV10Cg(chain);
    const eventBlock = result.blockNumber;

    const poller = new ChainEventPoller({
      chain,
      publishHandler: handler,
      intervalMs: 50,
      cursorPersistence: cursor,
      onContextGraphCreated: async () => { /* seen */ },
    });

    await poller.start();
    await pollOnce(poller);
    poller.stop();

    expect(cursor.saved.length).toBeGreaterThan(0);
    // Final cursor must cover the block we emitted at.
    expect(cursor.saved.at(-1)).toBeGreaterThanOrEqual(eventBlock);
  }, 30_000);

  it('load() errors are non-fatal (poller still starts)', async () => {
    const chain = createEVMAdapter(HARDHAT_KEYS.CORE_OP);
    const handler = new PublishHandler(new OxigraphStore(), new TypedEventBus());
    const brokenCursor: CursorPersistence = {
      load: async () => { throw new Error('disk full'); },
      save: async () => { /* ok */ },
    };

    const poller = new ChainEventPoller({
      chain,
      publishHandler: handler,
      intervalMs: 50,
      cursorPersistence: brokenCursor,
      onContextGraphCreated: async () => { /* noop */ },
    });

    await expect(poller.start()).resolves.toBeUndefined();
    poller.stop();
  }, 15_000);
});

describe('ChainEventPoller — head seeding & range capping', () => {
  it('seeds cursor near head (head - 500) when no pending publishes and no cursor persistence', async () => {
    const chain = createEVMAdapter(HARDHAT_KEYS.CORE_OP);
    const provider = createProvider();
    const handler = new PublishHandler(new OxigraphStore(), new TypedEventBus());

    // Emit a ContextGraphCreated at block B, then mine 1000 empty blocks so
    // the new head is B+1000, putting B well before the seed window (head-500).
    const result = await createV10Cg(chain);
    const oldEventBlock = result.blockNumber;
    await mineBlocks(1000);
    const head = await provider.getBlockNumber();
    expect(head).toBeGreaterThanOrEqual(oldEventBlock + 1000);

    const received: number[] = [];
    const poller = new ChainEventPoller({
      chain,
      publishHandler: handler,
      intervalMs: 50,
      onContextGraphCreated: async (e) => { received.push(e.blockNumber); },
    });

    // No cursor persistence + no pendings → poller seeds at head-500.
    // Our old event (block oldEventBlock) is > 500 blocks behind head,
    // so it MUST NOT be received on the first (seeded) poll.
    await poller.start();
    await pollOnce(poller);
    poller.stop();

    expect(received.includes(oldEventBlock), `expected block ${oldEventBlock} to be skipped (head=${head}, seed=${head - 500}); received=${received.join(',')}`).toBe(false);
  }, 45_000);

  it('does NOT skip early-block events when hasPendingPublishes (cursor is not seeded near head)', async () => {
    const chain = createEVMAdapter(HARDHAT_KEYS.CORE_OP);
    const provider = createProvider();
    const handler = new PublishHandler(new OxigraphStore(), new TypedEventBus());

    // Plant a pending publish sentinel so `hasPendingPublishes === true` and
    // the poller is FORCED to start scanning from lastBlock=0 (full history).
    (handler as unknown as { pendingPublishes: Map<string, unknown> }).pendingPublishes.set(
      'sentinel',
      { expectedMerkleRoot: new Uint8Array(32) } as never,
    );
    expect(handler.hasPendingPublishes).toBe(true);

    const beforeHead = await provider.getBlockNumber();
    const result = await createV10Cg(chain);
    const earlyBlock = result.blockNumber;
    expect(earlyBlock).toBeGreaterThan(beforeHead);

    // Mine far past the early block — head would trigger head-500 seeding
    // if hasPendingPublishes were false. This assertion proves it isn't.
    await mineBlocks(2000);

    const received: number[] = [];
    const poller = new ChainEventPoller({
      chain,
      publishHandler: handler,
      intervalMs: 50,
      onContextGraphCreated: async (e) => { received.push(e.blockNumber); },
    });

    await poller.start();
    await pollOnce(poller, 500);
    poller.stop();

    // Early event must be picked up in the first poll. Scan range is
    // [1, min(9000, head)]; earlyBlock < 9000 for a fresh Hardhat, so the
    // event is reachable on the first poll even though head is far beyond.
    expect(received.includes(earlyBlock), `expected block ${earlyBlock} to be picked up; received=${received.join(',')}`).toBe(true);
  }, 45_000);

  it('caps scan range at MAX_RANGE (9000 blocks) — multiple polls needed to cover large gaps', async () => {
    const chain = createEVMAdapter(HARDHAT_KEYS.CORE_OP);
    const provider = createProvider();
    const handler = new PublishHandler(new OxigraphStore(), new TypedEventBus());

    // Pending publish forces full-history scan.
    (handler as unknown as { pendingPublishes: Map<string, unknown> }).pendingPublishes.set(
      'sentinel',
      { expectedMerkleRoot: new Uint8Array(32) } as never,
    );

    // Emit an early event (current head + 1 after this tx).
    const earlyResult = await createV10Cg(chain);
    const earlyBlock = earlyResult.blockNumber;

    // Mine past MAX_RANGE so cursor cannot cover the gap in one poll.
    await mineBlocks(10_000);

    // Emit a late event near current head — two events straddle the cap.
    const lateResult = await createV10Cg(chain);
    const lateBlock = lateResult.blockNumber;
    const head = await provider.getBlockNumber();
    expect(head - earlyBlock).toBeGreaterThan(9_000);
    expect(lateBlock).toBeGreaterThan(earlyBlock + 9_000);

    const received: number[] = [];
    const poller = new ChainEventPoller({
      chain,
      publishHandler: handler,
      intervalMs: 50,
      onContextGraphCreated: async (e) => { received.push(e.blockNumber); },
    });

    await poller.start();
    // Wait long enough for multiple polls (intervalMs=50, head-early > 9000
    // means at least 2 polls are required to close the gap).
    await pollOnce(poller, 1500);
    poller.stop();

    // Both events are observed, but NOT in a single poll — the cursor
    // had to advance through the MAX_RANGE boundary first.
    expect(received.includes(earlyBlock), `earlyBlock ${earlyBlock} missing; received=${received.join(',')}`).toBe(true);
    expect(received.includes(lateBlock),  `lateBlock  ${lateBlock}  missing; received=${received.join(',')}`).toBe(true);
  }, 60_000);
});

describe('ChainEventPoller — fault isolation & lifecycle', () => {
  it('a callback that throws does NOT propagate or stop the poll', async () => {
    const chain = createEVMAdapter(HARDHAT_KEYS.CORE_OP);
    const handler = new PublishHandler(new OxigraphStore(), new TypedEventBus());

    const id1 = (await createV10Cg(chain)).contextGraphId;
    const id2 = (await createV10Cg(chain)).contextGraphId;

    // First event sees a throwing callback; the second must still be
    // delivered → poller's dispatch loop catches and continues.
    let throwerCalls = 0;
    const seen: string[] = [];

    const poller = new ChainEventPoller({
      chain,
      publishHandler: handler,
      intervalMs: 50,
      onContextGraphCreated: async (e) => {
        seen.push(e.contextGraphId);
        if (e.contextGraphId === id1) {
          throwerCalls++;
          throw new Error('kaboom');
        }
      },
    });

    await poller.start();
    await pollOnce(poller, 500);
    poller.stop();

    expect(throwerCalls).toBeGreaterThanOrEqual(1);
    expect(seen).toContain(id1);
    expect(seen).toContain(id2);
  }, 45_000);

  it('stop() is idempotent and clears the interval timer', async () => {
    const chain = createEVMAdapter(HARDHAT_KEYS.CORE_OP);
    const handler = new PublishHandler(new OxigraphStore(), new TypedEventBus());
    const poller = new ChainEventPoller({
      chain,
      publishHandler: handler,
      intervalMs: 50,
      onContextGraphCreated: async () => { /* noop */ },
    });

    await poller.start();
    poller.stop();
    poller.stop();

    // Re-start succeeds without leaving ghost timers (open-handle warning
    // from vitest would fail the suite if the previous interval leaked).
    await poller.start();
    poller.stop();
  }, 15_000);

  it('start() is idempotent (calling twice does not create two timers)', async () => {
    const chain = createEVMAdapter(HARDHAT_KEYS.CORE_OP);
    const handler = new PublishHandler(new OxigraphStore(), new TypedEventBus());

    const result = await createV10Cg(chain);
    const ourId = result.contextGraphId;
    const ourBlock = result.blockNumber;

    // Probe the timer count directly on the internal state. A non-idempotent
    // start() would overwrite `timer` with a second handle, losing the first
    // (still active) interval — observable as a leaked setInterval only on
    // real node runtimes. Here we also check the `running` flag flipped
    // exactly once and that the second start() was a no-op.
    const pollCalls: number[] = [];
    const poller = new ChainEventPoller({
      chain,
      publishHandler: handler,
      // Large interval so `setInterval` never fires during this test — any
      // poll observed must come from the synchronous `this.poll()` call
      // inside `start()`. If start() were non-idempotent, we'd see 2.
      intervalMs: 60_000,
      onContextGraphCreated: async (e) => {
        if (e.contextGraphId === ourId && e.blockNumber === ourBlock) {
          pollCalls.push(e.blockNumber);
        }
      },
    });

    // Seed cursor right before our event so the first poll is guaranteed to
    // reach it in the initial synchronous poll triggered by start().
    (poller as unknown as { lastBlock: number }).lastBlock = ourBlock - 1;

    await poller.start();
    // Capture timer + running snapshot before the second start() call.
    const firstTimer = (poller as unknown as { timer: unknown }).timer;
    const firstRunning = (poller as unknown as { running: boolean }).running;

    await poller.start(); // MUST be a no-op — `running === true` short-circuits
    const secondTimer = (poller as unknown as { timer: unknown }).timer;

    // Give the first (synchronous) poll time to complete its async work.
    await pollOnce(poller, 400);
    poller.stop();

    // Timer handle must be unchanged after the second start() call — proof
    // that no additional setInterval was scheduled.
    expect(secondTimer).toBe(firstTimer);
    expect(firstRunning).toBe(true);
    // Our event must have been observed exactly once — the synchronous
    // first poll. setInterval's next tick would be at T=60s, so any count
    // above 1 would mean start() double-fired the immediate poll.
    expect(pollCalls.length).toBe(1);
    expect(pollCalls[0]).toBe(ourBlock);
  }, 30_000);
});

describe('ChainEventPoller — SPEC-GAP SG-6: adapter missing 4 extended event types', () => {
  it('EVMChainAdapter.listenForEvents does NOT yield KnowledgeCollectionUpdated / AllowListUpdated / ProfileCreated / ProfileUpdated', async () => {
    // Spec §5.1 names extended event types the poller declares callback
    // slots for. Four are never produced by the real adapter (the V9
    // KnowledgeAssetsStorage branches were archived together with the
    // contract — see `packages/chain/src/archive/`). This test proves
    // the gap end-to-end against the V10 channels the adapter does
    // support today: we scan a broad block range asking for every
    // currently-supported type plus the four missing ones, and assert
    // the four missing ones are never yielded.
    const chain = createEVMAdapter(HARDHAT_KEYS.CORE_OP);
    const provider = createProvider();

    const head = await provider.getBlockNumber();
    const yielded: string[] = [];

    for await (const ev of chain.listenForEvents({
      eventTypes: [
        'KCCreated',
        'ContextGraphCreated',
        'KnowledgeCollectionUpdated',
        'AllowListUpdated',
        'ProfileCreated',
        'ProfileUpdated',
      ],
      fromBlock: 0,
      toBlock: head,
    })) {
      yielded.push(ev.type);
    }

    const missing = ['KnowledgeCollectionUpdated', 'AllowListUpdated', 'ProfileCreated', 'ProfileUpdated'];
    for (const type of missing) {
      // This assertion is expected to PASS today (adapter never yields
      // these types). If a future PR extends the adapter correctly, the
      // assertion flips to fail loudly — at which point this test should
      // be rewritten into a positive coverage test for that event type.
      expect(yielded.includes(type), `adapter now yields "${type}" — great, but this SG-6 regression test must be updated to exercise the positive dispatch path`).toBe(false);
    }
  }, 30_000);
});
