import type {
  AIProvider,
  AIProviderRegistry,
  ChatMessage,
  ChatRequestOptions,
} from '@genesislcap/foundation-ai';
import { assert, createLogicSuite } from '@genesislcap/foundation-testing';
import type { AgentConfig } from '../../config/config';
import { defineStatefulAgent } from '../../config/define-stateful-agent';
// Side-effect import — MUST come before `./chat-driver` so the driver subclasses
// jsdom's EventTarget rather than Node's native one. Mirrors chat-driver.test.ts.
import './align-event-globals';
import { ChatDriver } from './chat-driver';

// ---------------------------------------------------------------------------
// Sub-agent state scoping.
//
// A sub-agent config lives in the parent's `subAgentsMap`, keyed by NAME — one
// config object, reused for every invocation. Anything a handler closes over is
// therefore scoped to the *config*, not to the invocation.
//
// These tests PIN that behaviour; they do not wish it away. Per-conversation
// registration is deliberate, so a closure in a sub-agent config will always be
// config-scoped — no planned platform change (F8 included) alters it. F8 adds a
// separate invocation-scoped store to write to instead; it does not re-scope
// closures. Consumers that accumulate across a child's turns must use that store
// (today: a WeakMap keyed on `promptCtx.signal`), never a closure.
//
// ⚠ TO WHOEVER IMPLEMENTS THE PER-ACTIVATION-STATE TICKET
// (`defineStatefulAgent`: per-activation state and headless execution)
//
// Its acceptance criterion 2 — "two concurrent activations of the same config
// hold independent state, with a test that fails against today's shared closure
// variable" — reads almost word-for-word like the first two tests below. It is
// not the same subject, and they must be treated differently:
//
//   • Criterion 2 concerns FRAMEWORK-owned state: `defineStatefulAgent`'s single
//     `let state` (define-stateful-agent.ts:374). That is the bug it fixes, and
//     it needs a NEW test of its own.
//   • The first two tests below concern CONSUMER-owned closures in a plain
//     `AgentConfig`. The platform cannot re-scope a closure someone wrote in
//     their own factory, so these stay true afterwards. Do not delete or invert
//     them to make criterion 2 look satisfied.
//   • The third test — "a defineStatefulAgent config cannot be used as a
//     sub-agent" — IS invalidated by that ticket (its criteria 1 and 3), and
//     must be rewritten when it lands, not before.
//
// The provider here answers by rule rather than by scripted queue: sub-agent
// invocations dispatched from one assistant turn run concurrently under
// `Promise.all`, so a shared FIFO queue would make ordering — and the test —
// nondeterministic.
// ---------------------------------------------------------------------------

/**
 * Answers by rule, not by scripted queue: sub-agent invocations dispatched from
 * one assistant turn run concurrently under `Promise.all`, so a shared FIFO
 * would make ordering — and the test — nondeterministic.
 *
 * `fanOut` controls how many `delegate` calls the parent emits in its single
 * delegating turn. Parent turns after the first return plain text so the loop
 * terminates.
 */
const ruleProvider = (fanOut: number): AIProvider => {
  let parentTurns = 0;
  return {
    chat: async (
      _history: ChatMessage[],
      _userMessage: string,
      options?: ChatRequestOptions,
    ): Promise<ChatMessage> => {
      const tools = (options?.tools ?? []).map((t) => t.name);
      // Child turn: it can only finish via its completion tool.
      if (tools.includes('work')) {
        return {
          role: 'assistant',
          content: '',
          toolCalls: [{ id: 'w1', name: 'work', args: {} }],
        };
      }
      // Parent turn — delegate once, then wrap up.
      if (tools.includes('delegate') && parentTurns === 0) {
        parentTurns += 1;
        return {
          role: 'assistant',
          content: '',
          toolCalls: Array.from({ length: fanOut }, (_, i) => ({
            id: `d${i}`,
            name: 'delegate',
            args: {},
          })),
        };
      }
      return { role: 'assistant', content: 'done' };
    },
  };
};

const makeRegistry = (provider: AIProvider): AIProviderRegistry => ({
  get: () => provider,
  default: () => provider,
  defaultName: () => 'test',
  names: () => ['test'],
  getStatus: async () => null,
  listStatuses: async () => [],
});

const agent = (overrides: Partial<AgentConfig> & { name: string }): AgentConfig =>
  ({ description: 'test agent', ...overrides }) as AgentConfig;

const makeDriver = (config: AgentConfig, provider: AIProvider): ChatDriver => {
  const driver = new ChatDriver(makeRegistry(provider), {
    maxToolIterations: 20,
    maxFoldOperations: 5,
    sessionKey: '',
  });
  driver.applyAgent(config);
  return driver;
};

const scope = createLogicSuite('ChatDriver sub-agent invocation scope');

// ---------------------------------------------------------------------------

scope(
  'closure state is config-scoped, so concurrent invocations contaminate each other',
  async () => {
    // The natural-looking way to accumulate across a sub-agent's turns — a closure
    // in the config factory — is created once, with the config, and shared by every
    // invocation. This is the trap; the assertion records it rather than wanting it.
    const collected: string[] = [];

    const worker = agent({
      name: 'worker',
      toolDefinitions: [
        { name: 'work', description: 'work', parameters: { type: 'object', properties: {} } },
      ],
      toolHandlers: {
        work: async (_args, ctx) => {
          collected.push('item');
          // Report what THIS invocation believes it has accumulated.
          ctx.completeSubAgent?.({ seen: collected.length });
          return 'worked';
        },
      },
    });

    const outcomes: unknown[] = [];
    const parent = agent({
      name: 'boss',
      subAgents: [worker],
      toolDefinitions: [
        {
          name: 'delegate',
          description: 'delegate',
          parameters: { type: 'object', properties: {} },
        },
      ],
      toolHandlers: {
        delegate: async (_args, ctx) => {
          const outcome = await ctx.requestSubAgent!('worker', { task: 'go' });
          outcomes.push(outcome);
          return 'delegated';
        },
      },
    });

    await makeDriver(parent, ruleProvider(2)).sendMessage('go');

    assert.is(outcomes.length, 2, 'both delegations should have resolved');

    // Each invocation did exactly one unit of work, so an invocation-scoped
    // accumulator would report 1 from both. A config-scoped one reports 1 and 2.
    // Deterministic despite the concurrency: the handler pushes and reads with no
    // await between, so two invocations cannot interleave mid-body.
    const seen = outcomes.map((o: any) => o?.result?.seen).sort();
    assert.equal(
      seen,
      [1, 2],
      "the second invocation sees the first's work — if this ever reports [1,1], closures " +
        'have become invocation-scoped and this test should be rewritten, not deleted',
    );
  },
);

// ---------------------------------------------------------------------------

scope('closure state is config-scoped, so a later invocation resumes the earlier one', async () => {
  // The sequential form of the same trap: nothing resets the closure between
  // invocations, so a second delegation resumes where the first left off. This is
  // the failure CG8 describes — a retried child whose cursor is already exhausted
  // emits nothing and completes silently.
  const collected: string[] = [];

  const worker = agent({
    name: 'worker',
    toolDefinitions: [
      { name: 'work', description: 'work', parameters: { type: 'object', properties: {} } },
    ],
    toolHandlers: {
      work: async (_args, ctx) => {
        collected.push('item');
        ctx.completeSubAgent?.({ seen: collected.length });
        return 'worked';
      },
    },
  });

  const outcomes: unknown[] = [];
  const parent = agent({
    name: 'boss',
    subAgents: [worker],
    toolDefinitions: [
      { name: 'delegate', description: 'delegate', parameters: { type: 'object', properties: {} } },
    ],
    toolHandlers: {
      // Await them one after the other — no concurrency involved.
      delegate: async (_args, ctx) => {
        outcomes.push(await ctx.requestSubAgent!('worker', { task: 'first' }));
        outcomes.push(await ctx.requestSubAgent!('worker', { task: 'second' }));
        return 'delegated';
      },
    },
  });

  await makeDriver(parent, ruleProvider(1)).sendMessage('go');

  const seen = (outcomes as any[]).map((o) => o?.result?.seen);
  assert.equal(
    seen,
    [1, 2],
    "a second invocation does NOT start from zero — it resumes the first's accumulator",
  );
});

// ---------------------------------------------------------------------------

scope('a defineStatefulAgent config cannot be used as a sub-agent', async () => {
  // `init` runs from `onActivate`, which only OrchestratingDriver fires on an
  // agent switch. `invokeSubAgent` builds a raw ChatDriver and calls
  // `applyAgent` directly, so a stateful sub-agent never initialises. This test
  // pins that boundary — it is a documented-by-behaviour constraint, not a bug,
  // and the failure is loud rather than silent.
  const stateful = defineStatefulAgent<{ count: number }>({
    name: 'worker',
    description: 'stateful worker',
    init: () => ({ count: 0 }),
    systemPrompt: ({ state }) => `count=${state.count}`,
    toolDefinitions: [
      { name: 'work', description: 'work', parameters: { type: 'object', properties: {} } },
    ],
    toolHandlers: () => ({
      work: async (_args, ctx) => {
        ctx.completeSubAgent?.({ ok: true });
        return 'worked';
      },
    }),
  });

  let outcome: any;
  const parent = agent({
    name: 'boss',
    subAgents: [stateful],
    toolDefinitions: [
      { name: 'delegate', description: 'delegate', parameters: { type: 'object', properties: {} } },
    ],
    toolHandlers: {
      delegate: async (_args, ctx) => {
        outcome = await ctx.requestSubAgent!('worker', { task: 'go' });
        return 'delegated';
      },
    },
  });

  await makeDriver(parent, ruleProvider(1)).sendMessage('go');

  assert.ok(outcome, 'the delegation should have resolved rather than hanging');
  assert.is(
    outcome.ok,
    false,
    `a stateful sub-agent never runs init (onActivate is orchestrator-only), so the ` +
      `invocation must fail rather than silently run with undefined state — got ${JSON.stringify(outcome)}`,
  );
});

scope.run();
