import type {
  AIProvider,
  AIProviderRegistry,
  ChatDriverResult,
  ChatMessage,
  ChatRequestOptions,
} from '@genesislcap/foundation-ai';
import { BudgetExhaustedError } from '@genesislcap/foundation-ai';
import { assert, createLogicSuite } from '@genesislcap/foundation-testing';
import { AgenticActivityBus } from '../../channel/ai-activity-bus';
import type { AgentConfig } from '../../config/config';
// Side-effect import — MUST precede `./orchestrating-driver` so the driver
// subclasses jsdom's EventTarget (CustomEvent dispatch then works in node).
import '../chat-driver/align-event-globals';
import { OrchestratingDriver } from './orchestrating-driver';

// GENC-1464 — the orchestrated turn's OUTCOME.
//
// `createDriver` returns an `OrchestratingDriver` for every agents-configured
// host, and its `sendMessage` used to end with a hardcoded `return { reason:
// 'done' }`. That made every typed failure unreachable for those hosts —
// including `'budget-exhausted'`, which the assistant element latches its blocked
// state off. A 402 therefore produced an apology over a still-enabled composer
// for exactly the configurations this workstream exists to fix.
//
// The classifier compounds it: `classify()` is a SECOND retry ladder stacked on
// the transport's, so an untyped catch re-issued the 402 `classifierRetries + 1`
// times and then ran a fallback turn into the same wall.

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;

/** A provider that ends every turn immediately with a plain-text reply (no tools). */
const okProvider = (): AIProvider =>
  ({
    chat: async (): Promise<ChatMessage> => ({ role: 'assistant', content: 'ok' }),
  }) as AIProvider;

/** A provider that counts calls and always refuses for budget. */
const walledProvider = (
  vendorLabel = 'Anthropic',
): { calls: () => number; provider: AIProvider } => {
  let n = 0;
  return {
    calls: () => n,
    provider: {
      chat: async (): Promise<ChatMessage> => {
        n += 1;
        throw new BudgetExhaustedError(vendorLabel, 25, 25.4);
      },
    } as AIProvider,
  };
};

const failureReasonOf = (r: ChatDriverResult) =>
  r.reason === 'done' ? r.failureReason : undefined;

const Suite = createLogicSuite('orchestrating-driver budget wall');

// ── 1. The showstopper: a walled AGENT turn must reach the caller ───────────

Suite('a budget wall on the agent turn surfaces as the orchestrated result', async () => {
  // ONE specialist and no fallback, so `classify` short-circuits without calling
  // the provider — the wall is hit by the AGENT turn, inside a real tool loop.
  // Against the old hardcoded return this deep-equal fails.
  const { provider } = walledProvider();
  const driver = new OrchestratingDriver(makeRegistry(provider), [agent({ name: 'Solo' })], {
    sessionKey: 'budget-1',
  });

  const result = await driver.sendMessage('go');

  assert.equal(result, {
    reason: 'done',
    failureReason: 'budget-exhausted',
    budget: { budgetUsd: 25, spentUsd: 25.4, vendorLabel: 'Anthropic', vendor: 'anthropic' },
  });
});

Suite("the vendor is the refusing transport's, not a hardcoded default", async () => {
  // The label rides the contract; `vendor` is its normalised key. Without a
  // second vendor exercised here, a constant would pass every other assertion in
  // this file — and per-vendor blocking would wall the wrong vendor forever.
  const { provider } = walledProvider('Gemini');
  const driver = new OrchestratingDriver(makeRegistry(provider), [agent({ name: 'Solo' })], {
    sessionKey: 'budget-gemini',
  });

  const result = await driver.sendMessage('go');

  assert.equal(result.reason === 'done' ? result.budget : undefined, {
    budgetUsd: 25,
    spentUsd: 25.4,
    vendorLabel: 'Gemini',
    vendor: 'gemini',
  });
});

Suite('an unrecognised vendor label carries no typed vendor at all', async () => {
  // Attribution degrades to "unattributable" rather than to a wrong vendor —
  // which makes the element fall back to its vendor-agnostic block. Safe.
  const { provider } = walledProvider('Acme AI');
  const driver = new OrchestratingDriver(makeRegistry(provider), [agent({ name: 'Solo' })], {
    sessionKey: 'budget-unknown-vendor',
  });

  const result = await driver.sendMessage('go');

  assert.equal(result.reason === 'done' ? result.budget : undefined, {
    budgetUsd: 25,
    spentUsd: 25.4,
    vendorLabel: 'Acme AI',
  });
});

Suite('the wall also reaches the tool-loop-end bus detail through the orchestrator', async () => {
  const bus = new AgenticActivityBus();
  const seen: Array<{ failureReason?: string } | undefined> = [];
  const stop = bus.subscribe('tool-loop-end', (d) => seen.push(d));
  const { provider } = walledProvider();
  const driver = new OrchestratingDriver(makeRegistry(provider), [agent({ name: 'Solo' })], {
    sessionKey: 'budget-2',
    activityBus: bus,
  });

  await driver.sendMessage('go');

  assert.is(seen.length, 1, 'exactly one tool-loop-end for the one agent turn');
  assert.is(seen[0]?.failureReason, 'budget-exhausted');
  stop();
  bus.close();
});

Suite('the bus detail carries the proxy figures, not just the failure reason', async () => {
  // The element latches off THIS event for an in-loop wall — the publish sits in
  // sendMessage's `finally`, so it lands before the return-value seam and the
  // idempotent latch makes the later one a no-op. If the figures rode only the
  // return value they would be unreachable in the common case and the banner would
  // show the generic copy in exactly the path they were added for.
  const bus = new AgenticActivityBus();
  const seen: Array<
    | {
        failureReason?: string;
        vendor?: string;
        budget?: {
          budgetUsd?: number;
          spentUsd?: number;
          vendorLabel: string;
          vendor?: string;
        };
      }
    | undefined
  > = [];
  const stop = bus.subscribe('tool-loop-end', (d) => seen.push(d));
  const { provider } = walledProvider();
  const driver = new OrchestratingDriver(makeRegistry(provider), [agent({ name: 'Solo' })], {
    sessionKey: 'budget-figures',
    activityBus: bus,
  });

  const result = await driver.sendMessage('go');

  assert.equal(
    seen[0]?.budget,
    { budgetUsd: 25, spentUsd: 25.4, vendorLabel: 'Anthropic', vendor: 'anthropic' },
    'the event carries the same figures as the return value',
  );
  assert.is(
    seen[0]?.vendor,
    'anthropic',
    'and the top-level vendor agrees with the refusing transport, not with a stale last-resolved provider',
  );
  assert.equal(
    result.reason === 'done' ? result.budget : undefined,
    seen[0]?.budget,
    'event and return value agree — neither is the sole carrier',
  );
  stop();
  bus.close();
});

Suite('a clean turn publishes no budget key at all', async () => {
  const bus = new AgenticActivityBus();
  const seen: Array<{ budget?: unknown } | undefined> = [];
  const stop = bus.subscribe('tool-loop-end', (d) => seen.push(d));
  const provider: AIProvider = {
    chat: async () => ({ role: 'assistant', content: 'ok' }) as ChatMessage,
  } as unknown as AIProvider;
  const driver = new OrchestratingDriver(makeRegistry(provider), [agent({ name: 'Solo' })], {
    sessionKey: 'budget-clean',
    activityBus: bus,
  });

  await driver.sendMessage('go');

  assert.ok(
    seen.every((d) => d === undefined || !('budget' in d)),
    'no budget key on a non-budget turn — the historical detail shape is unchanged',
  );
  stop();
  bus.close();
});

// ── 2. No regression on the happy / handoff paths ───────────────────────────

Suite('a clean turn still returns the bare legacy shape', async () => {
  const driver = new OrchestratingDriver(makeRegistry(okProvider()), [agent({ name: 'Solo' })], {
    sessionKey: 'budget-3',
  });

  const result = await driver.sendMessage('go');

  assert.equal(result, { reason: 'done' }, 'no failureReason on a clean turn');
});

Suite('a handoff-capped turn never leaks agent-handoff to the caller', async () => {
  // `'agent-handoff'` is an internal routing protocol. The two breaks that hold a
  // non-'done' inner result both carry it, so the collapse rule in `sendMessage`
  // is what keeps "report the turn that ended the loop" from changing the
  // discriminant a host matches on.
  let n = 0;
  const nextId = () => {
    n += 1;
    return n;
  };
  const handoffProvider: AIProvider = {
    chat: async (
      _h: ChatMessage[],
      _u: string,
      options?: ChatRequestOptions,
    ): Promise<ChatMessage> => {
      // The classifier call carries the routing tool; answer it with index 0.
      if (options?.tools?.some((t) => t.name === 'select_agent')) {
        return {
          role: 'assistant',
          content: '',
          toolCalls: [{ id: `c-${nextId()}`, name: 'select_agent', args: { agent_index: 0 } }],
        };
      }
      // Every agent turn asks to hand off, so the handoff cap is what ends it.
      return {
        role: 'assistant',
        content: '',
        toolCalls: [
          {
            id: `h-${nextId()}`,
            name: 'request_continuation',
            args: { summary: 's', remaining_task: 'more' },
          },
        ],
      };
    },
  };
  const driver = new OrchestratingDriver(
    makeRegistry(handoffProvider),
    [agent({ name: 'A' }), agent({ name: 'B' })],
    { sessionKey: 'budget-4', maxHandoffs: 1 },
  );

  const result = await driver.sendMessage('go');

  assert.is(result.reason, 'done', 'the internal handoff protocol never escapes');
});

Suite('a wall on a post-handoff classify does not duplicate the user message', async () => {
  // `classify()` runs at TWO seams. Before the first agent turn the user's
  // message is unappended (only the optimistic `history-updated` echo exists),
  // so the wall-catch must append it. At the END of a handoff iteration
  // `chatDriver.sendMessage` has already appended it — passing it again
  // produced ['user','assistant','tool','user','assistant'] with 'go' twice,
  // persisting into the snapshot and into the history sent to the model once
  // the budget was raised. The single-specialist narrowness guard below cannot
  // catch this: with one specialist, classify short-circuits and the handoff
  // seam never runs.
  let n = 0;
  const provider: AIProvider = {
    chat: async (
      _h: ChatMessage[],
      _u: string,
      options?: ChatRequestOptions,
    ): Promise<ChatMessage> => {
      n += 1;
      if (options?.tools?.some((t) => t.name === 'select_agent')) {
        // First classify routes; the post-handoff classify hits the wall.
        if (n > 2) throw new BudgetExhaustedError('Anthropic', 25, 25.4);
        return {
          role: 'assistant',
          content: '',
          toolCalls: [{ id: `c-${n}`, name: 'select_agent', args: { agent_index: 0 } }],
        };
      }
      // The agent turn asks to hand off, forcing the second classify.
      return {
        role: 'assistant',
        content: '',
        toolCalls: [
          {
            id: `h-${n}`,
            name: 'request_continuation',
            args: { summary: 's', remaining_task: 'more' },
          },
        ],
      };
    },
  };
  const driver = new OrchestratingDriver(
    makeRegistry(provider),
    [agent({ name: 'A' }), agent({ name: 'B' })],
    { sessionKey: 'budget-4b' },
  );

  const result = await driver.sendMessage('go');

  assert.is(failureReasonOf(result), 'budget-exhausted');
  const history = driver.getRawHistory() as ChatMessage[];
  const userMessages = history.filter((m) => m.role === 'user' && m.content === 'go');
  assert.is(userMessages.length, 1, "the user's message appears exactly once");
});

// ── 3. The classifier's own retry ladder ───────────────────────────────────

Suite('a wall during classification costs exactly one provider call', async () => {
  // TWO specialists and no fallback, so `classify` really runs. Before the narrow
  // rethrow this was FOUR calls: `classifierRetries + 1` classifier attempts, then
  // a doomed agent turn.
  const { calls, provider } = walledProvider();
  const driver = new OrchestratingDriver(
    makeRegistry(provider),
    [agent({ name: 'A' }), agent({ name: 'B' })],
    { sessionKey: 'budget-5' },
  );

  const result = await driver.sendMessage('go');

  assert.is(calls(), 1, 'no ladder clears a budget wall — do not climb it');
  assert.is(failureReasonOf(result), 'budget-exhausted');
});

Suite('a classify-time wall lands on the transcript, without a routing apology', async () => {
  // The old ordering emitted "I'm not sure how to help with that…" from the
  // no-fallback exit and THEN routed anyway, so the user read a routing excuse
  // the classifier never earned, immediately above the real error.
  const { provider } = walledProvider();
  const driver = new OrchestratingDriver(
    makeRegistry(provider),
    [agent({ name: 'A' }), agent({ name: 'B' })],
    { sessionKey: 'budget-6' },
  );

  await driver.sendMessage('go');

  const history = driver.getRawHistory() as ChatMessage[];
  const text = history.map((m) => m.content).join('\n');
  assert.ok(text.includes('AI usage limit'), 'the real reason is what the user reads');
  assert.not.ok(text.includes("I'm not sure how to help with that"));
  // The user's own message must survive. `runOrchestratedTurn` dispatches it
  // optimistically and leaves the real append to `chatDriver.sendMessage`, which
  // a classify-time wall never reaches — so the driver's history held only the
  // assistant bubble and the next render dropped what the user had typed.
  assert.equal(
    history.map((m) => m.role),
    ['user', 'assistant'],
    'the question stays above the answer',
  );
  assert.is(history[0].content, 'go');
});

Suite('an in-loop wall does not double-append the user message', async () => {
  // Narrowness guard for the append above: `reportBudgetExhausted` also serves
  // the in-loop path, where `sendMessage` has ALREADY appended the user message.
  // Only the classify seam passes one, so this path must be unchanged. ONE
  // specialist and no fallback, so `classify` short-circuits and the wall is hit
  // inside a real tool loop.
  const { provider } = walledProvider();
  const driver = new OrchestratingDriver(makeRegistry(provider), [agent({ name: 'Solo' })], {
    sessionKey: 'budget-6b',
  });

  await driver.sendMessage('go');

  const history = driver.getRawHistory() as ChatMessage[];
  assert.is(history.filter((m) => m.role === 'user').length, 1, 'one user message, not two');
});

Suite('a wall during classification never starts the fallback turn', async () => {
  const { calls, provider } = walledProvider();
  const driver = new OrchestratingDriver(
    makeRegistry(provider),
    [agent({ name: 'A' }), agent({ name: 'B' }), agent({ name: 'Catch-all', fallback: true })],
    { sessionKey: 'budget-7' },
  );

  const result = await driver.sendMessage('go');

  assert.is(calls(), 1, 'the fallback agent is not run into the same wall');
  assert.is(failureReasonOf(result), 'budget-exhausted');
});

Suite('a plain classifier failure still retries and falls back (narrowness guard)', async () => {
  // Proves the rethrow did not make every classify failure terminal: only the
  // budget wall short-circuits, because only the budget wall is unclearable.
  let classifierCalls = 0;
  const provider: AIProvider = {
    chat: async (
      _h: ChatMessage[],
      _u: string,
      options?: ChatRequestOptions,
    ): Promise<ChatMessage> => {
      if (options?.tools?.some((t) => t.name === 'select_agent')) {
        classifierCalls += 1;
        throw new Error('classifier is having a moment');
      }
      return { role: 'assistant', content: 'fallback handled it' };
    },
  };
  const driver = new OrchestratingDriver(
    makeRegistry(provider),
    [agent({ name: 'A' }), agent({ name: 'B' }), agent({ name: 'Catch-all', fallback: true })],
    { sessionKey: 'budget-8' },
  );

  const result = await driver.sendMessage('go');

  assert.is(classifierCalls, 3, 'classifierRetries (2) + the first attempt');
  assert.is(result.reason, 'done');
  assert.is(failureReasonOf(result), undefined, 'the fallback turn succeeded');
});

Suite.run();
