import type { ChatMessage } from '@genesislcap/foundation-ai';
import { assert, createLogicSuite } from '@genesislcap/foundation-testing';
import type { VendorBudgetFigures } from '../state/ai-assistant-slice';
import type { SessionStoreReturn } from '../state/session-store';
import { clearAllSessionStores, getSessionStore } from '../state/session-store';
import { logger } from '../utils/logger';
import { budgetPercentOf, FoundationAiAssistant } from './main';

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

// GENC-1464 — the settings-modal per-vendor budget meter.
//
// Like `blocked-state.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 — it does not connect — so nothing subscribes here.
//
// The bindings under test in `settings-modal.template.ts` are:
//   ${when((x) => x.settingsBudgetUsageVisible, …)}          (the whole meter)
//   ${repeat((x) => x.settingsBudgetRows, …)}                (one row per vendor)
//   <…-progress value="${(row) => row.barValue}">            (the clamped bar)
// plus `settingsModelSectionVisible`, which decides whether the "AI Model
// Settings" section renders at all.

const Suite = createLogicSuite('FoundationAiAssistant budget meter');

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(
    `budget-meter-test-${storeSeq}`,
    false,
  );
  return el;
}

/** The raw stored figures, read the way the rows getter reads them. */
const storedBudgets = (el: FoundationAiAssistant) =>
  (el as unknown as { _sessionRef: SessionStoreReturn })._sessionRef.store.aiAssistant
    .vendorBudgets;

/** The store half of "Clear" / "New chat" — see blocked-state.test.ts. */
const resetSession = (el: FoundationAiAssistant): void =>
  (
    el as unknown as { _sessionRef: SessionStoreReturn }
  )._sessionRef.actions.aiAssistant.resetSession();

/** Count `logger.warn` calls during `run` — the boundary warns ONCE per element. */
const countWarns = (run: () => void): number => {
  const original = logger.warn;
  let calls = 0;
  logger.warn = ((...args: unknown[]) => {
    calls += 1;
    void args;
  }) as typeof logger.warn;
  try {
    run();
  } finally {
    logger.warn = original;
  }
  return calls;
};

/**
 * Count reads of `settingsBudgetRows` during `run`.
 *
 * Building the rows formats every vendor's money text — a `formatUsd` pair and
 * a `budgetPercentOf` each — so a gate that answers THROUGH the rows pays for
 * all of it. Counting reads of the getter is the direct probe of that; the spy
 * goes on the prototype (where the accessor lives) and is put back after.
 */
const countRowBuilds = (run: () => void): number => {
  const proto = FoundationAiAssistant.prototype;
  const original = Object.getOwnPropertyDescriptor(proto, 'settingsBudgetRows')!;
  let calls = 0;
  Object.defineProperty(proto, 'settingsBudgetRows', {
    ...original,
    get(this: FoundationAiAssistant) {
      calls += 1;
      return original.get!.call(this);
    },
  });
  try {
    run();
  } finally {
    Object.defineProperty(proto, 'settingsBudgetRows', original);
  }
  return calls;
};

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

// ── Feeding figures ─────────────────────────────────────────────────────────

Suite('feeding two vendors renders two rows with the real money text', () => {
  const el = element();

  assert.is(el.setVendorBudget('anthropic', { budgetUsd: 50, spentUsd: 45 }), true);
  assert.is(el.setVendorBudget('gemini', { budgetUsd: 200, spentUsd: 30.5 }), true);

  const rows = el.settingsBudgetRows;
  assert.is(rows.length, 2);
  assert.is(rows[0].vendor, 'anthropic');
  assert.is(rows[0].label, 'Anthropic', 'display name comes from VENDOR_LABELS');
  assert.is(rows[0].figures, '$45.00 / $50.00 (90%)');
  assert.is(rows[0].barValue, 90);
  assert.is(rows[1].vendor, 'gemini');
  assert.is(rows[1].figures, '$30.50 / $200.00 (15%)', 'two decimals always, percent rounded');
});

Suite('rows keep BUDGETED_VENDORS order however the host ordered its feed', () => {
  // The feed loops over a server response whose order is the server's business;
  // the meter must not reorder itself between turns because of it.
  const el = element();
  el.setVendorBudget('gemini', { budgetUsd: 10, spentUsd: 1 });
  el.setVendorBudget('anthropic', { budgetUsd: 10, spentUsd: 2 });

  assert.equal(
    el.settingsBudgetRows.map((r) => r.vendor),
    ['anthropic', 'gemini'],
  );
});

Suite('a vendor with no figures gets no row — that is how "unlimited" renders', () => {
  // Display rule: hosts simply do not feed a vendor the server marks
  // `unlimited: true` / `limitUsd: null`, and the meter shows only what it was
  // fed. No "∞" row, no empty bar.
  const el = element();
  el.setVendorBudget('anthropic', { budgetUsd: 50, spentUsd: 15 });

  const rows = el.settingsBudgetRows;
  assert.is(rows.length, 1);
  assert.is(rows[0].vendor, 'anthropic');
});

// ── Overshoot: the text tells the truth, the bar clamps ─────────────────────

Suite('spend past the cap shows the real figures but clamps the bar at 100', () => {
  // Real overshoot exists — the last allowed call can cross the cap (the demo
  // "Exhaust" lands at $62.34 of $50 on purpose). A bar past its end renders
  // nonsense, so only the bar clamps.
  const el = element();
  el.setVendorBudget('anthropic', { budgetUsd: 50, spentUsd: 62.34 });

  const [row] = el.settingsBudgetRows;
  assert.is(row.figures, '$62.34 / $50.00 (125%)', 'the text is the truth, unclamped');
  assert.is(row.barValue, 100, 'the bar is full, not broken');
});

Suite('a $0 cap reads as a full meter, not a division by zero', () => {
  // The platform treats a $0 budget as "no spend allowed" (not "unlimited"), so
  // it has no headroom by definition.
  assert.is(budgetPercentOf({ budgetUsd: 0, spentUsd: 0 }), 100);
  assert.is(budgetPercentOf({ budgetUsd: 0, spentUsd: 3 }), 100);
  const el = element();
  el.setVendorBudget('gemini', { budgetUsd: 0, spentUsd: 0 });
  assert.is(el.settingsBudgetRows[0].figures, '$0.00 / $0.00 (100%)');
  assert.is(el.settingsBudgetRows[0].barValue, 100);
});

// ── Clearing ────────────────────────────────────────────────────────────────

Suite('setVendorBudget(vendor, null) clears that row and only that row', () => {
  const el = element();
  el.setVendorBudget('anthropic', { budgetUsd: 50, spentUsd: 45 });
  el.setVendorBudget('gemini', { budgetUsd: 50, spentUsd: 15 });

  assert.is(el.setVendorBudget('anthropic', null), true);

  assert.equal(
    el.settingsBudgetRows.map((r) => r.vendor),
    ['gemini'],
  );
});

Suite('clearing a vendor that has no figures is a true no-op', () => {
  const el = element();
  assert.is(el.setVendorBudget('anthropic', null), true, 'clearing nothing is not an error');
  assert.is(el.settingsBudgetRows.length, 0);
});

Suite("undefined clears too — an untyped host's missing lookup is not a crash", () => {
  // The `| null` parameter type is for typed callers; the runtime is more
  // forgiving on purpose. Untyped JS hosts feed this straight from
  // GET /api/budget, where a per-vendor lookup that MISSES yields `undefined`,
  // not `null` — and a boundary whose whole contract is "return false, never
  // throw" must not TypeError on it. "No figures" means "no row", either way.
  const el = element();
  el.setVendorBudget('anthropic', { budgetUsd: 50, spentUsd: 45 });
  el.setVendorBudget('gemini', { budgetUsd: 50, spentUsd: 15 });

  const warns = countWarns(() => {
    assert.is(el.setVendorBudget('anthropic', undefined as never), true, 'accepted, not refused');
  });

  assert.is(warns, 0, 'a missing lookup is not garbage input — nothing to warn about');
  assert.equal(
    el.settingsBudgetRows.map((r) => r.vendor),
    ['gemini'],
    'it cleared that vendor and only that vendor, exactly as null does',
  );
});

// ── Invalid input: false + warn once, never a throw, never a write ──────────

Suite('an unbudgeted vendor is refused with false and no state change', () => {
  const el = element();
  el.setVendorBudget('anthropic', { budgetUsd: 50, spentUsd: 15 });

  // 'none' is the no-provider sentinel; 'chrome' is on-device (no pot to
  // meter); 'openai' is refused by the proxy up front — none can carry figures.
  for (const vendor of ['none', 'chrome', 'openai', 'acme-ai']) {
    assert.is(el.setVendorBudget(vendor, { budgetUsd: 1, spentUsd: 0 }), false, vendor);
  }

  assert.equal(
    el.settingsBudgetRows.map((r) => r.vendor),
    ['anthropic'],
    'nothing was stored for any of them',
  );
});

Suite('non-finite or negative figures are refused with false and no state change', () => {
  const el = element();
  el.setVendorBudget('anthropic', { budgetUsd: 50, spentUsd: 15 });

  const bad: Array<{ budgetUsd: number; spentUsd: number }> = [
    { budgetUsd: NaN, spentUsd: 1 },
    { budgetUsd: 1, spentUsd: NaN },
    { budgetUsd: Infinity, spentUsd: 1 },
    { budgetUsd: 1, spentUsd: -0.01 },
    { budgetUsd: -50, spentUsd: 1 },
    // The endpoint's `limitUsd: null` (unlimited) must be dropped by the host,
    // not forwarded; forwarded anyway, it is garbage, not a clear.
    { budgetUsd: null as unknown as number, spentUsd: 1 },
  ];
  for (const figures of bad) {
    assert.is(el.setVendorBudget('anthropic', figures), false, JSON.stringify(figures));
  }

  assert.is(el.settingsBudgetRows[0].figures, '$15.00 / $50.00 (30%)', 'the good figures survive');
});

Suite('garbage warns once per element, not once per call', () => {
  // The documented feed re-runs after every turn, so a host bug would repeat
  // the same warning on every send for the life of the page.
  const el = element();

  const warns = countWarns(() => {
    assert.is(el.setVendorBudget('openai', { budgetUsd: 1, spentUsd: 0 }), false);
    assert.is(el.setVendorBudget('openai', { budgetUsd: 1, spentUsd: 0 }), false);
    assert.is(el.setVendorBudget('anthropic', { budgetUsd: NaN, spentUsd: 0 }), false);
  });

  assert.is(warns, 1, 'one warning covers the whole broken feed');
});

// ── Not ready yet: the other reason a feed is refused ───────────────────────

Suite('a feed before the element has a session store is refused, not silently dropped', () => {
  // The lifecycle window: a host feeding ahead of the first append, or during a
  // pop-out remount, has no store to write to. The dispatch no-ops there, so
  // returning true reported a write that never happened and the caller — which
  // reads the boolean as "accepted" — would never retry.
  const el = document.createElement('foundation-ai-assistant') as FoundationAiAssistant;

  const warns = countWarns(() => {
    assert.is(el.setVendorBudget('anthropic', { budgetUsd: 50, spentUsd: 45 }), false);
  });

  assert.is(warns, 0, 'a lifecycle window is not bad input — the warn-once channel is for garbage');
  assert.is(el.settingsBudgetRows.length, 0, 'nothing was stored');
  assert.is(el.settingsBudgetUsageVisible, false, 'and nothing is shown');

  // The recovery is the documented per-turn re-feed, which lands as soon as the
  // store exists — the connected path is unchanged.
  const ready = element();
  assert.is(ready.setVendorBudget('anthropic', { budgetUsd: 50, spentUsd: 45 }), true);
  assert.is(ready.settingsBudgetRows.length, 1);
});

// ── Idempotence ─────────────────────────────────────────────────────────────

Suite('re-feeding identical figures leaves the stored object untouched', () => {
  // The feed re-runs unconditionally after every turn; identical dollars must
  // not publish a new store reference (and re-render an unchanged meter).
  const el = element();
  el.setVendorBudget('anthropic', { budgetUsd: 50, spentUsd: 15 });
  const before = storedBudgets(el).anthropic;

  assert.is(el.setVendorBudget('anthropic', { budgetUsd: 50, spentUsd: 15 }), true);

  assert.is(storedBudgets(el).anthropic, before, 'same dollars, same object');
});

Suite('the store keeps its own copy, not the host object', () => {
  // The store must not adopt an object the host still owns: immer finalizes
  // whatever the reducer assigns into the draft and FREEZES it — so adoption
  // would freeze state the host believes is its own, turning the host's next
  // `figures.spentUsd += x` into a strict-mode TypeError (or a silent no-op).
  // The frozen-ness probe is the observable that catches adoption directly;
  // the mutation-isolation assertion is the user-visible consequence.
  const el = element();
  const hostFigures: VendorBudgetFigures = { budgetUsd: 50, spentUsd: 15 };
  el.setVendorBudget('anthropic', hostFigures);

  assert.is(Object.isFrozen(hostFigures), false, 'the host object was not adopted-and-frozen');

  hostFigures.spentUsd = 999;

  assert.is(el.settingsBudgetRows[0].figures, '$15.00 / $50.00 (30%)');
});

// ── Lifetime ────────────────────────────────────────────────────────────────

Suite('the figures survive "Clear" / "New chat" — budget is not conversation state', () => {
  // Same side of the resetSession preserve-list as the blocked latch: a new
  // chat refills no budget, so wiping the meter would blank real dollars until
  // the host's next re-feed. Opposite side from contextTokens, which IS
  // conversation state (a fresh chat genuinely has an empty context window).
  const el = element();
  el.setVendorBudget('anthropic', { budgetUsd: 50, spentUsd: 45 });
  el.messages = [{ id: '1', role: 'user', content: 'hello' } as ChatMessage];

  resetSession(el);

  assert.is(el.messages.length, 0, 'the transcript is gone');
  assert.is(el.contextTokens, undefined, 'context usage reset with the conversation');
  assert.is(el.settingsBudgetRows[0].figures, '$45.00 / $50.00 (90%)', 'the meter is not');
});

Suite('the figures are per session store — a different stateKey starts empty', () => {
  const first = element();
  const second = element();

  first.setVendorBudget('anthropic', { budgetUsd: 50, spentUsd: 45 });

  assert.is(first.settingsBudgetRows.length, 1);
  assert.is(second.settingsBudgetRows.length, 0, 'a different session store is unaffected');
});

// ── Visibility ──────────────────────────────────────────────────────────────

Suite('showBudgetUsage: false hides the meter even with figures fed', () => {
  const el = element();
  el.setVendorBudget('anthropic', { budgetUsd: 50, spentUsd: 45 });
  assert.is(el.settingsBudgetUsageVisible, true, 'default ON — undefined shows');

  el.chatConfig = { ui: { showBudgetUsage: false } };

  assert.is(el.settingsBudgetUsageVisible, false);
  assert.is(el.settingsBudgetRows.length, 1, 'the state itself is kept, only the render is off');

  el.chatConfig = { ui: { showBudgetUsage: true } };
  assert.is(el.settingsBudgetUsageVisible, true, 'explicit true shows too');
});

Suite('no figures → the meter behaves like the context indicator with no data', () => {
  // The absent case mirrors settingsContextUsageVisible exactly: nothing to
  // show means no meter AND no "AI Model Settings" section on its account.
  const el = element();

  assert.is(el.settingsContextUsageVisible, false, 'the sibling gate this one mirrors');
  assert.is(el.settingsBudgetUsageVisible, false);
  assert.is(el.settingsModelSectionVisible, false, 'no slot content, no data — no section');
});

Suite('clearing the last vendor takes the meter back out of view', () => {
  // The gate reads the RAW slice state rather than counting formatted rows, so
  // this is the case that keeps the two readings honest: a vendor that was fed
  // and then cleared must leave NO trace the gate could mistake for data.
  const el = element();
  el.setVendorBudget('anthropic', { budgetUsd: 50, spentUsd: 45 });
  assert.is(el.settingsBudgetUsageVisible, true);

  el.setVendorBudget('anthropic', null);

  assert.is(el.settingsBudgetUsageVisible, false, 'cleared is invisible, not an empty meter');
  assert.is(el.settingsBudgetRows.length, 0, 'the rows agree');
  assert.is(el.settingsModelSectionVisible, false, 'and the section goes with it');
});

Suite('the visibility gate answers without building a single row', () => {
  // The gate is read several times per render pass — this getter, the section
  // gate, the template's `when` — and answering it through
  // `settingsBudgetRows.length` formatted every vendor's money text each time
  // just to ask whether any rows exist. It reads the raw slice state instead;
  // the context sibling it mirrors gates on a bare `!= null` for the same
  // reason. The rendering path still builds rows, of course — the control below.
  const el = element();
  el.setVendorBudget('anthropic', { budgetUsd: 50, spentUsd: 45 });
  el.setVendorBudget('gemini', { budgetUsd: 200, spentUsd: 30.5 });

  assert.is(
    countRowBuilds(() => void el.settingsBudgetUsageVisible),
    0,
    'the gate builds nothing',
  );
  assert.is(
    countRowBuilds(() => void el.settingsBudgetRows),
    1,
    'the rendering path does (the control that the probe can see a read at all)',
  );
});

Suite('figures alone are enough to surface the AI Model Settings section', () => {
  // The wiring into settingsModelSectionVisible, the same way
  // settingsContextUsageVisible is wired in.
  const el = element();
  assert.is(el.settingsModelSectionVisible, false);

  el.setVendorBudget('gemini', { budgetUsd: 50, spentUsd: 15 });

  assert.is(el.settingsModelSectionVisible, true);

  el.chatConfig = { ui: { showBudgetUsage: false } };
  assert.is(el.settingsModelSectionVisible, false, 'and the config flag takes it back out');
});

// ── Freshness stamp + refresh request (GENC-1464 refresh affordance) ─────────

Suite('every feed stamps budgetMeterLastFedAt — identical figures included', () => {
  const el = element();
  assert.is(el.budgetMeterLastFedAt, null, 'null before any feed');
  assert.is(el.settingsBudgetUpdatedLabel, undefined, 'no label before any feed');

  el.setVendorBudget('anthropic', { budgetUsd: 50, spentUsd: 10 });
  const first = el.budgetMeterLastFedAt;
  assert.ok(typeof first === 'number', 'stamped on feed');
  assert.ok(el.settingsBudgetUpdatedLabel!.startsWith('Updated '), 'label renders');

  // The store is idempotent by value, but a refresh that CONFIRMS the same
  // dollars is still a refresh — the stamp must move even when the rows don't.
  el.budgetMeterLastFedAt = first! - 5000; // age it so a same-ms re-feed still differs
  el.setVendorBudget('anthropic', { budgetUsd: 50, spentUsd: 10 });
  assert.ok(el.budgetMeterLastFedAt! > first! - 5000, 'identical re-feed still stamps');

  // Clearing figures is not a refresh — the label describes shown figures.
  const before = el.budgetMeterLastFedAt;
  el.setVendorBudget('anthropic', null);
  assert.is(el.budgetMeterLastFedAt, before, 'a clear does not stamp');
});

Suite('requestBudgetRefresh emits budget-refresh-requested, and settings open asks too', () => {
  const el = element();
  let fired = 0;
  el.addEventListener('budget-refresh-requested', () => {
    fired += 1;
  });

  el.requestBudgetRefresh();
  assert.is(fired, 1, 'the button path emits');

  // The element cannot fetch — the event is the whole contract. Hosts without
  // a listener simply keep the per-turn cadence, so firing is unconditional.
  //
  // The modal stub is load-bearing: openSettingsModal's `show` closure
  // re-queues itself via DOM.queueUpdate until `settingsModal` exists, and on
  // an unconnected element that is never — an infinite queue that keeps the
  // test runner alive (this file's documented hang class). `open: true` makes
  // the first queued run return immediately.
  (el as unknown as { settingsModal: { open: boolean; show(): void } }).settingsModal = {
    open: true,
    show() {},
  };
  el.openSettingsModal();
  assert.is(fired, 2, 'opening settings requests fresh figures');
});

Suite.run();
