/**
 * @fileoverview Record-Replay V3 RunRunner onError Contracts
 * @description Verifies RunRunner onError behavior via event stream + final Run status.
 */

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { z } from 'zod';

import type {
  EdgeV3,
  FlowV3,
  NodeV3,
  RunEvent,
  RunRecordV3,
} from '@/entrypoints/background/record-replay-v3';

import {
  EDGE_LABELS,
  FLOW_SCHEMA_VERSION,
  RR_ERROR_CODES,
  createRRError,
} from '@/entrypoints/background/record-replay-v3';
import { InMemoryEventsBus } from '@/entrypoints/background/record-replay-v3/engine/transport/events-bus';
import { PluginRegistry } from '@/entrypoints/background/record-replay-v3/engine/plugins/registry';
import { createNotImplementedStoragePort } from '@/entrypoints/background/record-replay-v3/engine/storage/storage-port';
import { createRunRunnerFactory } from '@/entrypoints/background/record-replay-v3/engine/kernel/runner';
import { resetBreakpointRegistry } from '@/entrypoints/background/record-replay-v3/engine/kernel/breakpoints';
import type { ArtifactService } from '@/entrypoints/background/record-replay-v3/engine/kernel/artifacts';
import type {
  NodeDefinition,
  NodeExecutionResult,
} from '@/entrypoints/background/record-replay-v3/engine/plugins/types';
import type { RunRunner } from '@/entrypoints/background/record-replay-v3/engine/kernel/runner';

import type {
  NodeId,
  RunId,
  PersistentVarRecord,
} from '@/entrypoints/background/record-replay-v3';
import type {
  PersistentVarsStore,
  RunsStore,
} from '@/entrypoints/background/record-replay-v3/engine/storage/storage-port';

// ==================== Test Helpers ====================

type TestNodeConfig = {
  action: 'succeed' | 'fail' | 'flaky' | 'setTab';
  failTimes?: number;
  errorCode?: string;
  tabId?: number;
};

/**
 * Create a test node definition that can succeed, fail, or be flaky
 */
function createTestNodeDefinition(
  callsByNodeId: Map<string, number>,
): NodeDefinition<'test', TestNodeConfig> {
  return {
    kind: 'test',
    schema: z
      .object({
        action: z.enum(['succeed', 'fail', 'flaky', 'setTab']),
        failTimes: z.number().int().min(0).optional(),
        errorCode: z.string().optional(),
        tabId: z.number().int().optional(),
      })
      .passthrough(),
    execute: async (ctx, node): Promise<NodeExecutionResult> => {
      const prev = callsByNodeId.get(ctx.nodeId) ?? 0;
      const cur = prev + 1;
      callsByNodeId.set(ctx.nodeId, cur);

      const cfg = node.config as unknown as TestNodeConfig;
      const error = createRRError(
        (cfg.errorCode ?? RR_ERROR_CODES.TOOL_ERROR) as typeof RR_ERROR_CODES.TOOL_ERROR,
        `test failure (${ctx.nodeId})`,
      );

      if (cfg.action === 'succeed') return { status: 'succeeded' };
      if (cfg.action === 'setTab') {
        return {
          status: 'succeeded',
          varsPatch: [
            {
              op: 'set',
              name: '__rr_runtime__tabId',
              value: cfg.tabId ?? 1,
            },
          ],
        };
      }
      if (cfg.action === 'fail') return { status: 'failed', error };

      // flaky: fail for the first `failTimes` calls
      const failTimes = Math.max(0, cfg.failTimes ?? 0);
      if (cur <= failTimes) return { status: 'failed', error };
      return { status: 'succeeded' };
    },
  };
}

/**
 * Create a test flow
 */
function createFlow(entryNodeId: string, nodes: NodeV3[], edges: EdgeV3[]): FlowV3 {
  const iso = new Date(0).toISOString();
  return {
    schemaVersion: FLOW_SCHEMA_VERSION,
    id: 'flow-onerror',
    name: 'onError contract flow',
    createdAt: iso,
    updatedAt: iso,
    entryNodeId,
    nodes,
    edges,
  };
}

/**
 * Create an in-memory RunsStore for testing
 */
function createInMemoryRunsStore(): { store: RunsStore; byId: Map<RunId, RunRecordV3> } {
  const byId = new Map<RunId, RunRecordV3>();
  const store: RunsStore = {
    list: async () => Array.from(byId.values()),
    get: async (id) => byId.get(id) ?? null,
    save: async (record) => {
      byId.set(record.id, record);
    },
    patch: async (id, patch) => {
      const existing = byId.get(id);
      if (!existing) {
        throw createRRError(RR_ERROR_CODES.INTERNAL, `Run "${id}" not found`);
      }
      byId.set(id, {
        ...existing,
        ...patch,
        id: existing.id,
        schemaVersion: existing.schemaVersion,
        updatedAt: Date.now(),
      });
    },
  };
  return { store, byId };
}

/**
 * Create an in-memory PersistentVarsStore for testing
 */
function createInMemoryPersistentVarsStore(): PersistentVarsStore {
  const byKey = new Map<string, PersistentVarRecord>();
  return {
    get: async (key) => byKey.get(key as string) as PersistentVarRecord | undefined,
    set: async (key, value) => {
      const prev = byKey.get(key as string);
      const record: PersistentVarRecord = {
        key,
        value,
        updatedAt: Date.now(),
        version: (prev?.version ?? 0) + 1,
      };
      byKey.set(key as string, record);
      return record;
    },
    delete: async (key) => {
      byKey.delete(key as string);
    },
    list: async (prefix) => {
      const all = Array.from(byKey.values());
      if (!prefix) return all;
      return all.filter((r) => r.key.startsWith(prefix));
    },
  };
}

/**
 * Extract node IDs from node.started events
 */
function startedNodeIds(events: RunEvent[]): string[] {
  return events
    .filter((e) => e.type === 'node.started')
    .map((e) => (e as Extract<RunEvent, { type: 'node.started' }>).nodeId);
}

/**
 * Extract node.failed events for a specific node
 */
function nodeFailedEvents(
  events: RunEvent[],
  nodeId: string,
): Array<Extract<RunEvent, { type: 'node.failed' }>> {
  return events.filter(
    (e): e is Extract<RunEvent, { type: 'node.failed' }> =>
      e.type === 'node.failed' && e.nodeId === nodeId,
  );
}

/**
 * List events from InMemoryEventsBus
 */
async function listEvents(bus: InMemoryEventsBus, runId: RunId): Promise<RunEvent[]> {
  return bus.list({ runId });
}

function failEventType(bus: InMemoryEventsBus, type: RunEvent['type']) {
  const append = bus.append.bind(bus);
  const spy = vi.spyOn(bus, 'append');
  spy.mockImplementation(async (event) => {
    if (event.type === type) {
      throw new Error(`${type} persistence unavailable`);
    }
    return append(event);
  });
  return spy;
}

/**
 * Create a complete runner context for testing
 */
function createRunnerContext(
  runId: RunId,
  flow: FlowV3,
  options: {
    artifactService?: ArtifactService;
    execution?: RunRecordV3['execution'];
    args?: RunRecordV3['args'];
    recordArgs?: RunRecordV3['args'];
    startNodeId?: NodeId;
    stopBeforeNodeId?: NodeId;
    endNodeId?: NodeId;
  } = {},
): {
  runner: RunRunner;
  bus: InMemoryEventsBus;
  runsById: Map<RunId, RunRecordV3>;
  calls: Map<string, number>;
} {
  const calls = new Map<string, number>();
  const plugins = new PluginRegistry();
  plugins.registerNode(createTestNodeDefinition(calls));

  const bus = new InMemoryEventsBus();
  const { store: runs, byId: runsById } = createInMemoryRunsStore();

  const storage = createNotImplementedStoragePort();
  storage.runs = runs;
  storage.persistentVars = createInMemoryPersistentVarsStore();

  const factory = createRunRunnerFactory({
    storage,
    events: bus,
    plugins,
    ...(options.artifactService ? { artifactService: options.artifactService } : {}),
  });
  const runner = factory.create(runId, {
    flow,
    tabId: 1,
    ...(options.args ? { args: options.args } : {}),
    ...(options.recordArgs ? { recordArgs: options.recordArgs } : {}),
    ...(options.execution ? { execution: options.execution } : {}),
    ...(options.startNodeId ? { startNodeId: options.startNodeId } : {}),
    ...(options.stopBeforeNodeId ? { stopBeforeNodeId: options.stopBeforeNodeId } : {}),
    ...(options.endNodeId ? { endNodeId: options.endNodeId } : {}),
  });

  return { runner, bus, runsById, calls };
}

// ==================== Tests ====================

describe('V3 RunRunner onError contracts', () => {
  beforeEach(() => {
    resetBreakpointRegistry();
  });

  afterEach(() => {
    vi.useRealTimers();
    vi.restoreAllMocks();
  });

  it('persists recordArgs instead of runtime args for secret-backed runs', async () => {
    const runId = 'run-secret-record-args' as RunId;
    const flow = createFlow(
      'n1',
      [{ id: 'n1' as any, kind: 'test', config: { action: 'succeed' } }],
      [],
    );
    const h = createRunnerContext(runId, flow, {
      args: { password: 'plain-secret-password' },
      recordArgs: { password: { secretRef: 'secret://login-password' } },
    });

    expect(h.runner.getVar('password')).toBe('plain-secret-password');

    const result = await h.runner.start();
    const persisted = h.runsById.get(runId);

    expect(result.status).toBe('succeeded');
    expect(persisted?.args).toEqual({
      password: { secretRef: 'secret://login-password' },
    });
    expect(JSON.stringify(persisted)).not.toContain('plain-secret-password');
  });

  it('keeps succeeded records terminal when run.succeeded event persistence fails', async () => {
    const runId = 'run-terminal-event-succeeded';
    const flow = createFlow(
      'A',
      [{ id: 'A', kind: 'test', config: { action: 'succeed' } }],
      [],
    );
    const h = createRunnerContext(runId, flow);
    const appendSpy = failEventType(h.bus, 'run.succeeded');

    const result = await h.runner.start();
    const events = await listEvents(h.bus, runId);

    expect(result.status).toBe('succeeded');
    expect(h.runsById.get(runId)?.status).toBe('succeeded');
    expect(appendSpy).toHaveBeenCalledWith(
      expect.objectContaining({ runId, type: 'run.succeeded' }),
    );
    expect(events.some((event) => event.type === 'run.succeeded')).toBe(false);
  });

  it('preserves failed records when run.failed event persistence fails', async () => {
    const runId = 'run-terminal-event-failed';
    const flow = createFlow(
      'A',
      [
        {
          id: 'A',
          kind: 'test',
          config: { action: 'fail' },
          policy: { onError: { kind: 'stop' } },
        },
      ],
      [],
    );
    const h = createRunnerContext(runId, flow);
    const appendSpy = failEventType(h.bus, 'run.failed');

    const result = await h.runner.start();
    const events = await listEvents(h.bus, runId);
    const persisted = h.runsById.get(runId);

    expect(result.status).toBe('failed');
    expect(persisted?.status).toBe('failed');
    expect(persisted?.error?.code).toBe(RR_ERROR_CODES.TOOL_ERROR);
    expect(appendSpy).toHaveBeenCalledWith(
      expect.objectContaining({ runId, type: 'run.failed' }),
    );
    expect(events.some((event) => event.type === 'run.failed')).toBe(false);
  });

  it('keeps boundary-stopped records terminal when boundary event persistence fails', async () => {
    const runId = 'run-terminal-event-boundary';
    const flow = createFlow(
      'A',
      [{ id: 'A', kind: 'test', config: { action: 'succeed' } }],
      [],
    );
    const h = createRunnerContext(runId, flow, { stopBeforeNodeId: 'A' as NodeId });
    const appendSpy = failEventType(h.bus, 'run.stopped_at_boundary');

    const result = await h.runner.start();
    const events = await listEvents(h.bus, runId);

    expect(result.status).toBe('stopped_at_boundary');
    expect(h.runsById.get(runId)?.status).toBe('stopped_at_boundary');
    expect(appendSpy).toHaveBeenCalledWith(
      expect.objectContaining({ runId, type: 'run.stopped_at_boundary' }),
    );
    expect(events.some((event) => event.type === 'run.stopped_at_boundary')).toBe(false);
  });

  it('keeps canceled records terminal when run.canceled event persistence fails', async () => {
    const runId = 'run-terminal-event-canceled';
    const flow = createFlow(
      'A',
      [{ id: 'A', kind: 'test', config: { action: 'succeed' } }],
      [],
    );
    const h = createRunnerContext(runId, flow);
    const appendSpy = failEventType(h.bus, 'run.canceled');

    h.runner.cancel('user requested');
    const result = await h.runner.start();
    const events = await listEvents(h.bus, runId);

    expect(result.status).toBe('canceled');
    expect(h.runsById.get(runId)?.status).toBe('canceled');
    expect(appendSpy).toHaveBeenCalledWith(
      expect.objectContaining({
        runId,
        type: 'run.canceled',
        reason: 'user requested',
      }),
    );
    expect(events.some((event) => event.type === 'run.canceled')).toBe(false);
  });

  it('stop: node failure ends run as failed', async () => {
    const runId = 'run-stop';
    const flow = createFlow(
      'A',
      [
        {
          id: 'A',
          kind: 'test',
          config: { action: 'fail' },
          policy: { onError: { kind: 'stop' } },
        },
        { id: 'B', kind: 'test', config: { action: 'succeed' } },
      ],
      [{ id: 'e1', from: 'A', to: 'B', label: EDGE_LABELS.DEFAULT }],
    );

    const { runner, bus, runsById } = createRunnerContext(runId, flow);
    const result = await runner.start();
    expect(result.status).toBe('failed');
    expect(runsById.get(runId)?.status).toBe('failed');

    const events = await listEvents(bus, runId);
    expect(nodeFailedEvents(events, 'A')[0].decision).toBe('stop');
    expect(startedNodeIds(events)).toEqual(['A']);
  });

  it('continue: node failure continues to next node', async () => {
    const runId = 'run-continue';
    const flow = createFlow(
      'A',
      [
        {
          id: 'A',
          kind: 'test',
          config: { action: 'fail' },
          policy: { onError: { kind: 'continue' } },
        },
        { id: 'B', kind: 'test', config: { action: 'succeed' } },
      ],
      [{ id: 'e1', from: 'A', to: 'B', label: EDGE_LABELS.DEFAULT }],
    );

    const { runner, bus, runsById } = createRunnerContext(runId, flow);
    const result = await runner.start();
    expect(result.status).toBe('succeeded');
    expect(runsById.get(runId)?.status).toBe('succeeded');

    const events = await listEvents(bus, runId);
    expect(nodeFailedEvents(events, 'A')[0].decision).toBe('continue');
    expect(startedNodeIds(events)).toEqual(['A', 'B']);
  });

  it('goto edgeLabel: node failure jumps to ON_ERROR edge target', async () => {
    const runId = 'run-goto-edge';
    const flow = createFlow(
      'A',
      [
        {
          id: 'A',
          kind: 'test',
          config: { action: 'fail' },
          policy: {
            onError: { kind: 'goto', target: { kind: 'edgeLabel', label: EDGE_LABELS.ON_ERROR } },
          },
        },
        { id: 'B', kind: 'test', config: { action: 'succeed' } },
        { id: 'C', kind: 'test', config: { action: 'succeed' } },
      ],
      [
        { id: 'e1', from: 'A', to: 'B', label: EDGE_LABELS.DEFAULT },
        { id: 'e2', from: 'A', to: 'C', label: EDGE_LABELS.ON_ERROR },
      ],
    );

    const { runner, bus, runsById } = createRunnerContext(runId, flow);
    const result = await runner.start();
    expect(result.status).toBe('succeeded');
    expect(runsById.get(runId)?.status).toBe('succeeded');

    const events = await listEvents(bus, runId);
    expect(nodeFailedEvents(events, 'A')[0].decision).toBe('goto');
    expect(startedNodeIds(events)).toEqual(['A', 'C']);
  });

  it('goto nodeId: node failure jumps to specified node', async () => {
    const runId = 'run-goto-node';
    const flow = createFlow(
      'A',
      [
        {
          id: 'A',
          kind: 'test',
          config: { action: 'fail' },
          policy: { onError: { kind: 'goto', target: { kind: 'node', nodeId: 'C' } } },
        },
        { id: 'B', kind: 'test', config: { action: 'succeed' } },
        { id: 'C', kind: 'test', config: { action: 'succeed' } },
      ],
      [{ id: 'e1', from: 'A', to: 'B', label: EDGE_LABELS.DEFAULT }],
    );

    const { runner, bus, runsById } = createRunnerContext(runId, flow);
    const result = await runner.start();
    expect(result.status).toBe('succeeded');
    expect(runsById.get(runId)?.status).toBe('succeeded');

    const events = await listEvents(bus, runId);
    expect(nodeFailedEvents(events, 'A')[0].decision).toBe('goto');
    expect(startedNodeIds(events)).toEqual(['A', 'C']);
  });

  it('retry: retries the configured number of times and can succeed', async () => {
    const runId = 'run-retry-succeed';
    const flow = createFlow(
      'A',
      [
        {
          id: 'A',
          kind: 'test',
          config: { action: 'flaky', failTimes: 2 },
          policy: { onError: { kind: 'retry' }, retry: { retries: 2, intervalMs: 0 } },
        },
      ],
      [],
    );

    const { runner, bus, runsById } = createRunnerContext(runId, flow);
    const result = await runner.start();
    expect(result.status).toBe('succeeded');
    expect(runsById.get(runId)?.status).toBe('succeeded');

    const events = await listEvents(bus, runId);
    const started = events.filter((e) => e.type === 'node.started') as Array<
      Extract<RunEvent, { type: 'node.started' }>
    >;
    expect(started.map((e) => e.attempt)).toEqual([1, 2, 3]);

    const failed = nodeFailedEvents(events, 'A');
    expect(failed.map((e) => e.decision)).toEqual(['retry', 'retry']);
  });

  it('retry: uses backoff and fails after retries are exhausted', async () => {
    const runId = 'run-retry-fail';
    const flow = createFlow(
      'A',
      [
        {
          id: 'A',
          kind: 'test',
          config: { action: 'fail' },
          policy: {
            onError: { kind: 'retry' },
            retry: { retries: 2, intervalMs: 100, backoff: 'linear' },
          },
        },
      ],
      [],
    );

    const { runner, bus, runsById } = createRunnerContext(runId, flow);

    vi.useFakeTimers();
    const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout');

    const startPromise = runner.start();
    await vi.runAllTimersAsync();
    const result = await startPromise;

    expect(result.status).toBe('failed');
    expect(runsById.get(runId)?.status).toBe('failed');

    const delays = setTimeoutSpy.mock.calls
      .map((call) => call[1])
      .filter((ms): ms is number => typeof ms === 'number' && ms > 0);
    // Linear backoff: 100, 200
    expect(delays).toContain(100);
    expect(delays).toContain(200);

    const events = await listEvents(bus, runId);
    const started = events.filter((e) => e.type === 'node.started') as Array<
      Extract<RunEvent, { type: 'node.started' }>
    >;
    expect(started.map((e) => e.attempt)).toEqual([1, 2, 3]);

    const failed = nodeFailedEvents(events, 'A');
    expect(failed).toHaveLength(3);
    // Last retry should still be 'retry' as that's the decision made before checking max attempts
    expect(failed.map((e) => e.decision)).toEqual(['retry', 'retry', 'retry']);
  });

  it('default retry policy retries when no explicit onError policy or ON_ERROR edge exists', async () => {
    const runId = 'run-default-retry-succeed';
    const flow = createFlow(
      'A',
      [
        {
          id: 'A',
          kind: 'test',
          config: { action: 'flaky', failTimes: 1 },
          policy: { retry: { retries: 1, intervalMs: 0 } },
        },
      ],
      [],
    );

    const { runner, bus, runsById } = createRunnerContext(runId, flow);
    const result = await runner.start();
    expect(result.status).toBe('succeeded');
    expect(runsById.get(runId)?.status).toBe('succeeded');

    const events = await listEvents(bus, runId);
    const started = events.filter((e) => e.type === 'node.started') as Array<
      Extract<RunEvent, { type: 'node.started' }>
    >;
    expect(started.map((e) => e.attempt)).toEqual([1, 2]);

    const failed = nodeFailedEvents(events, 'A');
    expect(failed.map((e) => e.decision)).toEqual(['retry']);
  });

  it('flow default retry applies only to side-effect safe nodes', async () => {
    const runId = 'run-side-effect-safe-default-retry';
    const flow = createFlow(
      'A',
      [
        {
          id: 'A',
          kind: 'test',
          sideEffect: { category: 'safe', retry: 'default' },
          config: { action: 'flaky', failTimes: 1 },
        },
      ],
      [],
    );
    flow.policy = { defaultNodePolicy: { retry: { retries: 1, intervalMs: 0 } } };

    const { runner, bus, runsById } = createRunnerContext(runId, flow);
    const result = await runner.start();
    expect(result.status).toBe('succeeded');
    expect(runsById.get(runId)?.status).toBe('succeeded');

    const events = await listEvents(bus, runId);
    const started = events.filter((e) => e.type === 'node.started') as Array<
      Extract<RunEvent, { type: 'node.started' }>
    >;
    expect(started.map((e) => e.attempt)).toEqual([1, 2]);
    expect(nodeFailedEvents(events, 'A').map((e) => e.decision)).toEqual(['retry']);
  });

  it('flow default retry is suppressed for side-effecting nodes', async () => {
    const runId = 'run-side-effect-danger-default-retry';
    const flow = createFlow(
      'A',
      [
        {
          id: 'A',
          kind: 'test',
          sideEffect: { category: 'dangerous', retry: 'explicit' },
          config: { action: 'flaky', failTimes: 1 },
        },
      ],
      [],
    );
    flow.policy = { defaultNodePolicy: { retry: { retries: 1, intervalMs: 0 } } };

    const { runner, bus, runsById } = createRunnerContext(runId, flow);
    const result = await runner.start();
    expect(result.status).toBe('failed');
    expect(runsById.get(runId)?.status).toBe('failed');

    const events = await listEvents(bus, runId);
    const started = events.filter((e) => e.type === 'node.started') as Array<
      Extract<RunEvent, { type: 'node.started' }>
    >;
    expect(started.map((e) => e.attempt)).toEqual([1]);
    expect(nodeFailedEvents(events, 'A').map((e) => e.decision)).toEqual(['stop']);
  });

  it('artifacts: captures and records failure screenshots from node policy', async () => {
    const runId = 'run-artifact-on-failure';
    const artifactService: ArtifactService = {
      screenshot: vi.fn().mockResolvedValue({ ok: true, base64: 'failure-shot' }),
      saveScreenshot: vi.fn().mockResolvedValue({
        savedAs: 'failure.png',
        artifactId: 'run-artifact-on-failure/A/1',
      }),
    };
    const flow = createFlow(
      'A',
      [
        {
          id: 'A',
          kind: 'test',
          config: { action: 'fail' },
          policy: {
            artifacts: { screenshot: 'onFailure', saveScreenshotAs: 'failure.png' },
          },
        },
      ],
      [],
    );

    const { runner, bus } = createRunnerContext(runId, flow, {
      artifactService,
      execution: { backgroundTabs: true },
    });
    const result = await runner.start();
    expect(result.status).toBe('failed');

    expect(artifactService.screenshot).toHaveBeenCalledWith(1, { background: true });
    expect(artifactService.saveScreenshot).toHaveBeenCalledWith(
      runId,
      'A',
      'failure-shot',
      'failure.png',
    );

    const events = await listEvents(bus, runId);
    const screenshots = events.filter(
      (event): event is Extract<RunEvent, { type: 'artifact.screenshot' }> =>
        event.type === 'artifact.screenshot',
    );
    expect(screenshots).toHaveLength(1);
    expect(screenshots[0]).toMatchObject({
      type: 'artifact.screenshot',
      nodeId: 'A',
      artifactId: 'run-artifact-on-failure/A/1',
      savedAs: 'failure.png',
    });
    expect(screenshots[0]).not.toHaveProperty('data');
  });

  it('artifacts: captures failure screenshots by default', async () => {
    const runId = 'run-artifact-default-on-failure';
    const artifactService: ArtifactService = {
      screenshot: vi.fn().mockResolvedValue({ ok: true, base64: 'default-failure-shot' }),
      saveScreenshot: vi.fn().mockResolvedValue({
        savedAs: 'run-artifact-default-on-failure_A_1.png',
        artifactId: 'run-artifact-default-on-failure/A/1',
      }),
    };
    const flow = createFlow(
      'A',
      [{ id: 'A', kind: 'test', config: { action: 'fail' } }],
      [],
    );

    const { runner, bus } = createRunnerContext(runId, flow, { artifactService });
    const result = await runner.start();
    expect(result.status).toBe('failed');

    expect(artifactService.screenshot).toHaveBeenCalledWith(1, { background: false });
    expect(artifactService.saveScreenshot).toHaveBeenCalledWith(
      runId,
      'A',
      'default-failure-shot',
      undefined,
    );
    const events = await listEvents(bus, runId);
    expect(
      events.some(
        (event) =>
          event.type === 'artifact.screenshot' &&
          event.artifactId === 'run-artifact-default-on-failure/A/1',
      ),
    ).toBe(true);
  });

  it('artifacts: emits resource warning metadata when screenshot persistence hits quota', async () => {
    const runId = 'run-artifact-quota-warning';
    const artifactService: ArtifactService = {
      screenshot: vi.fn().mockResolvedValue({ ok: true, base64: 'quota-shot' }),
      saveScreenshot: vi.fn().mockResolvedValue({
        error: createRRError(
          RR_ERROR_CODES.RESOURCE_LIMIT_EXCEEDED,
          'Save screenshot failed: quota exceeded',
          { retryable: false },
        ),
      }),
    };
    const flow = createFlow(
      'A',
      [{ id: 'A', kind: 'test', config: { action: 'fail' } }],
      [],
    );

    const { runner, bus } = createRunnerContext(runId, flow, { artifactService });
    const result = await runner.start();
    expect(result.status).toBe('failed');

    const events = await listEvents(bus, runId);
    expect(
      events.some((event) => event.type === 'artifact.screenshot'),
    ).toBe(false);
    expect(
      events.find(
        (event): event is Extract<RunEvent, { type: 'log' }> =>
          event.type === 'log' &&
          event.level === 'warn' &&
          event.message.includes('Failed to save screenshot artifact'),
      ),
    ).toMatchObject({
      data: {
        code: RR_ERROR_CODES.RESOURCE_LIMIT_EXCEEDED,
        category: 'resource',
        retryable: false,
      },
    });
  });

  it('artifacts: captures successful nodes when policy is always', async () => {
    const runId = 'run-artifact-always';
    const artifactService: ArtifactService = {
      screenshot: vi.fn().mockResolvedValue({ ok: true, base64: 'success-shot' }),
      saveScreenshot: vi.fn().mockResolvedValue({
        savedAs: 'run-artifact-always_A_1.png',
        artifactId: 'run-artifact-always/A/1',
      }),
    };
    const flow = createFlow(
      'A',
      [
        {
          id: 'A',
          kind: 'test',
          config: { action: 'succeed' },
          policy: { artifacts: { screenshot: 'always' } },
        },
      ],
      [],
    );

    const { runner } = createRunnerContext(runId, flow, { artifactService });
    const result = await runner.start();
    expect(result.status).toBe('succeeded');
    expect(artifactService.screenshot).toHaveBeenCalledWith(1, { background: false });
    expect(artifactService.saveScreenshot).toHaveBeenCalledWith(
      runId,
      'A',
      'success-shot',
      undefined,
    );
  });

  it('artifacts: respects explicit never screenshot policy', async () => {
    const runId = 'run-artifact-never';
    const artifactService: ArtifactService = {
      screenshot: vi.fn().mockResolvedValue({ ok: true, base64: 'should-not-capture' }),
      saveScreenshot: vi.fn().mockResolvedValue({
        savedAs: 'run-artifact-never_A_1.png',
        artifactId: 'run-artifact-never/A/1',
      }),
    };
    const flow = createFlow(
      'A',
      [
        {
          id: 'A',
          kind: 'test',
          config: { action: 'fail' },
          policy: { artifacts: { screenshot: 'never' } },
        },
      ],
      [],
    );

    const { runner } = createRunnerContext(runId, flow, { artifactService });
    const result = await runner.start();
    expect(result.status).toBe('failed');
    expect(artifactService.screenshot).not.toHaveBeenCalled();
    expect(artifactService.saveScreenshot).not.toHaveBeenCalled();
  });

  it('artifacts: captures screenshots from the current runtime tab', async () => {
    const runId = 'run-artifact-current-tab';
    const artifactService: ArtifactService = {
      screenshot: vi.fn().mockResolvedValue({ ok: true, base64: 'current-tab-shot' }),
      saveScreenshot: vi.fn().mockResolvedValue({
        savedAs: 'run-artifact-current-tab_B_1.png',
        artifactId: 'run-artifact-current-tab/B/1',
      }),
    };
    const flow = createFlow(
      'A',
      [
        { id: 'A', kind: 'test', config: { action: 'setTab', tabId: 42 } },
        {
          id: 'B',
          kind: 'test',
          config: { action: 'fail' },
          policy: {
            artifacts: { screenshot: 'onFailure' },
          },
        },
      ],
      [{ id: 'e1', from: 'A', to: 'B', label: EDGE_LABELS.DEFAULT }],
    );

    const { runner, bus } = createRunnerContext(runId, flow, {
      artifactService,
    });
    const result = await runner.start();
    expect(result.status).toBe('failed');

    expect(artifactService.screenshot).toHaveBeenCalledWith(42, { background: false });
    expect(artifactService.saveScreenshot).toHaveBeenCalledWith(
      runId,
      'B',
      'current-tab-shot',
      undefined,
    );

    const events = await listEvents(bus, runId);
    const screenshots = events.filter(
      (event): event is Extract<RunEvent, { type: 'artifact.screenshot' }> =>
        event.type === 'artifact.screenshot',
    );
    expect(screenshots).toHaveLength(1);
    expect(screenshots[0]).toMatchObject({
      type: 'artifact.screenshot',
      nodeId: 'B',
      artifactId: 'run-artifact-current-tab/B/1',
      savedAs: 'run-artifact-current-tab_B_1.png',
    });
    expect(screenshots[0]).not.toHaveProperty('data');
  });

  it('artifacts: captures node background screenshots with the current runtime tab', async () => {
    const runId = 'run-artifact-node-background-tab';
    const artifactService: ArtifactService = {
      screenshot: vi.fn().mockResolvedValue({ ok: true, base64: 'background-tab-shot' }),
      saveScreenshot: vi.fn().mockResolvedValue({
        savedAs: 'run-artifact-node-background-tab_B_1.png',
        artifactId: 'run-artifact-node-background-tab/B/1',
      }),
    };
    const flow = createFlow(
      'A',
      [
        { id: 'A', kind: 'test', config: { action: 'setTab', tabId: 42 } },
        {
          id: 'B',
          kind: 'test',
          config: { action: 'fail', background: true },
          policy: {
            artifacts: { screenshot: 'onFailure' },
          },
        },
      ],
      [{ id: 'e1', from: 'A', to: 'B', label: EDGE_LABELS.DEFAULT }],
    );

    const { runner } = createRunnerContext(runId, flow, {
      artifactService,
    });
    const result = await runner.start();
    expect(result.status).toBe('failed');

    expect(artifactService.screenshot).toHaveBeenCalledWith(42, { background: true });
    expect(artifactService.saveScreenshot).toHaveBeenCalledWith(
      runId,
      'B',
      'background-tab-shot',
      undefined,
    );
  });

  it('artifacts: captures inactive runtime tab screenshots in the background', async () => {
    const runId = 'run-artifact-inactive-runtime-tab';
    const artifactService: ArtifactService = {
      screenshot: vi.fn().mockResolvedValue({ ok: true, base64: 'inactive-tab-shot' }),
      saveScreenshot: vi.fn().mockResolvedValue({
        savedAs: 'run-artifact-inactive-runtime-tab_B_1.png',
        artifactId: 'run-artifact-inactive-runtime-tab/B/1',
      }),
    };
    (chrome.tabs.get as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
      id: 42,
      active: false,
      windowId: 1,
    } as chrome.tabs.Tab);
    const flow = createFlow(
      'A',
      [
        { id: 'A', kind: 'test', config: { action: 'setTab', tabId: 42 } },
        {
          id: 'B',
          kind: 'test',
          config: { action: 'fail' },
          policy: {
            artifacts: { screenshot: 'onFailure' },
          },
        },
      ],
      [{ id: 'e1', from: 'A', to: 'B', label: EDGE_LABELS.DEFAULT }],
    );

    const { runner } = createRunnerContext(runId, flow, {
      artifactService,
    });
    const result = await runner.start();
    expect(result.status).toBe('failed');

    expect(artifactService.screenshot).toHaveBeenCalledWith(42, { background: true });
    expect(artifactService.saveScreenshot).toHaveBeenCalledWith(
      runId,
      'B',
      'inactive-tab-shot',
      undefined,
    );
  });

  it('default: without onError policy, uses ON_ERROR edge when present', async () => {
    const runId = 'run-default-goto';
    const flow = createFlow(
      'A',
      [
        { id: 'A', kind: 'test', config: { action: 'fail' } },
        { id: 'B', kind: 'test', config: { action: 'succeed' } },
        { id: 'C', kind: 'test', config: { action: 'succeed' } },
      ],
      [
        { id: 'e1', from: 'A', to: 'B', label: EDGE_LABELS.DEFAULT },
        { id: 'e2', from: 'A', to: 'C', label: EDGE_LABELS.ON_ERROR },
      ],
    );

    const { runner, bus, runsById } = createRunnerContext(runId, flow);
    const result = await runner.start();
    expect(result.status).toBe('succeeded');
    expect(runsById.get(runId)?.status).toBe('succeeded');

    const events = await listEvents(bus, runId);
    expect(nodeFailedEvents(events, 'A')[0].decision).toBe('goto');
    expect(startedNodeIds(events)).toEqual(['A', 'C']);
  });

  it('default: without onError policy and without ON_ERROR edge, stops', async () => {
    const runId = 'run-default-stop';
    const flow = createFlow(
      'A',
      [
        { id: 'A', kind: 'test', config: { action: 'fail' } },
        { id: 'B', kind: 'test', config: { action: 'succeed' } },
      ],
      [{ id: 'e1', from: 'A', to: 'B', label: EDGE_LABELS.DEFAULT }],
    );

    const { runner, bus, runsById } = createRunnerContext(runId, flow);
    const result = await runner.start();
    expect(result.status).toBe('failed');
    expect(runsById.get(runId)?.status).toBe('failed');

    const events = await listEvents(bus, runId);
    expect(nodeFailedEvents(events, 'A')[0].decision).toBe('stop');
    expect(startedNodeIds(events)).toEqual(['A']);
  });
});
