import type {
  AIProvider,
  AIProviderRegistry,
  ChatMessage,
  ChatRequestOptions,
  ChatThinkingPolicy,
} from '@genesislcap/foundation-ai';
import { assert, createLogicSuite } from '@genesislcap/foundation-testing';
import type { AgentConfig } from '../../config/config';
// 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';

// ---------------------------------------------------------------------------
// Per-turn `thinkingPolicy` resolution.
//
// The cost case for this option is the SHAPE of a tool loop, not a single call: the
// opening turn is a real decision and worth reasoning over, while the iterations that
// follow mostly pick the next tool from a narrow set — and on a thinking model each of
// those bills reasoning at the full output rate. That saving only exists if the
// resolver runs per iteration, so these tests assert the sequence across a loop, not
// just that one value arrives.
//
// The other half is the undefined case. Every agent written before this option leaves
// it unset, and a resolver may answer `undefined` on any given turn; both must reach
// the transport as `undefined` so the model keeps its own default. `undefined` reaching
// the wire as `'off'` would be a silent capability regression, and as `'auto'` a silent
// bill increase — so it is asserted explicitly rather than assumed.
// ---------------------------------------------------------------------------

/** Captures the policy seen on each model call; calls one tool, then finishes. */
const capturingProvider = (): AIProvider & { seen: Array<ChatThinkingPolicy | undefined> } => {
  const seen: Array<ChatThinkingPolicy | undefined> = [];
  let turns = 0;
  return {
    seen,
    chat: async (
      _history: ChatMessage[],
      _userMessage: string,
      options?: ChatRequestOptions,
    ): Promise<ChatMessage> => {
      seen.push(options?.thinkingPolicy);
      turns += 1;
      // Two tool calls, so the loop runs three model calls in total — enough for a
      // per-turn resolver to say something different on the later ones.
      if (turns <= 2) {
        return {
          role: 'assistant',
          content: '',
          toolCalls: [{ id: `t${turns}`, name: 'step', 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>): AgentConfig =>
  ({
    name: 'worker',
    description: 'test agent',
    toolDefinitions: [
      { name: 'step', description: 'step', parameters: { type: 'object', properties: {} } },
    ],
    toolHandlers: { step: async () => 'stepped' },
    ...overrides,
  }) as AgentConfig;

/** Run one user turn through a driver carrying `config`, and return the policies seen. */
const policiesFor = async (
  config: Partial<AgentConfig>,
): Promise<Array<ChatThinkingPolicy | undefined>> => {
  const provider = capturingProvider();
  const driver = new ChatDriver(makeRegistry(provider), {
    maxToolIterations: 10,
    maxFoldOperations: 5,
    sessionKey: '',
  });
  driver.applyAgent(agent(config));
  await driver.sendMessage('go');
  return provider.seen;
};

const suite = createLogicSuite('ChatDriver thinkingPolicy');

suite('leaves the policy undefined when the agent does not set one', async () => {
  // The compatibility case: every existing agent. Undefined must reach the transport
  // as undefined so each model keeps its own default posture — not silently coerced
  // to 'off' (a capability regression) or 'auto' (a bill increase).
  const seen = await policiesFor({});
  assert.ok(seen.length >= 3, `expected a multi-call loop, got ${seen.length}`);
  assert.equal(
    seen.filter((p) => p !== undefined),
    [],
    'no turn invents a policy',
  );
});

suite('applies a static policy to every turn of the loop', async () => {
  const seen = await policiesFor({ thinkingPolicy: 'off' });
  assert.ok(seen.length >= 3);
  assert.equal([...new Set(seen)], ['off'], 'a static value is not just a first-turn setting');
});

suite('pins the resolved policy for the whole tool loop', async () => {
  // A tool-use loop is ONE assistant turn and Anthropic requires a single thinking mode
  // for its duration. Toggling part-way does not error — the API silently disables
  // thinking for that request and strips blocks that would leave the turn structure
  // invalid, so an 'auto' -> 'off' switch loses the continuity the opening call
  // established and 'off' -> 'auto' never delivers the reasoning asked for. It also
  // invalidates the prompt cache, costing more than the reasoning it meant to save.
  //
  // So a resolver that changes its mind mid-loop must NOT be honoured mid-loop. This
  // asserts the opposite of what it looks like it should: the later values are ignored.
  let call = 0;
  const seen = await policiesFor({
    thinkingPolicy: () => {
      call += 1;
      return call === 1 ? 'auto' : 'off';
    },
  });
  assert.ok(seen.length >= 3, `expected a multi-call loop, got ${seen.length}`);
  assert.equal(
    [...new Set(seen)],
    ['auto'],
    'the first call decides; later resolutions do not take effect until the next user turn',
  );
});

suite('re-resolves on the next user turn', async () => {
  // The flip side: pinning is per turn, not for the driver's lifetime, so a state change
  // between turns still lands.
  const provider = capturingProvider();
  const driver = new ChatDriver(makeRegistry(provider), {
    maxToolIterations: 10,
    maxFoldOperations: 5,
    sessionKey: '',
  });
  let turn = 0;
  driver.applyAgent(
    agent({
      thinkingPolicy: () => (turn === 0 ? 'auto' : 'off'),
    }),
  );
  await driver.sendMessage('go');
  const firstTurn = [...provider.seen];
  turn = 1;
  await driver.sendMessage('again');
  const secondTurn = provider.seen.slice(firstTurn.length);

  assert.equal([...new Set(firstTurn)], ['auto'], 'turn one holds its posture');
  assert.equal([...new Set(secondTurn)], ['off'], 'turn two picks up the new one');
});

suite('passes undefined through when the resolver declines to choose', async () => {
  // A resolver may answer on some turns and not others. `undefined` is its third answer —
  // "leave this model alone" — and must not be normalised into a value. Asserted across
  // the whole loop because the first call's answer is the one that gets pinned.
  const seen = await policiesFor({ thinkingPolicy: () => undefined });
  assert.ok(seen.length >= 3);
  assert.equal(
    seen.filter((p) => p !== undefined),
    [],
    'declining is not the same as choosing',
  );
});

suite('awaits an async resolver', async () => {
  const seen = await policiesFor({
    thinkingPolicy: async () => 'off' as ChatThinkingPolicy,
  });
  assert.equal([...new Set(seen)], ['off'], 'a promise is resolved, not passed through');
});

suite.run();
