import type {
  AIProviderRegistryStatusEntry,
  AIProviderType,
  ChatMessage,
  TurnFailureReason,
} from '@genesislcap/foundation-ai';
import { DEFAULT_BUDGET_EXHAUSTED_MESSAGE } from '@genesislcap/foundation-ai';
import { assert, createLogicSuite } from '@genesislcap/foundation-testing';
import type { SessionStoreReturn } from '../state/session-store';
import { clearAllSessionStores, getSessionStore } from '../state/session-store';
import { formatBlockedReason, FoundationAiAssistant } from './main';
import { BLOCKED_BANNER_ID, blockedBannerClasses, composerDescribedBy } from './main.template';

// Hold a reference so the custom-element registration isn't tree-shaken.
FoundationAiAssistant;

// GENC-1464 — the first-class "AI budget exhausted" blocked state.
//
// Like `popout-interaction-gate.test.ts`, this pins the GETTER LOGIC the template
// bindings depend on rather than the rendered DOM, and for the same reason: mounting
// the assistant runs `connectedCallback`, which subscribes to `agenticActivityBus` and
// leaves the runner's event loop open. `document.createElement` only upgrades the
// element (constructor) — it does not connect — so nothing subscribes here.
//
// The three bindings under test in `main.template.ts` are:
//   ?disabled=${(x) => x.busy || x.compacting || x.restoring || x.blocked}   (textarea)
//   ?disabled=${(x) => ... || x.blocked || ...}                             (send button)
//   ${when((x) => x.blocked, blockedBannerTemplate)}                        (banner)
// All three are driven by `blocked`; the banner's only dynamic content is
// `effectiveBlockedReason`. Both are asserted below.

const Suite = createLogicSuite('FoundationAiAssistant blocked state');

let storeSeq = 0;

/** A fresh (unconnected) element wired to its own real session store. */
function element(): FoundationAiAssistant {
  const el = document.createElement('foundation-ai-assistant') as FoundationAiAssistant;
  storeSeq += 1;
  (el as unknown as { _sessionRef: unknown })._sessionRef = getSessionStore(
    `blocked-test-${storeSeq}`,
    false,
  );
  return el;
}

/** `sendBlocked` is private; this reads it the way the composer bindings do. */
const sendBlocked = (el: FoundationAiAssistant): boolean =>
  (el as unknown as { sendBlocked: boolean }).sendBlocked;

/** The `ChatDriverResult['budget']` shape, as the latch receives it. */
type LatchBudget = {
  budgetUsd?: number;
  spentUsd?: number;
  vendorLabel: string;
  vendor?: AIProviderType;
  otherVendorAvailable?: boolean;
};

/**
 * `latchBlockedFrom` is the single decision point both latch sites go through
 * (the `tool-loop-end` bus subscription and `send()`'s return-value check). It is
 * private, so drive it the way those sites do rather than reproducing its rule.
 */
const latch = (
  el: FoundationAiAssistant,
  reason?: TurnFailureReason,
  budget?: LatchBudget,
  vendorHint?: AIProviderType,
): void =>
  (
    el as unknown as {
      latchBlockedFrom: (
        r?: TurnFailureReason,
        ref?: unknown,
        b?: LatchBudget,
        v?: AIProviderType,
      ) => void;
    }
  ).latchBlockedFrom(
    reason,
    (el as unknown as { _sessionRef: unknown })._sessionRef,
    budget,
    vendorHint,
  );

/**
 * Point the element's provider statuses at a set of vendors — the input
 * `reachableVendors` (and therefore the derived `blocked`) reads. The first
 * entry is the registry default, which is what `suggestionsBlocked` resolves
 * against.
 */
const reachable = (el: FoundationAiAssistant, ...vendors: AIProviderType[]): void => {
  el.providerStatuses = vendors.map(
    (provider, i): AIProviderRegistryStatusEntry => ({
      name: provider,
      isDefault: i === 0,
      status: { provider, model: `${provider}-model` },
    }),
  );
};

/**
 * Like `reachable`, but with a registry behind the statuses so the capability
 * gate in `reachableVendors` has something to consult. `chatless` names the
 * vendors whose provider — like the shipped ChromeProvider/OpenAIProvider —
 * implements `getStatus()` only. In production every status entry's `name`
 * resolves in the registry (statuses come FROM `listStatuses()`), so this is
 * the faithful harness; `reachable` above models the direct-assignment case
 * where an unresolvable name must degrade to counting the vendor.
 */
const reachableWithCapabilities = (
  el: FoundationAiAssistant,
  vendors: AIProviderType[],
  chatless: AIProviderType[] = [],
): void => {
  reachable(el, ...vendors);
  const providers = new Map(
    vendors.map((v) => [
      v,
      chatless.includes(v)
        ? { getStatus: async () => ({ provider: v, model: `${v}-model` }) }
        : {
            getStatus: async () => ({ provider: v, model: `${v}-model` }),
            chat: async () => ({ role: 'assistant', content: 'ok' }),
          },
    ]),
  );
  // `providerRegistry` is a DI-decorated prototype getter; a plain assignment
  // is swallowed by it, so shadow it with an instance own-property.
  Object.defineProperty(el, 'providerRegistry', {
    configurable: true,
    value: {
      get: (name: string) => providers.get(name as AIProviderType),
      default: () => providers.get(vendors[0])!,
      defaultName: () => vendors[0],
      names: () => [...vendors],
      getStatus: async () => null,
      listStatuses: async () => [],
    },
  });
};

/**
 * The store half of "Clear" / "New chat". `clearSession()` itself also tears the
 * driver down and awaits the persister, neither of which exists on an
 * unconnected element — `resetSession` is the one action it dispatches, and the
 * only part of it that touches the blocked state.
 */
const resetSession = (el: FoundationAiAssistant): void =>
  (
    el as unknown as { _sessionRef: SessionStoreReturn }
  )._sessionRef.actions.aiAssistant.resetSession();

Suite.after(() => {
  clearAllSessionStores();
});

Suite('defaults to unblocked with no reason', () => {
  const el = element();
  assert.is(el.blocked, false);
  assert.is(el.blockedReason, null);
  assert.is(sendBlocked(el), false);
});

Suite('the blocked setter round-trips through the session store', () => {
  const el = element();
  el.blocked = true;
  assert.is(el.blocked, true, 'the getter reads back what the setter wrote');
  el.blocked = false;
  assert.is(el.blocked, false);
});

Suite('setBlocked writes the flag and the reason together', () => {
  const el = element();
  el.setBlocked(true, 'Budget exhausted — contact billing');
  assert.is(el.blocked, true);
  assert.is(el.blockedReason, 'Budget exhausted — contact billing');
  assert.is(el.effectiveBlockedReason, 'Budget exhausted — contact billing');

  // Unblocking clears the reason so no stale copy can be rendered later.
  el.setBlocked(false);
  assert.is(el.blockedReason, null);
});

Suite('effectiveBlockedReason falls back to the default copy', () => {
  const el = element();
  el.blocked = true;
  assert.is(el.blockedReason, null, 'no host-supplied reason');
  // One identity assertion against the shared const rather than substring probes
  // — the banner and the driver's transcript bubble now read from a single
  // source, and this is what stops the two drifting apart again.
  assert.is(el.effectiveBlockedReason, DEFAULT_BUDGET_EXHAUSTED_MESSAGE);
});

Suite('blocked folds into sendBlocked — the composer-disable predicate', () => {
  // `sendBlocked` is what gates send()/submitMessage(); the textarea and send
  // button bind the same `|| x.blocked` term.
  const el = element();
  assert.is(sendBlocked(el), false, 'idle and unblocked → sends allowed');
  el.blocked = true;
  assert.is(sendBlocked(el), true, 'blocked → sends refused even while idle');
  el.blocked = false;
  assert.is(sendBlocked(el), false, 'unblocking re-enables the composer');
});

Suite('effectivePlaceholder swaps to the blocked copy, outranking a pinned agent', async () => {
  const el = element();
  el.pinnedAgentName = 'Booking';
  assert.is(el.effectivePlaceholder, 'Message Booking...', 'unblocked → the pinned-agent prompt');

  el.blocked = true;
  assert.is(
    el.effectivePlaceholder,
    'AI usage limit reached',
    'blocked outranks the pin — the composer is disabled, so naming the agent would mislead',
  );
});

Suite('submitMessage refuses while blocked, with the blocked reason', async () => {
  const el = element();
  el.setBlocked(true, 'Budget exhausted — contact billing');

  const result = await el.submitMessage({ text: 'hello' });

  assert.is(result.ok, false, 'a programmatic send is refused');
  assert.equal(
    result.errors,
    ['Budget exhausted — contact billing'],
    'the refusal names the block, not a transient "busy" a caller would retry on',
  );
});

Suite('submitMessage still refuses with the default copy when no reason was set', async () => {
  const el = element();
  el.blocked = true;

  const result = await el.submitMessage({ text: 'hello' });

  assert.is(result.ok, false);
  assert.is(result.errors![0], DEFAULT_BUDGET_EXHAUSTED_MESSAGE);
});

Suite('submitMessage works again once the host unblocks', async () => {
  // Proves the block is the only thing refusing — and that it is releasable.
  const el = element();
  el.blocked = true;
  assert.is((await el.submitMessage({ text: 'hi' })).ok, false);

  el.blocked = false;
  // No driver is wired on an unconnected element, so the send is a no-op that
  // still reports ok — what matters is that the refusal is gone.
  assert.is(sendBlocked(el), false, 'the gate is open again');
});

// ── The blockedReason setter (API symmetry) ─────────────────────────────────

Suite('the blockedReason setter round-trips and does not disturb blocked', () => {
  // Every other store-backed public accessor on this class is a symmetric pair;
  // `blockedReason` being read-only made `el.blockedReason = msg` a compile error
  // for typed consumers and a strict-mode TypeError for JS ones.
  const el = element();
  el.setBlocked(true, 'host copy');

  el.blockedReason = 'new copy';

  assert.is(el.blocked, true, 'writing the reason must not clear the block');
  assert.is(el.effectiveBlockedReason, 'new copy');
});

Suite('assigning blockedReason = null falls back to the default copy', () => {
  const el = element();
  el.setBlocked(true, 'host copy');
  el.blockedReason = null;
  assert.is(el.blocked, true);
  assert.is(el.effectiveBlockedReason, DEFAULT_BUDGET_EXHAUSTED_MESSAGE);
});

Suite('el.blocked = true after a host reason keeps the reason', () => {
  // The driver-latch race at element level: a second block with no reason must
  // not blank the host's explanation.
  const el = element();
  el.setBlocked(true, 'AI usage limit reached ($25.40 of $25). Contact billing.');

  el.blocked = true;

  assert.is(el.effectiveBlockedReason, 'AI usage limit reached ($25.40 of $25). Contact billing.');
});

// ── The driver latch (GENC-1464) ───────────────────────────────────────────────

Suite('latchBlockedFrom latches only on budget-exhausted', () => {
  const budget = element();
  latch(budget, 'budget-exhausted');
  assert.is(budget.blocked, true, 'a budget wall latches');

  const other = element();
  latch(other, 'exception');
  assert.is(other.blocked, false, 'an ordinary failure does not lock the composer');

  const clean = element();
  latch(clean, undefined);
  assert.is(clean.blocked, false, 'a clean turn does not lock the composer');
});

Suite('a second latch never clobbers a host-set blockedReason', () => {
  // The bus fires from the driver's `finally`, BEFORE sendMessage() resolves, so
  // a bus-subscribed host writes its copy first and the return-value latch lands
  // second. It must be a no-op, not a rewrite.
  const el = element();
  el.setBlocked(true, 'custom copy');

  latch(el, 'budget-exhausted');

  assert.is(el.blockedReason, 'custom copy');
  assert.is(el.effectiveBlockedReason, 'custom copy');
});

Suite('the driver latch formats the proxy figures into the banner', () => {
  const el = element();

  latch(el, 'budget-exhausted', { budgetUsd: 25, spentUsd: 25.4, vendorLabel: 'Anthropic' });

  assert.is(el.blocked, true);
  assert.ok(el.effectiveBlockedReason.includes('$25.40'), 'the spend is shown');
  assert.ok(el.effectiveBlockedReason.includes('$25'), 'so is the cap');
});

Suite('formatBlockedReason returns undefined when the proxy sent no figures', () => {
  // `undefined` is the meaningful return: with the slice's "omitted reason keeps
  // the existing explanation" rule it is what lets a figureless latch leave a
  // host reason intact.
  assert.is(formatBlockedReason(undefined), undefined);
  assert.is(formatBlockedReason({ vendorLabel: 'Anthropic' }), undefined);
  assert.ok(formatBlockedReason({ spentUsd: 5.02, vendorLabel: 'Gemini' })?.includes('$5.02'));
});

Suite('a figureless latch leaves a host reason intact', () => {
  const el = element();
  el.setBlocked(true, 'host copy');

  latch(el, 'budget-exhausted');

  assert.is(el.effectiveBlockedReason, 'host copy');
});

// ── Lifetime + composer inertness ───────────────────────────────────────────

Suite('the latch is per-stateKey — a different session store starts unblocked', () => {
  // Pins the documented Lifetime: `blocked` lives on the session store, so it is
  // per-stateKey and a session switch (which tears the outgoing store down)
  // yields an unblocked one. The host pre-flight is the durable source of truth.
  const first = element();
  const second = element();

  first.blocked = true;

  assert.is(first.blocked, true);
  assert.is(second.blocked, false, 'a different session store is unaffected');
});

Suite('"Clear" keeps a PARTIAL wall partial — it does not escalate to a full lock', () => {
  // The reported failure: `resetSession` preserved `blockedVendors` but wiped
  // `providerStatuses`, so `reachableVendors` went empty, the "unknown
  // reachability fails safe" rule fired, and one walled vendor out of two turned
  // a live composer into a locked one. Nothing reloads the statuses on Clear —
  // `loadProviderStatuses()` runs only on connect/session-switch and off the
  // observable-registry subscription — so a host with a static registry stayed
  // locked until a remount. The statuses describe the registry, not the
  // conversation, so they are now preserved across the reset.
  const el = element();
  reachable(el, 'anthropic', 'gemini');
  el.setVendorBlocked('anthropic', true, 'Anthropic budget spent — see billing');
  assert.is(el.blocked, false, 'precondition: Gemini has headroom, so the composer is live');

  resetSession(el);

  assert.equal(el.reachableVendors, ['anthropic', 'gemini'], 'the registry snapshot survives');
  assert.equal(el.blockedVendors, ['anthropic'], 'and so does the wall — Clear refills no budget');
  assert.is(el.blocked, false, 'so the composer is still usable after Clear');
  assert.is(sendBlocked(el), false);
  assert.is(el.bannerVisible, true, 'and the wall is still announced');
  assert.ok(el.effectiveBlockedReason.startsWith('Anthropic budget spent — see billing'));
});

Suite('"Clear" still leaves a FULL wall locked', () => {
  // The other direction of the same change: preserving the statuses must not
  // make Clear a way out of a wall that really does cover every vendor.
  const el = element();
  reachable(el, 'anthropic', 'gemini');
  el.setVendorBlocked('anthropic', true);
  el.setVendorBlocked('gemini', true);

  resetSession(el);

  assert.is(el.blocked, true);
  assert.is(sendBlocked(el), true);
  assert.is(el.effectivePlaceholder, 'AI usage limit reached');
});

Suite('"Clear" still wipes the conversation it is there to wipe', () => {
  // Guards the preserve-list against over-reach: `providerStatuses` joining it
  // must not smuggle in anything that IS conversation state.
  const el = element();
  reachable(el, 'anthropic');
  el.messages = [{ id: '1', role: 'user', content: 'hello' } as ChatMessage];
  assert.is(el.messages.length, 1);

  resetSession(el);

  assert.is(el.messages.length, 0, 'the transcript is gone');
  assert.equal(el.reachableVendors, ['anthropic'], 'the registry snapshot is not');
});

Suite('handleSuggestionClick is inert while blocked', () => {
  // The chips are hidden by the template while blocked, but a host (or a stale
  // render) can still reach the handler. Without the guard the suggestion text is
  // deposited into a disabled textarea the user cannot clear, because `send()`
  // refuses only AFTER `inputValue` has been written.
  const el = element();
  el.blocked = true;

  el.handleSuggestionClick('draft me a report');

  assert.is(el.inputValue, '', 'nothing is written into the disabled composer');
});

Suite('fetchSuggestions does not call the driver while blocked', () => {
  // Otherwise every blocked turn, pop-in and pin change fires one guaranteed-402
  // request, burning a `suggestions.failed` meta event and parking a raw
  // transport-error string in `suggestionsState.message` for hosts to find.
  const el = element();
  let calls = 0;
  (el as unknown as { driver: unknown }).driver = {
    isBusy: () => false,
    getSuggestions: async () => {
      calls += 1;
      return [];
    },
  };
  el.chatConfig = { suggestions: { behavior: 'always', prompt: 'p', count: 3 } } as never;
  el.blocked = true;

  (el as unknown as { fetchSuggestions: () => Promise<void> }).fetchSuggestions();

  assert.is(calls, 0, 'no request is issued against the wall');
  assert.is(el.suggestionsState.status, 'idle', 'and no loading state is entered');
});

// ── Per-vendor budgets (GENC-1464) ─────────────────────────────────────────────
//
// The central question this design answers — "which vendor would the NEXT turn
// use?" — has no correct answer and deliberately gets no API: the provider is
// resolved per turn AND per agent, an orchestrated turn picks its agent with an
// LLM classify() call, and sub-agents resolve their own. So `blocked` is instead
// derived: "every vendor the registry can currently REACH is walled". That is
// synchronous, free (the statuses are already loaded), and makes vendor-switch
// recovery a derivation rather than a mutation.

Suite('a walled vendor with another reachable one does NOT block the composer', () => {
  // D3 — partial exhaustion blocks only the exhausted vendor.
  const el = element();
  reachable(el, 'anthropic', 'gemini');

  el.setVendorBlocked('anthropic', true);

  assert.equal(el.blockedVendors, ['anthropic']);
  assert.is(el.blocked, false, 'Gemini still has headroom, so sends stay allowed');
  assert.is(sendBlocked(el), false);
  assert.is(el.bannerVisible, true, 'but the banner still announces it');
});

Suite('walling every reachable vendor blocks the composer', () => {
  const el = element();
  reachable(el, 'anthropic', 'gemini');
  el.setVendorBlocked('anthropic', true);

  el.setVendorBlocked('gemini', true);

  assert.is(el.blocked, true);
  assert.is(sendBlocked(el), true);
  assert.is(el.effectivePlaceholder, 'AI usage limit reached');
});

Suite('a vendor switch releases the block by derivation, with the latch left intact', () => {
  // The known-hard part: there is no vendor-switch EVENT to hang a clear on. A
  // host that swaps its registry to Gemini fires the observable, the statuses
  // reload, Anthropic drops out of `reachableVendors`, and `blocked` goes false
  // — nothing mutated the latch, so switching back re-derives the block.
  const el = element();
  reachable(el, 'anthropic');
  el.setVendorBlocked('anthropic', true);
  assert.is(el.blocked, true, 'walled and it is the only reachable vendor');

  reachable(el, 'gemini');
  assert.is(el.blocked, false, 'the switch is recovery, with no host code at all');
  assert.equal(el.blockedVendors, ['anthropic'], 'and the latch was never cleared');

  reachable(el, 'anthropic');
  assert.is(el.blocked, true, 'switching back re-derives the block');
});

Suite('unknown reachability fails safe — any wall blocks everything', () => {
  // Providers need not implement `getStatus()`. A host whose providers report
  // nothing gets no per-vendor benefit, but it must not get a LESS locked UI
  // than before, so an empty reachable set means "any wall blocks everything".
  const el = element();
  assert.equal(el.reachableVendors, [], 'no statuses loaded');

  el.setVendorBlocked('anthropic', true);

  assert.is(el.blocked, true);
});

Suite('a host that never adopts the per-vendor API reads `blocked` exactly as before', () => {
  // THE back-compat pin: with `blockedVendors` empty the derivation short-circuits
  // to the stored boolean, whatever the provider statuses say.
  const el = element();
  reachable(el, 'anthropic', 'gemini');

  assert.is(el.blocked, false);
  el.blocked = true;
  assert.is(el.blocked, true, 'a vendor-agnostic block still blocks everything');
  assert.equal(el.blockedVendors, [], 'and does not invent a per-vendor latch');
  el.blocked = false;
  assert.is(el.blocked, false);
});

Suite('setBlocked(false) clears the per-vendor latches too', () => {
  // What keeps migration-GENC-1464 Option B working verbatim: a host asserting "the
  // wall is gone" after its own pre-flight must not be overruled by a driver
  // latch it cannot see.
  const el = element();
  reachable(el, 'anthropic');
  el.setVendorBlocked('anthropic', true);

  el.setBlocked(false);

  assert.is(el.blocked, false);
  assert.equal(el.blockedVendors, [], 'the latch is gone, not merely outvoted');
});

Suite('isVendorBlocked answers per vendor, independently of the composer gate', () => {
  const el = element();
  reachable(el, 'anthropic', 'gemini');
  el.setVendorBlocked('gemini', true, 'Gemini budget spent — see billing');

  assert.is(el.isVendorBlocked('gemini'), true);
  assert.is(el.isVendorBlocked('anthropic'), false);
  assert.is(el.blocked, false, 'and the composer is still live');
});

Suite('writing blockedReason does not clear the per-vendor latches', () => {
  // `blocked` is derived now, so routing this setter through
  // `setBlocked({ blocked: this.blocked })` would pass `false` for a partially
  // walled session and wipe the latches as a side effect of setting a string.
  const el = element();
  reachable(el, 'anthropic', 'gemini');
  el.setVendorBlocked('anthropic', true);

  el.blockedReason = 'custom copy';

  assert.equal(el.blockedVendors, ['anthropic']);
  assert.is(el.effectiveBlockedReason, 'custom copy');
});

// ── The per-vendor latch ────────────────────────────────────────────────────

Suite('the latch attributes a wall to the vendor named by the transport label', () => {
  const el = element();
  reachable(el, 'anthropic', 'gemini');

  latch(el, 'budget-exhausted', { budgetUsd: 500, spentUsd: 500, vendorLabel: 'Anthropic' });

  assert.equal(el.blockedVendors, ['anthropic']);
  assert.is(el.blocked, false, 'Gemini is untouched — D3');
});

Suite('the per-vendor guard lets a SECOND vendor wall land', () => {
  // The old global `if (this.blocked) return` would have swallowed this, so a
  // session could never record that both vendors were gone — and under a mixed
  // registry an Anthropic-only wall would have locked a composer Gemini could
  // still serve.
  const el = element();
  reachable(el, 'anthropic', 'gemini');
  latch(el, 'budget-exhausted', { budgetUsd: 500, spentUsd: 500, vendorLabel: 'Anthropic' });

  latch(el, 'budget-exhausted', { budgetUsd: 500, spentUsd: 501, vendorLabel: 'Gemini' });

  assert.equal(el.blockedVendors, ['anthropic', 'gemini'], 'both walls are recorded, in order');
  assert.is(el.blocked, true);
});

Suite('a re-latch of an already-walled vendor never clobbers host copy', () => {
  const el = element();
  reachable(el, 'anthropic', 'gemini');
  el.setVendorBlocked('anthropic', true, 'Anthropic budget spent — see billing');

  latch(el, 'budget-exhausted', { budgetUsd: 500, spentUsd: 500, vendorLabel: 'Anthropic' });

  assert.ok(
    el.effectiveBlockedReason.startsWith('Anthropic budget spent — see billing'),
    'the host per-vendor copy survives the driver latch',
  );
});

Suite('the label wins over a stale driver-supplied vendor', () => {
  // On the classification seam the driver's last-resolved provider is the
  // PREVIOUS turn's vendor; the label comes from the transport that actually
  // said no. Seed the disagreement to prove which one is believed.
  const el = element();
  reachable(el, 'anthropic', 'gemini');

  latch(
    el,
    'budget-exhausted',
    { budgetUsd: 500, spentUsd: 500, vendorLabel: 'Gemini', vendor: 'anthropic' },
    'anthropic',
  );

  assert.equal(el.blockedVendors, ['gemini']);
});

Suite('a figure-less wall attributes by label, not by the stale hint', () => {
  // This replaces a test that pinned the opposite path as correct. A figure-less
  // 402 used to reach the latch with NO budget object at all, so the only vendor
  // on offer was the bus hint — the driver's last-resolved provider, i.e. the
  // PREVIOUS turn's vendor. `chat-driver.test.ts` now proves the driver keeps the
  // label on a figure-less wall; this proves the latch prefers it. Seed the
  // disagreement, or a constant would satisfy both.
  const el = element();
  reachable(el, 'anthropic', 'gemini');

  latch(el, 'budget-exhausted', { vendorLabel: 'Anthropic', vendor: 'anthropic' }, 'gemini');

  assert.equal(el.blockedVendors, ['anthropic'], 'the transport that said no');
});

Suite('the bus vendor hint still attributes a detail that carries no budget', () => {
  // `tool-loop-end` is a public topic and its top-level `vendor` is documented
  // and additive, so a host may publish the reason and the vendor with no budget
  // payload at all. That path stays supported — it is just no longer how the
  // driver's own figure-less walls travel.
  const el = element();
  reachable(el, 'anthropic', 'gemini');

  latch(el, 'budget-exhausted', undefined, 'gemini');

  assert.equal(el.blockedVendors, ['gemini']);
});

Suite('a wall naming no known vendor falls back to the vendor-agnostic block', () => {
  // Fail-safe: blocking the wrong vendor is worse than blocking all of them.
  const el = element();
  reachable(el, 'anthropic', 'gemini');

  latch(el, 'budget-exhausted', { budgetUsd: 500, spentUsd: 500, vendorLabel: 'Acme AI' });

  assert.equal(el.blockedVendors, [], 'nothing is attributed');
  assert.is(el.blocked, true, 'and the whole assistant locks, exactly as it used to');
});

// ── Banner copy ─────────────────────────────────────────────────────────────

Suite('partial exhaustion names the walled vendor and the way out', () => {
  const el = element();
  reachable(el, 'anthropic', 'gemini');

  latch(el, 'budget-exhausted', { budgetUsd: 500, spentUsd: 500, vendorLabel: 'Anthropic' });

  assert.is(
    el.effectiveBlockedReason,
    "Anthropic's AI usage limit is reached ($500.00 of $500.00). Switch to Gemini to keep going.",
  );
});

Suite('it says "switch", it does not switch — no auto-switching', () => {
  // D3: auto-switching would silently downgrade model quality. The element only
  // ever advises; nothing here touches the registry.
  const el = element();
  reachable(el, 'anthropic', 'gemini');
  el.setVendorBlocked('anthropic', true);

  assert.equal(el.reachableVendors, ['anthropic', 'gemini'], 'the registry is untouched');
  assert.ok(el.effectiveBlockedReason.includes('Switch to Gemini'));
});

Suite('full exhaustion of a single vendor asks for a raise, not a switch', () => {
  const el = element();
  reachable(el, 'anthropic');

  latch(el, 'budget-exhausted', { budgetUsd: 25, spentUsd: 25.4, vendorLabel: 'Anthropic' });

  assert.is(
    el.effectiveBlockedReason,
    "Anthropic's AI usage limit is reached ($25.40 of $25.00). Contact your administrator to raise it.",
  );
});

Suite('several walled vendors are named together, with plural copy', () => {
  const el = element();
  reachable(el, 'anthropic', 'gemini');
  el.setVendorBlocked('anthropic', true);
  el.setVendorBlocked('gemini', true);

  assert.is(
    el.effectiveBlockedReason,
    'AI usage limits are reached for Anthropic and Gemini. Contact your administrator to raise them.',
  );
});

Suite('a walled vendor with figureless copy still names itself', () => {
  // The shape here is the one `budgetDetailOf` actually emits for a figure-less
  // 402 — label plus resolved vendor, no numbers — not a hand-made shape the
  // driver never produces. `chat-driver.test.ts` pins the emitting end.
  const el = element();
  reachable(el, 'anthropic');

  latch(el, 'budget-exhausted', { vendorLabel: 'Anthropic', vendor: 'anthropic' });

  assert.is(
    el.effectiveBlockedReason,
    "Anthropic's AI usage limit is reached. Contact your administrator to raise it.",
  );
});

Suite('host per-vendor copy survives a SECOND vendor walling', () => {
  // The reported failure: the host reasons were read only on the `walled.length
  // === 1` branch, so the moment a second vendor walled every carefully written
  // host sentence was replaced by generic copy — contradicting both the
  // `setVendorBlocked` JSDoc and the migration doc, and doing it at the exact
  // moment the situation got worse.
  const el = element();
  reachable(el, 'anthropic', 'gemini');

  el.setVendorBlocked('anthropic', true, 'Anthropic spend is capped for this desk.');
  el.setVendorBlocked('gemini', true, 'Gemini spend is capped for this desk.');

  assert.is(
    el.effectiveBlockedReason,
    'Anthropic spend is capped for this desk. Gemini spend is capped for this desk. ' +
      'Contact your administrator to raise them.',
  );
});

Suite('a vendor with no copy still gets a sentence beside one that has copy', () => {
  // Mixed is the realistic case — e.g. the proxy's `otherVendorAvailable: false`
  // sweep wals the second vendor with no figures of its own. The composed set must
  // stay complete rather than silently naming a subset.
  const el = element();
  reachable(el, 'anthropic', 'gemini');

  el.setVendorBlocked('anthropic', true, 'Anthropic spend is capped for this desk.');
  el.setVendorBlocked('gemini', true);

  assert.is(
    el.effectiveBlockedReason,
    "Anthropic spend is capped for this desk. Gemini's AI usage limit is reached. " +
      'Contact your administrator to raise them.',
  );
});

Suite('three walled vendors with copy read as three statements', () => {
  const el = element();
  reachable(el, 'anthropic', 'gemini', 'openai');

  el.setVendorBlocked('anthropic', true, 'Anthropic: capped.');
  el.setVendorBlocked('gemini', true, 'Gemini: capped.');
  el.setVendorBlocked('openai', true, 'OpenAI: capped.');

  assert.is(
    el.effectiveBlockedReason,
    'Anthropic: capped. Gemini: capped. OpenAI: capped. Contact your administrator to raise them.',
  );
});

Suite('only the REACHABLE walled vendors contribute a statement', () => {
  // The composition is layered on `relevantBlockedVendors`, not on the raw list,
  // so host copy for a vendor the registry has moved away from stays unshown.
  const el = element();
  reachable(el, 'anthropic');
  el.setVendorBlocked('anthropic', true, 'Anthropic: capped.');
  reachable(el, 'gemini', 'openai');
  el.setVendorBlocked('gemini', true, 'Gemini: capped.');
  el.setVendorBlocked('openai', true, 'OpenAI: capped.');

  assert.is(
    el.effectiveBlockedReason,
    'Gemini: capped. OpenAI: capped. Contact your administrator to raise them.',
    'Anthropic is walled and has copy, but the user cannot reach it',
  );
});

Suite('a host blockedReason outranks every composed sentence', () => {
  const el = element();
  reachable(el, 'anthropic', 'gemini');
  el.setVendorBlocked('anthropic', true);
  el.setVendorBlocked('gemini', true);

  el.blockedReason = 'Contact ops@acme.com';

  assert.is(el.effectiveBlockedReason, 'Contact ops@acme.com');
});

Suite('formatBlockedReason composes a vendor statement without an action clause', () => {
  // The action is composed at render time, because whether "switch vendor" or
  // "contact your administrator" is honest depends on the CURRENT reachable set.
  assert.is(
    formatBlockedReason({ budgetUsd: 25, spentUsd: 25.4, vendorLabel: 'Gemini' }, 'gemini'),
    "Gemini's AI usage limit is reached ($25.40 of $25.00).",
  );
  assert.is(formatBlockedReason(undefined, 'gemini'), undefined, 'no figures, no statement');
});

// ── The server's `otherVendorAvailable` verdict ─────────────────────────────
//
// The client can only infer "another vendor is free" from REGISTRY MEMBERSHIP,
// which knows which vendors exist and nothing about their remaining spend. The
// proxy meters the pots and says so on the 402. Where the two disagree, the
// server wins — otherwise the user is told to switch to a vendor that is already
// spent, and burns a second doomed turn learning what arrived in the first
// response.

Suite('`otherVendorAvailable: false` locks the composer on the FIRST wall', () => {
  const el = element();
  reachable(el, 'anthropic', 'gemini');

  latch(el, 'budget-exhausted', {
    budgetUsd: 500,
    spentUsd: 500,
    vendorLabel: 'Anthropic',
    otherVendorAvailable: false,
  });

  assert.equal(
    el.blockedVendors,
    ['anthropic', 'gemini'],
    'every metered pot is walled — the verdict is about the proxy, not this registry',
  );
  assert.is(el.blocked, true, 'so `blocked` derives true immediately');
  assert.is(sendBlocked(el), true, 'and no second doomed turn is possible');
});

Suite('the early-window banner names only the refuser, never a swept vendor', () => {
  // The window: a 402 lands while `loadProviderStatuses()` is still in flight,
  // and its `otherVendorAvailable: false` sweep walls every budgeted vendor.
  // The unknown-reachability fallback used to surface the swept ones, so the
  // banner read "Anthropic's AI usage limit is reached ($25.40 of $25.00).
  // Gemini's AI usage limit is reached. Contact your administrator to raise
  // them." — naming a vendor this host may not even ship, on the plural branch,
  // off nothing but the proxy's headroom verdict. A refusal is nameable
  // evidence; a sweep entry is not, until reachability can vouch for it.
  const el = element();
  assert.equal(el.reachableVendors, [], 'precondition: statuses have not resolved yet');

  latch(el, 'budget-exhausted', {
    budgetUsd: 25,
    spentUsd: 25.4,
    vendorLabel: 'Anthropic',
    otherVendorAvailable: false,
  });

  assert.is(el.blocked, true, 'locking still counts every wall — that fail-safe is untouched');
  assert.equal(el.blockedVendors, ['anthropic', 'gemini'], 'and the verdict is still recorded');
  assert.is(
    el.effectiveBlockedReason,
    "Anthropic's AI usage limit is reached ($25.40 of $25.00). Contact your administrator to raise it.",
    'singular, figures, refuser only — nothing the user cannot verify',
  );

  // Statuses land with both vendors: reachability now vouches for the swept
  // wall, so the banner grows to the honest multi-vendor copy.
  reachable(el, 'anthropic', 'gemini');
  assert.ok(
    el.effectiveBlockedReason.includes('Gemini'),
    'once reachable, the swept vendor is named after all',
  );
});

Suite('a swept vendor that then REFUSES is named even before the statuses land', () => {
  // Provenance upgrades on first-hand evidence: the sweep walled Gemini off the
  // proxy's verdict, then the user (say, via a pre-set vendor preference)
  // actually hit Gemini and got its own 402. That refusal is nameable — the
  // latch clears the swept mark when it writes the wall without `swept`.
  const el = element();
  latch(el, 'budget-exhausted', {
    budgetUsd: 25,
    spentUsd: 25.4,
    vendorLabel: 'Anthropic',
    otherVendorAvailable: false,
  });
  assert.not.ok(el.effectiveBlockedReason.includes('Gemini'), 'swept: not named yet');

  latch(el, 'budget-exhausted', {
    budgetUsd: 500,
    spentUsd: 512,
    vendorLabel: 'Gemini',
    otherVendorAvailable: false,
  });

  assert.ok(el.effectiveBlockedReason.includes('Gemini'), 'refused: named on its own evidence');
  assert.ok(el.effectiveBlockedReason.includes('$512.00'), 'with its own figures');
});

Suite('a host-set wall is never treated as swept', () => {
  // `setVendorBlocked` from the host is an explicit statement (e.g. its own
  // pre-flight consuming GET /api/budget) — first-hand, nameable, statuses or
  // not. Only the element's own sweep passes the provenance flag.
  const el = element();
  assert.equal(el.reachableVendors, []);

  el.setVendorBlocked('gemini', true, 'Gemini: capped by pre-flight.');

  assert.ok(el.effectiveBlockedReason.includes('Gemini: capped by pre-flight.'));
});

Suite('the verdict survives a wall that arrives BEFORE the statuses load', () => {
  // The ordering bug: `activateSession` kicks off `loadProviderStatuses()` without
  // awaiting it, so a 402 can land while `reachableVendors` is still empty. The
  // sweep used to iterate that empty set, so `otherVendorAvailable: false` — the
  // one fact on the 402 the client cannot re-derive — was stored nowhere at all,
  // and nothing re-ran it when the statuses arrived. The wall then effectively
  // UN-latched: `blocked` was true only while reachability was unknown, so the
  // composer came back to life the instant the statuses landed.
  const el = element();
  assert.equal(el.reachableVendors, [], 'precondition: statuses have not resolved yet');

  latch(el, 'budget-exhausted', {
    budgetUsd: 500,
    spentUsd: 500,
    vendorLabel: 'Anthropic',
    otherVendorAvailable: false,
  });
  assert.is(el.blocked, true, 'the fail-safe locks it while reachability is unknown');

  // …and now the statuses resolve.
  reachable(el, 'anthropic', 'gemini');

  assert.equal(el.blockedVendors, ['anthropic', 'gemini'], 'the verdict was recorded');
  assert.is(el.blocked, true, 'so it stays locked, rather than un-latching');
  assert.is(sendBlocked(el), true);
  assert.not.ok(el.effectiveBlockedReason.includes('Switch to'), 'and still advises no switch');
});

Suite('a wall before the statuses load still spares Chrome once they arrive', () => {
  // The sweep now walls vendors it cannot see, so the `isBudgetedVendor` boundary
  // is doing more work than before: it is the only thing keeping an on-device
  // provider out of a verdict that is only ever about the proxy's own pots.
  const el = element();

  latch(el, 'budget-exhausted', {
    budgetUsd: 500,
    spentUsd: 500,
    vendorLabel: 'Anthropic',
    otherVendorAvailable: false,
  });

  reachable(el, 'anthropic', 'chrome');

  assert.is(el.isVendorBlocked('chrome'), false, 'Chrome has no pot to exhaust');
  assert.is(el.blocked, false, 'so the composer comes back for the vendor that is still free');
  assert.ok(el.effectiveBlockedReason.includes('Switch to Chrome'));
});

Suite('`otherVendorAvailable: false` vetoes the "switch to X" advice', () => {
  const el = element();
  reachable(el, 'anthropic', 'gemini');

  latch(el, 'budget-exhausted', {
    budgetUsd: 500,
    spentUsd: 500,
    vendorLabel: 'Anthropic',
    otherVendorAvailable: false,
  });

  // The refusing vendor carries the 402's figures, so the multi-vendor branch
  // composes them in rather than dropping to the generic list — the swept vendor
  // has no figures of its own (only the proxy's word), so it gets the boilerplate.
  assert.is(
    el.effectiveBlockedReason,
    "Anthropic's AI usage limit is reached ($500.00 of $500.00). " +
      "Gemini's AI usage limit is reached. Contact your administrator to raise them.",
  );
  assert.not.ok(el.effectiveBlockedReason.includes('Switch to'), 'nowhere to switch to');
});

Suite('`otherVendorAvailable: true` leaves the inference exactly as it was', () => {
  // The other direction. A verdict of `true` agrees with the client's guess, so
  // nothing about the historical behaviour may change — including the copy.
  const el = element();
  reachable(el, 'anthropic', 'gemini');

  latch(el, 'budget-exhausted', {
    budgetUsd: 500,
    spentUsd: 500,
    vendorLabel: 'Anthropic',
    otherVendorAvailable: true,
  });

  assert.equal(el.blockedVendors, ['anthropic'], 'only the refused vendor');
  assert.is(el.blocked, false);
  assert.is(
    el.effectiveBlockedReason,
    "Anthropic's AI usage limit is reached ($500.00 of $500.00). Switch to Gemini to keep going.",
  );
});

Suite('an ABSENT verdict is not a `false` — an older proxy walls nothing extra', () => {
  const el = element();
  reachable(el, 'anthropic', 'gemini');

  latch(el, 'budget-exhausted', { budgetUsd: 500, spentUsd: 500, vendorLabel: 'Anthropic' });

  assert.equal(el.blockedVendors, ['anthropic']);
  assert.ok(el.effectiveBlockedReason.includes('Switch to Gemini'));
});

Suite('the verdict still lands when it arrives on a LATER wall', () => {
  // Turn 1 walls Anthropic while Gemini still has headroom; turn 2 hits the same
  // wall and reports that Gemini has since gone too. The per-vendor idempotence
  // guard would have returned before reading that, dropping the only news the
  // second turn carried — so the sweep runs ahead of it.
  const el = element();
  reachable(el, 'anthropic', 'gemini');
  latch(el, 'budget-exhausted', {
    budgetUsd: 500,
    spentUsd: 500,
    vendorLabel: 'Anthropic',
    otherVendorAvailable: true,
  });
  assert.is(el.blocked, false, 'turn 1 leaves Gemini usable');

  latch(el, 'budget-exhausted', {
    budgetUsd: 500,
    spentUsd: 500,
    vendorLabel: 'Anthropic',
    otherVendorAvailable: false,
  });

  assert.equal(el.blockedVendors, ['anthropic', 'gemini']);
  assert.is(el.blocked, true);
});

Suite('a chat-capable Chrome provider is spared by the sweep AND counts as headroom', () => {
  // `otherVendorAvailable` is the proxy speaking about the pots IT meters.
  // A host's CUSTOM Chrome provider that implements `chat()` (the shipped one
  // does not — see the next test) is never metered, so walling it would lock a
  // provider that is still free to use. The gate is capability, not identity.
  const el = element();
  reachableWithCapabilities(el, ['anthropic', 'chrome']);

  latch(el, 'budget-exhausted', {
    budgetUsd: 500,
    spentUsd: 500,
    vendorLabel: 'Anthropic',
    otherVendorAvailable: false,
  });

  assert.is(el.isVendorBlocked('chrome'), false, 'Chrome is untouched');
  assert.is(el.blocked, false, 'and the composer stays live');
  assert.ok(el.effectiveBlockedReason.includes('Switch to Chrome'), 'which is honest advice');
});

Suite('a status-only Chrome provider is NOT headroom — the wall locks the composer', () => {
  // The shipped ChromeProvider implements `getStatus()` and no `chat()`. It
  // used to count as reachable anyway, so `otherVendorAvailable: false` failed
  // to lock the composer and the banner advised switching to a provider that
  // cannot serve a turn — every subsequent send burned another doomed 402.
  const el = element();
  reachableWithCapabilities(el, ['anthropic', 'chrome'], ['chrome']);

  latch(el, 'budget-exhausted', {
    budgetUsd: 500,
    spentUsd: 500,
    vendorLabel: 'Anthropic',
    otherVendorAvailable: false,
  });

  assert.equal(el.reachableVendors, ['anthropic'], 'a chat-less provider is not reachable');
  assert.is(el.blocked, true, 'so the wall locks the composer');
  assert.is(sendBlocked(el), true, 'and no doomed send goes out');
  assert.not.ok(
    el.effectiveBlockedReason.includes('Switch to'),
    'and nobody is advised to switch to a provider that cannot chat',
  );
});

Suite('the sweep spares a chat-capable OpenAI provider — the proxy never meters it', () => {
  // Cross-repo contract, not cosmetics. ai-service rejects `openai` up front with
  // 400 UNSUPPORTED_PROVIDER, prices no model under it, and computes
  // `otherVendorAvailable` over Anthropic and Gemini alone — so a 402 is never a
  // statement about OpenAI. While `BUDGETED_VENDORS` listed it, this exact
  // scenario walled a chat-capable OpenAI provider and derived `blocked` over
  // headroom the user still had. (The SHIPPED OpenAIProvider is status-only and
  // is excluded from reachability by the capability gate — the chat-less Chrome
  // test above pins that half; this one uses a custom chat-capable provider.)
  const el = element();
  reachableWithCapabilities(el, ['anthropic', 'openai']);

  latch(el, 'budget-exhausted', {
    budgetUsd: 500,
    spentUsd: 500,
    vendorLabel: 'Anthropic',
    otherVendorAvailable: false,
  });

  assert.is(el.isVendorBlocked('openai'), false, 'OpenAI is untouched');
  assert.is(el.blocked, false, 'and the composer stays live');
  assert.is(sendBlocked(el), false, 'so the next turn is allowed through');
  assert.ok(el.effectiveBlockedReason.includes('Switch to OpenAI'), 'which is honest advice');
});

Suite('directly-assigned statuses with no registry entry still count (degrade open)', () => {
  // A host (or this suite's own `reachable` helper) may assign
  // `providerStatuses` directly, with names the registry cannot resolve. An
  // unresolvable name must NOT silently erase a vendor the status layer vouched
  // for — the gate drops a vendor only on positive evidence of chat-lessness.
  const el = element();
  reachable(el, 'anthropic', 'gemini');

  assert.equal(el.reachableVendors, ['anthropic', 'gemini']);
});

// ── The banner names only vendors this user can reach ───────────────────────

Suite('the banner never names a vendor the registry can no longer reach', () => {
  // The reported failure: a host ships Anthropic-only, Anthropic walls, the host
  // swaps its registry to Gemini, Gemini walls. `walled` was unfiltered, so the
  // copy read "limits are reached for Anthropic and Gemini" to a user who never
  // had an Anthropic key — and the spurious second name flipped the copy onto the
  // plural branch, so the closing sentence was wrong too.
  const el = element();
  reachable(el, 'anthropic');
  el.setVendorBlocked('anthropic', true);

  reachable(el, 'gemini');
  el.setVendorBlocked('gemini', true);

  assert.equal(el.blockedVendors, ['anthropic', 'gemini'], 'both latches are still held');
  assert.is(
    el.effectiveBlockedReason,
    "Gemini's AI usage limit is reached. Contact your administrator to raise it.",
    'but only the reachable one is named, and the copy is singular',
  );
});

Suite('a wall the registry has moved away from stops being announced at all', () => {
  // The intermediate state of the same story. With the swap done and nothing
  // walled that the user can reach, the banner has no news — and must not fall
  // through to the generic "you've reached your AI usage limit" over a composer
  // that works fine.
  const el = element();
  reachable(el, 'anthropic');
  el.setVendorBlocked('anthropic', true);
  assert.is(el.bannerVisible, true);

  reachable(el, 'gemini');

  assert.is(el.blocked, false);
  assert.is(el.bannerVisible, false, 'nothing reachable is walled');
});

Suite('unknown reachability still names every wall — the fail-safe path', () => {
  // With no statuses loaded the filter has nothing to filter against, so it must
  // fall back to the unfiltered list: `blocked` already treats any wall as
  // blocking everything there, and the banner has to explain why.
  const el = element();
  assert.equal(el.reachableVendors, [], 'no statuses loaded');
  el.setVendorBlocked('anthropic', true);

  assert.is(el.blocked, true);
  assert.is(el.bannerVisible, true);
  assert.is(
    el.effectiveBlockedReason,
    "Anthropic's AI usage limit is reached. Contact your administrator to raise it.",
  );
});

Suite('three walled vendors read as a list, not a pair', () => {
  // Why `formatVendorList` stays a general joiner: `anthropic`, `gemini` and
  // `openai` all reach the model through the metered proxy and all raise walls,
  // so the two-vendor shortcut would be wrong today, not just later.
  const el = element();
  reachable(el, 'anthropic', 'gemini', 'openai');
  el.setVendorBlocked('anthropic', true);
  el.setVendorBlocked('gemini', true);
  el.setVendorBlocked('openai', true);

  assert.is(
    el.effectiveBlockedReason,
    'AI usage limits are reached for Anthropic, Gemini and OpenAI. Contact your administrator to raise them.',
  );
});

// ── `none` is not a vendor ──────────────────────────────────────────────────

Suite("setVendorBlocked rejects 'none' at the boundary", () => {
  // `'none'` is the no-provider sentinel. Latching it rendered "none's AI usage
  // limit is reached" and could never be released by derivation, because `'none'`
  // never appears in `reachableVendors` — so the latch was permanently
  // underivable as well as ugly. Both internal callers already filtered it; this
  // is the public @beta entry point that did not.
  const el = element();
  reachable(el, 'anthropic');

  el.setVendorBlocked('none', true, 'nonsense');

  assert.equal(el.blockedVendors, [], 'nothing was latched');
  assert.is(el.isVendorBlocked('none'), false);
  assert.is(el.blocked, false, 'and the composer is untouched');
  assert.is(el.bannerVisible, false);
  assert.is(el.effectiveBlockedReason, DEFAULT_BUDGET_EXHAUSTED_MESSAGE, 'no "none\'s" copy');
});

Suite("a wall attributed to 'none' still falls back to the vendor-agnostic block", () => {
  // The latch's own filter, unchanged — the boundary check is a second line of
  // defence, not a replacement for it.
  const el = element();
  reachable(el, 'anthropic');

  latch(el, 'budget-exhausted', undefined, 'none');

  assert.equal(el.blockedVendors, []);
  assert.is(el.blocked, true, 'fail-safe: block everything rather than name a sentinel');
});

// ── The banner's class attribute ────────────────────────────────────────────

Suite('the banner emits no empty class slots', () => {
  // `class="blocked-banner  "` — two interpolations, neither applicable — is
  // harmless to a browser and noise in every DOM snapshot and innerHTML
  // assertion a host writes against this element.
  const el = element();
  assert.is(blockedBannerClasses(el), 'blocked-banner', 'unblocked: no modifiers, no padding');

  reachable(el, 'anthropic', 'gemini');
  el.setVendorBlocked('anthropic', true);
  assert.is(blockedBannerClasses(el), 'blocked-banner is-visible is-partial', 'partial wall');

  el.setVendorBlocked('gemini', true);
  assert.is(blockedBannerClasses(el), 'blocked-banner is-visible', 'fully walled');
});

// ── The composer's accessible description ───────────────────────────────────

Suite('the composer points aria-describedby at the banner whenever it says anything', () => {
  // The PARTIAL state is the one this feature exists to create, and it was the
  // one state with no accessible explanation at all: `aria-disabled` and
  // `aria-label` bind on `blocked`, which is false there, and a live composer
  // keeps the host's own placeholder. So a screen-reader user reached the textarea
  // and heard "Type a message" with nothing to say the next turn might be refused.
  const el = element();
  assert.is(composerDescribedBy(el), null, 'unblocked: no description, attribute omitted');

  reachable(el, 'anthropic', 'gemini');
  el.setVendorBlocked('anthropic', true);
  assert.is(el.blocked, false, 'partial: the composer is still usable…');
  assert.is(composerDescribedBy(el), BLOCKED_BANNER_ID, '…and now describable');

  el.setVendorBlocked('gemini', true);
  assert.is(el.blocked, true);
  assert.is(composerDescribedBy(el), BLOCKED_BANNER_ID, 'fully blocked: still described');
});

Suite('the description follows the banner, not the block', () => {
  // Keyed on `bannerVisible`, so a wall the registry has moved away from drops
  // the description at the same moment it drops the banner — otherwise the
  // controls would point at an empty region.
  const el = element();
  reachable(el, 'anthropic');
  el.setVendorBlocked('anthropic', true);
  assert.is(composerDescribedBy(el), BLOCKED_BANNER_ID);

  reachable(el, 'gemini');

  assert.is(el.bannerVisible, false, 'nothing reachable is walled');
  assert.is(composerDescribedBy(el), null, 'so there is nothing to describe the composer with');
});

// ── Suggestions ─────────────────────────────────────────────────────────────

Suite('suggestions resolve against the registry DEFAULT, not the whole gate', () => {
  // The one question in this feature that CAN be answered exactly: both
  // suggestion paths call `providerRegistry.default()`.
  const el = element();
  reachable(el, 'anthropic', 'gemini'); // anthropic is the default

  el.setVendorBlocked('gemini', true);
  assert.is(el.suggestionsBlocked, false, 'the default vendor still has headroom');

  el.setVendorBlocked('gemini', false);
  el.setVendorBlocked('anthropic', true);
  assert.is(el.suggestionsBlocked, true, 'the default vendor is walled');
  assert.is(el.blocked, false, '…while the composer stays live — the two gates differ');
});

Suite('fetchSuggestions is skipped when the default vendor is walled', () => {
  const el = element();
  reachable(el, 'anthropic', 'gemini');
  let calls = 0;
  (el as unknown as { driver: unknown }).driver = {
    isBusy: () => false,
    getSuggestions: async () => {
      calls += 1;
      return [];
    },
  };
  el.chatConfig = { suggestions: { behavior: 'always', prompt: 'p', count: 3 } } as never;
  el.setVendorBlocked('anthropic', true);

  (el as unknown as { fetchSuggestions: () => Promise<void> }).fetchSuggestions();

  assert.is(calls, 0, 'no guaranteed-402 request against the default vendor');
});

Suite.run();
