/**
 * AI Widget Builder — E2E / Static verification tests
 *
 * Covers:
 *   1. Relay security  — SSRF guard, auth gate, isPublicRoute, body limit, CORS
 *   2. Widget store    — constants, span-map keys, `cw-` prefix, history trim
 *   3. Title regex     — hyphens in titles (bug fixed: [^\n\-] → [^\n])
 *   4. HTML sanitizer  — allowlist shape, forbidden tags, unsafe style strip
 *   5. Panel guardrails — cw- exclusion in UnifiedSettings, event-handlers
 *   6. SSE event types — html_complete, done, error, tool_call all present
 */

import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import vm from 'node:vm';

const __dirname = dirname(fileURLToPath(import.meta.url));
const root = resolve(__dirname, '..');

function src(relPath) {
  return readFileSync(resolve(root, relPath), 'utf-8');
}

// ---------------------------------------------------------------------------
// 1. Relay security
// ---------------------------------------------------------------------------
describe('widget-agent relay — security', () => {
  const relay = src('scripts/ais-relay.cjs');

  it('isPublicRoute includes /widget-agent so relay secret gate is bypassed', () => {
    // Must be on the same line as other isPublicRoute checks
    const match = relay.match(/isPublicRoute\s*=\s*[^;]+/);
    assert.ok(match, 'isPublicRoute assignment not found');
    assert.ok(
      match[0].includes("'/widget-agent'") || match[0].includes('"/widget-agent"'),
      `isPublicRoute does not exempt /widget-agent:\n  ${match[0]}`,
    );
  });

  it('route is registered before the 404 catch-all', () => {
    const routeIdx = relay.indexOf("pathname === '/widget-agent' && req.method === 'POST'");
    const catchAllIdx = relay.lastIndexOf('res.writeHead(404)');
    assert.ok(routeIdx !== -1, 'widget-agent route registration not found');
    assert.ok(catchAllIdx !== -1, '404 catch-all not found');
    assert.ok(routeIdx < catchAllIdx, 'widget-agent route must appear before 404 catch-all');
  });

  it('auth check uses x-widget-key header (not relay shared secret)', () => {
    assert.ok(
      relay.includes("req.headers['x-widget-key']"),
      "Handler must check req.headers['x-widget-key']",
    );
    assert.ok(
      relay.includes('WIDGET_AGENT_KEY'),
      'Must compare against configured WIDGET_AGENT_KEY',
    );
  });

  it('widget-agent fails closed when WIDGET_AGENT_KEY is missing', () => {
    assert.ok(
      relay.includes('!status.widgetKeyConfigured'),
      'Shared widget-agent auth helper must reject requests when WIDGET_AGENT_KEY is unset',
    );
    const missingKeyIdx = relay.indexOf('!status.widgetKeyConfigured');
    const region = relay.slice(missingKeyIdx, missingKeyIdx + 200);
    assert.ok(region.includes('503'), 'Missing WIDGET_AGENT_KEY should return 503');
  });

  it('auth 403 response is sent before any processing on bad key', () => {
    const handlerStart = relay.indexOf('async function handleWidgetAgentRequest');
    assert.ok(handlerStart !== -1, 'handleWidgetAgentRequest not found');
    // Use 4000 chars to cover the full auth/setup section including SSE headers
    const handlerBody = relay.slice(handlerStart, handlerStart + 4000);
    const authCheckIdx = handlerBody.indexOf('requireWidgetAgentAccess(req, res)');
    const sseHeaderIdx = handlerBody.indexOf("text/event-stream");
    assert.ok(authCheckIdx !== -1, 'Auth helper call not found in handler start');
    assert.ok(sseHeaderIdx !== -1, "text/event-stream SSE header not found within handler");
    assert.ok(authCheckIdx < sseHeaderIdx, 'Auth check must come before SSE headers');
  });

  it('body size limit is enforced (160KB for PRO, covers basic too)', () => {
    assert.ok(
      relay.includes('163840'),
      'Body limit of 163840 bytes (160KB) must be present',
    );
    // Verify 413 is returned when limit exceeded (check global presence near the limit)
    assert.ok(relay.includes('413'), 'Body size guard must respond 413');
    // Both the check and 413 should be in the handler
    const handlerStart = relay.indexOf('async function handleWidgetAgentRequest');
    const handlerBody = relay.slice(handlerStart, handlerStart + 500);
    assert.ok(handlerBody.includes('163840'), 'Body limit must be enforced in handleWidgetAgentRequest');
  });

  it('SSRF guard — isWidgetEndpointAllowed function is present', () => {
    assert.ok(
      relay.includes('isWidgetEndpointAllowed'),
      'isWidgetEndpointAllowed guard function must exist',
    );
    // Must reject non-API paths
    assert.ok(
      relay.includes("startsWith('/api/')"),
      'Guard must restrict to /api/ prefix',
    );
  });

  it('SSRF guard — allowlist is checked before any fetch call in tool loop', () => {
    const allowlistCheck = relay.indexOf('isWidgetEndpointAllowed(endpoint)');
    assert.ok(allowlistCheck !== -1, 'isWidgetEndpointAllowed() check missing in tool loop');
    // The fetch call to api.worldmonitor.app must come AFTER the check
    const fetchCallIdx = relay.indexOf("'https://api.worldmonitor.app'", allowlistCheck);
    assert.ok(
      fetchCallIdx > allowlistCheck,
      'fetch() to api.worldmonitor.app must appear after allowlist check',
    );
  });

  it('SSRF guard — dangerous inference/write paths are blocked', () => {
    assert.ok(
      relay.includes('analyze-stock') && relay.includes('summarize-article'),
      'Blocklist must explicitly exclude inference-only paths',
    );
  });

  it('SSRF guard — deduct-situation blocklist entry matches the real method name (#3740)', () => {
    // Regression: blocklist previously had a one-char typo 'deduce-situation' that
    // never matched the real /api/intelligence/v1/deduct-situation path, leaving an
    // expensive LLM endpoint freely callable.
    //
    // A fully behavioral test (calling isWidgetEndpointAllowed() with the real URL)
    // would catch a wider set of regressions than these structural assertions, but
    // requires extracting the function from ais-relay.cjs into its own module —
    // ais-relay.cjs starts an HTTP server unconditionally at module-load time, so
    // it can't be required from a unit test without that refactor. The structural
    // checks below catch the failure modes the audit explicitly named: the typo,
    // the substring leaving the blocked-array, and a refactor away from the
    // substring-match dispatch that re-opens the gap.

    // 1. The correct entry is present (and the typo is gone).
    assert.ok(
      relay.includes("'deduct-situation'"),
      "Blocklist must contain 'deduct-situation' (matches /api/intelligence/v1/deduct-situation)",
    );
    assert.ok(
      !relay.includes("'deduce-situation'"),
      "Blocklist must not contain the typo 'deduce-situation' — it never matches any real URL",
    );

    // 2. The entry lives inside the `blocked = [...]` array literal, not in some
    //    comment that happens to mention the method name. This catches a regression
    //    where the array is commented out or guarded by a falsy condition but the
    //    string survives elsewhere in the file.
    const blockedArrayMatch = relay.match(/const blocked = \[[\s\S]*?\];/);
    assert.ok(blockedArrayMatch, "isWidgetEndpointAllowed must define `const blocked = [...]`");
    assert.ok(
      blockedArrayMatch[0].includes("'deduct-situation'"),
      "'deduct-situation' must appear inside the blocked array literal, not just somewhere in the file",
    );

    // 3. The dispatch still uses substring matching (`endpoint.includes(b)`). A
    //    refactor to exact equality (`endpoint === b`) would silently re-open the
    //    gap because callers pass the full `/api/intelligence/v1/deduct-situation`
    //    path, not the bare method name.
    assert.ok(
      /blocked\.some\([^)]*=>\s*endpoint\.includes\(/.test(relay),
      "isWidgetEndpointAllowed must keep substring matching (`endpoint.includes(b)`); switching to `endpoint === b` would re-open the SSRF gap",
    );
  });

  it('injection guard — isWidgetInjectionAttempt function is present', () => {
    assert.ok(relay.includes('isWidgetInjectionAttempt'), 'injection guard function must exist');
    assert.ok(relay.includes('ignore') && relay.includes('previous'), 'must detect override patterns');
    assert.ok(relay.includes('jailbreak'), 'must detect jailbreak keyword');
    assert.ok(relay.includes('act\\s+as'), 'must detect role hijacking');
  });

  it('injection guard — hard rejected before API call', () => {
    const guardIdx = relay.indexOf('isWidgetInjectionAttempt(prompt)');
    assert.ok(guardIdx !== -1, 'injection check must be called on prompt');
    // Guard must appear before the Anthropic client is created
    const anthropicIdx = relay.indexOf('new Anthropic(');
    assert.ok(guardIdx < anthropicIdx, 'injection check must happen before any Anthropic API call');
  });

  it('injection guard — tool results are sanitized before context insertion', () => {
    assert.ok(relay.includes('sanitizeToolContent'), 'sanitizeToolContent must be applied to tool results');
    // Must be called on both search results and WM data results
    const count = (relay.match(/sanitizeToolContent/g) || []).length;
    assert.ok(count >= 3, `sanitizeToolContent must appear in definition + both result paths (found ${count})`);
  });

  it('tool loop is bounded by maxTurns (6 for basic, 10 for PRO)', () => {
    assert.ok(
      relay.includes('turn < maxTurns'),
      'Tool loop must use maxTurns variable (not hardcoded 6)',
    );
    // Basic tier maxTurns is set to 6
    assert.ok(
      relay.includes('maxTurns = isPro ? 10 : 6') || relay.includes('isPro ? 10 : 6'),
      'maxTurns must be 6 for basic and 10 for PRO',
    );
  });

  it('server timeout is 90 seconds', () => {
    assert.ok(
      relay.includes('90_000') || relay.includes('90000'),
      'Server timeout must be 90 seconds (90_000 ms)',
    );
  });

  it('CORS for /widget-agent: POST in Allow-Methods, X-Widget-Key and X-Pro-Key in Allow-Headers', () => {
    const widgetCorsIdx = relay.indexOf("pathname.startsWith('/widget-agent')");
    assert.ok(widgetCorsIdx !== -1);
    const corsBlock = relay.slice(widgetCorsIdx, widgetCorsIdx + 500);
    assert.ok(
      corsBlock.includes('GET, POST, OPTIONS'),
      'CORS must include POST in Allow-Methods for /widget-agent',
    );
    assert.ok(
      corsBlock.includes('X-Widget-Key'),
      'CORS must include X-Widget-Key in Allow-Headers for /widget-agent',
    );
    assert.ok(
      corsBlock.includes('X-Pro-Key'),
      'CORS must include X-Pro-Key in Allow-Headers for /widget-agent',
    );
  });

  it('CORS reuses getCorsOrigin (not a narrow hardcoded origin list)', () => {
    const widgetCorsIdx = relay.indexOf("pathname.startsWith('/widget-agent')");
    const corsBlock = relay.slice(widgetCorsIdx, widgetCorsIdx + 600);
    // Must NOT define a hardcoded origins array for this specific route
    assert.ok(
      !corsBlock.includes("['https://worldmonitor.app'"),
      'Do NOT hardcode origins for /widget-agent — reuse getCorsOrigin()',
    );
    // Must reference corsOrigin variable (set by getCorsOrigin earlier)
    // (The block itself may not set Access-Control-Allow-Origin since that's
    // already set above; it just overrides Methods and Headers)
    assert.ok(
      corsBlock.includes('Access-Control-Allow-Methods') ||
      corsBlock.includes('Access-Control-Allow-Headers'),
      'CORS block for /widget-agent must set Allow-Methods or Allow-Headers',
    );
  });

  it('registers GET /widget-agent/health before the 404 catch-all', () => {
    const healthRouteIdx = relay.indexOf("pathname === '/widget-agent/health' && req.method === 'GET'");
    const catchAllIdx = relay.lastIndexOf('res.writeHead(404)');
    assert.ok(healthRouteIdx !== -1, 'widget-agent health route registration not found');
    assert.ok(healthRouteIdx < catchAllIdx, 'widget-agent health route must appear before 404 catch-all');
  });

  it('uses raw @anthropic-ai/sdk (not agent SDK)', () => {
    // Dynamic import should be for @anthropic-ai/sdk specifically
    assert.ok(
      relay.includes("'@anthropic-ai/sdk'") || relay.includes('"@anthropic-ai/sdk"'),
      'Must use @anthropic-ai/sdk (raw SDK)',
    );
    assert.ok(
      !relay.includes('@anthropic-ai/claude-code'),
      'Must NOT use @anthropic-ai/claude-code Agent SDK',
    );
  });

  it('model used is claude-haiku (cost-efficient for widgets)', () => {
    assert.ok(
      relay.includes('claude-haiku'),
      'Widget agent should use claude-haiku model for cost efficiency',
    );
  });
});

// ---------------------------------------------------------------------------
// 2. Widget store
// ---------------------------------------------------------------------------
describe('widget-store — constants and logic', () => {
  const store = src('src/services/widget-store.ts');
  const browserKeySession = src('src/services/browser-key-session.ts');

  it('storage key is wm-custom-widgets', () => {
    assert.ok(
      store.includes("'wm-custom-widgets'"),
      "Storage key must be 'wm-custom-widgets'",
    );
  });

  it('auth gate migrates wm-widget-key to an HttpOnly session instead of storing it', () => {
    assert.ok(
      store.includes("'wm-widget-key'"),
      "Feature gate must know the legacy 'wm-widget-key' name for migration",
    );
    assert.ok(
      store.includes('migrateLegacyKeysToHttpOnlySession') &&
        browserKeySession.includes('establishWmKeySession'),
      'Widget key writes must go through the server session endpoint',
    );
    assert.ok(
      !/localStorage\.setItem\(['"]wm-widget-key['"]/.test(store),
      'wm-widget-key must not be written to localStorage',
    );
    assert.ok(
      !/document\.cookie\s*=.*wm-widget-key.*encodeURIComponent\(.*key/s.test(store),
      'wm-widget-key must not be written to a JS-readable cookie',
    );
  });

  it('MAX_WIDGETS is 10', () => {
    assert.ok(
      store.includes('MAX_WIDGETS') && store.includes('10'),
      'MAX_WIDGETS constant should be 10',
    );
    const match = store.match(/MAX_WIDGETS\s*=\s*(\d+)/);
    assert.ok(match, 'MAX_WIDGETS not found');
    assert.equal(Number(match[1]), 10, 'MAX_WIDGETS must be 10');
  });

  it('MAX_HTML_CHARS is 50000', () => {
    const match = store.match(/MAX_HTML_(?:CHARS|BYTES)\s*=\s*([\d_]+)/);
    assert.ok(match, 'MAX_HTML_CHARS/BYTES constant not found');
    const val = Number(match[1].replace(/_/g, ''));
    assert.equal(val, 50000, 'HTML size limit must be 50,000 chars');
  });

  it('MAX_HISTORY is 10', () => {
    const match = store.match(/MAX_HISTORY\s*=\s*(\d+)/);
    assert.ok(match, 'MAX_HISTORY constant not found');
    assert.equal(Number(match[1]), 10, 'MAX_HISTORY must be 10');
  });

  it('widget IDs use cw- prefix (in modal or store)', () => {
    const modal = src('src/components/WidgetChatModal.ts');
    assert.ok(
      store.includes("'cw-'") || store.includes('"cw-"') ||
      modal.includes("'cw-'") || modal.includes('"cw-"') ||
      modal.includes('`cw-'),
      "Widget IDs must use 'cw-' prefix (check widget-store.ts and WidgetChatModal.ts)",
    );
  });

  it('deleteWidget cleans worldmonitor-panel-spans (aggregate map)', () => {
    assert.ok(
      store.includes('clearPanelSpanEntry(id)'),
      'deleteWidget must clean row-span entries through the shared panel storage helper',
    );
  });

  it('deleteWidget cleans worldmonitor-panel-col-spans (aggregate map)', () => {
    assert.ok(
      store.includes('clearPanelColSpanEntry(id)'),
      'deleteWidget must clean column-span entries through the shared panel storage helper',
    );
  });

  it('saveWidget trims conversationHistory before write', () => {
    // Should call slice(-MAX_HISTORY) before persisting
    const saveIdx = store.indexOf('function saveWidget');
    assert.ok(saveIdx !== -1, 'saveWidget not found');
    const saveBody = store.slice(saveIdx, saveIdx + 800);
    assert.ok(
      saveBody.includes('.slice(-') || saveBody.includes('slice(-MAX_HISTORY'),
      'saveWidget must trim conversationHistory with .slice(-MAX_HISTORY)',
    );
  });

  it('saveWidget truncates html to MAX_HTML_CHARS before write', () => {
    const saveIdx = store.indexOf('function saveWidget');
    assert.ok(saveIdx !== -1);
    const saveBody = store.slice(saveIdx, saveIdx + 800);
    assert.ok(
      saveBody.includes('.slice(0, MAX_HTML'),
      'saveWidget must truncate html to MAX_HTML_CHARS',
    );
  });
});

// ---------------------------------------------------------------------------
// 3. Title regex (hyphens-in-titles bug fix)
// ---------------------------------------------------------------------------
describe('widget-agent relay — title extraction regex', () => {
  const relay = src('scripts/ais-relay.cjs');

  it('title regex does NOT exclude hyphens (fixed bug: [^\\n\\-] → [^\\n])', () => {
    // Extract the title extraction regex from the relay source
    const match = relay.match(/titleMatch\s*=\s*text\.match\(([^;]+)\)/);
    assert.ok(match, 'Title extraction line not found (expected: titleMatch = text.match(...))');
    const regexStr = match[1];
    // Must NOT have \- inside a character class (the old bug)
    assert.ok(
      !regexStr.includes('\\-') && !regexStr.includes('\\\\-'),
      `Title regex must not exclude hyphens. Found: ${regexStr}`,
    );
  });

  it('title regex correctly parses hyphenated titles', () => {
    // Simulate the regex from the source
    const regex = /<!--\s*title:\s*([^\n]+?)\s*-->/;
    const cases = [
      { input: '<!-- title: Market-Tracker -->', expected: 'Market-Tracker' },
      { input: '<!-- title: US-China Trade Watch -->', expected: 'US-China Trade Watch' },
      { input: '<!-- title: Simple Widget -->', expected: 'Simple Widget' },
      { input: '<!-- title:  Leading Spaces -->', expected: 'Leading Spaces' },
    ];
    for (const { input, expected } of cases) {
      const m = input.match(regex);
      assert.ok(m, `No match for: ${input}`);
      assert.equal(m[1].trim(), expected, `Wrong title extracted from: ${input}`);
    }
  });

  it('title regex falls back to "Custom Widget" when comment absent', () => {
    const regex = /<!--\s*title:\s*([^\n]+?)\s*-->/;
    const text = 'Some widget HTML without title comment';
    const m = text.match(regex);
    const title = m?.[1]?.trim() ?? 'Custom Widget';
    assert.equal(title, 'Custom Widget');
  });

  it('html extraction regex handles multiline content', () => {
    const regex = /<!--\s*widget-html\s*-->([\s\S]*?)<!--\s*\/widget-html\s*-->/;
    const html = `<!-- widget-html -->\n<div>hello</div>\n<!-- /widget-html -->`;
    const m = html.match(regex);
    assert.ok(m, 'HTML extraction must match');
    assert.ok(m[1].includes('<div>hello</div>'), 'Must capture content between markers');
  });

  it('html extraction falls back to full text when markers missing', () => {
    const regex = /<!--\s*widget-html\s*-->([\s\S]*?)<!--\s*\/widget-html\s*-->/;
    const text = '<div>fallback</div>';
    const m = text.match(regex);
    const html = (m?.[1] ?? text).slice(0, 50000);
    assert.equal(html, '<div>fallback</div>');
  });
});

// ---------------------------------------------------------------------------
// 4. HTML sanitizer
// ---------------------------------------------------------------------------
describe('widget-sanitizer — allowlist verification', () => {
  const san = src('src/utils/widget-sanitizer.ts');

  const REQUIRED_ALLOWED_TAGS = ['div', 'span', 'p', 'table', 'svg', 'path'];
  const REQUIRED_FORBIDDEN_TAGS = ['button', 'input', 'script', 'iframe', 'form'];
  const REQUIRED_ALLOWED_ATTRS = ['class', 'style', 'viewBox', 'fill', 'stroke'];

  for (const tag of REQUIRED_ALLOWED_TAGS) {
    it(`allowed tag '${tag}' is in ALLOWED_TAGS`, () => {
      assert.ok(
        san.includes(`'${tag}'`) || san.includes(`"${tag}"`),
        `Tag '${tag}' must be in ALLOWED_TAGS`,
      );
    });
  }

  for (const tag of REQUIRED_FORBIDDEN_TAGS) {
    it(`forbidden tag '${tag}' is in FORBID_TAGS`, () => {
      assert.ok(
        san.includes(`'${tag}'`) || san.includes(`"${tag}"`),
        `Tag '${tag}' must be in FORBID_TAGS`,
      );
    });
  }

  for (const attr of REQUIRED_ALLOWED_ATTRS) {
    it(`attribute '${attr}' is in ALLOWED_ATTR`, () => {
      assert.ok(
        san.includes(`'${attr}'`) || san.includes(`"${attr}"`),
        `Attr '${attr}' must be in ALLOWED_ATTR`,
      );
    });
  }

  it('FORCE_BODY is true (prevents <html> wrapper)', () => {
    assert.ok(san.includes('FORCE_BODY: true'), 'FORCE_BODY must be true');
  });

  it('post-pass strips url() from style attributes', () => {
    assert.ok(
      san.includes('url') && (san.includes('UNSAFE_STYLE') || san.includes('unsafe')),
      'Must have post-pass regex stripping url() from style values',
    );
  });

  it('post-pass strips javascript: from style attributes', () => {
    assert.ok(
      san.includes('javascript'),
      'Must have post-pass regex stripping javascript: from style values',
    );
  });

  it('post-pass strips expression() from style attributes', () => {
    assert.ok(
      san.includes('expression'),
      'Must have post-pass regex stripping expression() from style values',
    );
  });
});

// ---------------------------------------------------------------------------
// 5. Panel guardrails — cw- exclusions
// ---------------------------------------------------------------------------
describe('panel guardrails — cw- prefix handling', () => {
  const settings = src('src/components/UnifiedSettings.ts');
  const events = src('src/app/event-handlers.ts');
  const layout = src('src/app/panel-layout.ts');

  it('UnifiedSettings filters out cw- panels from settings list', () => {
    assert.ok(
      settings.includes("startsWith('cw-')"),
      "UnifiedSettings must filter panels with id.startsWith('cw-')",
    );
  });

  it('event-handlers confirms before deleting cw- panels', () => {
    assert.ok(
      events.includes("startsWith('cw-')"),
      "event-handlers must detect cw- prefix for custom widget panels",
    );
    assert.ok(
      events.includes("t('widgets.confirmDelete')"),
      'Custom widget delete confirmation must use localized widgets.confirmDelete copy',
    );
    assert.ok(
      events.includes('confirm') || events.includes('window.confirm'),
      'Must show a confirm dialog before deleting custom widgets',
    );
  });

  it('event-handlers calls deleteWidget for cw- panels', () => {
    assert.ok(
      events.includes('deleteWidget'),
      'Must call deleteWidget() when removing a custom widget panel',
    );
  });

  it('event-handlers registers wm:widget-modify listener', () => {
    assert.ok(
      events.includes('wm:widget-modify'),
      'Must listen for wm:widget-modify custom event',
    );
  });

  it('panel-layout loads widgets when feature is enabled', () => {
    assert.ok(
      layout.includes('hasPremiumAccess') || layout.includes('isProUser'),
      'panel-layout must check hasPremiumAccess (or isProUser) before loading widgets',
    );
    assert.ok(
      layout.includes('loadWidgets'),
      'panel-layout must call loadWidgets() to restore persisted widgets',
    );
  });

  it('panel-layout has addCustomWidget method', () => {
    assert.ok(
      layout.includes('addCustomWidget'),
      'panel-layout must implement addCustomWidget() method',
    );
  });

  it('panel-layout AI button is gated by hasPremiumAccess', () => {
    const hasCheck = layout.includes('hasPremiumAccess') || layout.includes('isProUser');
    const buttonIdx = layout.indexOf('ai-widget-block');
    assert.ok(hasCheck, 'hasPremiumAccess (or isProUser) not found in panel-layout');
    assert.ok(buttonIdx !== -1, 'AI widget button not found in panel-layout');
  });

  it('panel-layout DEV warning excludes cw- panels', () => {
    assert.ok(
      layout.includes("startsWith('cw-')"),
      "DEV warning must exclude panels with id.startsWith('cw-')",
    );
  });
});

// ---------------------------------------------------------------------------
// 6. SSE event types
// ---------------------------------------------------------------------------
describe('widget-agent relay — SSE event protocol', () => {
  const relay = src('scripts/ais-relay.cjs');

  const EXPECTED_SSE_EVENTS = ['html_complete', 'done', 'error', 'tool_call'];

  for (const event of EXPECTED_SSE_EVENTS) {
    it(`SSE event '${event}' is sent by handler`, () => {
      assert.ok(
        relay.includes(`'${event}'`) || relay.includes(`"${event}"`),
        `SSE event '${event}' not found in relay handler`,
      );
    });
  }

  it('sendWidgetSSE helper is defined', () => {
    assert.ok(
      relay.includes('sendWidgetSSE') || relay.includes('function sendWidgetSSE'),
      'sendWidgetSSE helper must be defined',
    );
  });

  it('html_complete event carries html payload', () => {
    const idx = relay.indexOf('html_complete');
    assert.ok(idx !== -1);
    const region = relay.slice(idx - 50, idx + 200);
    assert.ok(region.includes('html'), "html_complete event must include 'html' field");
  });

  it('done event carries title payload', () => {
    const idx = relay.indexOf("'done'");
    assert.ok(idx !== -1);
    const region = relay.slice(idx, idx + 100);
    assert.ok(region.includes('title'), "done event must include 'title' field");
  });

  it('tool_call event carries endpoint for UI badge display', () => {
    const idx = relay.indexOf("'tool_call'");
    assert.ok(idx !== -1);
    const region = relay.slice(idx, idx + 150);
    assert.ok(region.includes('endpoint'), "tool_call event must include 'endpoint' field");
  });
});

// ---------------------------------------------------------------------------
// 7. WidgetChatModal — client-side SSE handling
// ---------------------------------------------------------------------------
describe('WidgetChatModal — SSE client protocol', () => {
  const modal = src('src/components/WidgetChatModal.ts');

  it('uses fetch (not EventSource) for POST SSE', () => {
    assert.ok(modal.includes('fetch(widgetAgentUrl'), 'Must use fetch() not EventSource');
    assert.ok(!modal.includes('new EventSource'), 'Must NOT use EventSource (POST not supported)');
  });

  it('sends X-Widget-Key header', () => {
    assert.ok(
      modal.includes('X-Widget-Key'),
      'Must send X-Widget-Key header with request',
    );
  });

  it('runs preflight against widget-agent health route on open', () => {
    assert.ok(modal.includes('widgetAgentHealthUrl'), 'Modal must import widgetAgentHealthUrl()');
    assert.ok(modal.includes('runPreflight'), 'Modal must define runPreflight()');
    assert.ok(modal.includes("fetch(widgetAgentHealthUrl()"), 'Modal must fetch widgetAgentHealthUrl() during preflight');
  });

  it('AbortController used for cancellation', () => {
    assert.ok(modal.includes('AbortController'), 'Must use AbortController for stream cancellation');
  });

  it('client timeout is 60 seconds', () => {
    assert.ok(
      modal.includes('60_000') || modal.includes('60000'),
      'Client timeout must be 60 seconds (60_000 ms)',
    );
  });

  it('currentHtml sent as separate field (not embedded in conversationHistory)', () => {
    const bodyIdx = modal.indexOf('JSON.stringify');
    assert.ok(bodyIdx !== -1);
    const bodyRegion = modal.slice(bodyIdx, bodyIdx + 400);
    assert.ok(bodyRegion.includes('currentHtml'), 'Must send currentHtml as separate request field');
    assert.ok(bodyRegion.includes('conversationHistory'), 'Must send conversationHistory');
  });

  it('prompt is sliced to 2000 chars before sending', () => {
    assert.ok(
      modal.includes('.slice(0, 2000)'),
      'Prompt must be sliced to 2000 chars before sending',
    );
  });

  it('history content is sliced to 500 chars per entry', () => {
    assert.ok(
      modal.includes('.slice(0, 500)'),
      'Each history entry content must be sliced to 500 chars',
    );
  });

  it('modal handles AbortError without showing error to user', () => {
    assert.ok(
      modal.includes('AbortError'),
      'Must handle AbortError (e.g. from timeout or close) gracefully',
    );
  });

  it('Escape key closes modal', () => {
    assert.ok(
      modal.includes('Escape') || modal.includes("'Escape'"),
      'Escape key must close the modal',
    );
  });

  it('action button says "Add to Dashboard" (create) or "Apply Changes" (modify)', () => {
    assert.ok(modal.includes("t('widgets.addToDashboard')"), 'Create mode button must use widgets.addToDashboard');
    assert.ok(modal.includes("t('widgets.applyChanges')"), 'Modify mode button must use widgets.applyChanges');
  });

  it('uses split layout and sticky footer action bar structure', () => {
    assert.ok(modal.includes('widget-chat-layout'), 'Modal must render widget-chat-layout');
    assert.ok(modal.includes('widget-chat-sidebar'), 'Modal must render widget-chat-sidebar');
    assert.ok(modal.includes('widget-chat-main'), 'Modal must render widget-chat-main');
    assert.ok(modal.includes('widget-chat-footer'), 'Modal must render widget-chat-footer');
  });

  it('renders prompt example chips', () => {
    assert.ok(modal.includes('EXAMPLE_PROMPT_KEYS'), 'Modal must define prompt example keys');
    assert.ok(modal.includes('widget-chat-example-chip'), 'Modal must render prompt example chips');
  });

  it('conversationHistory entries use literal role types (user | assistant)', () => {
    // After our fix, these should use `as const`
    assert.ok(
      modal.includes("'user' as const") || modal.includes('"user" as const'),
      "role must be typed as literal 'user' with `as const`",
    );
    assert.ok(
      modal.includes("'assistant' as const") || modal.includes('"assistant" as const'),
      "role must be typed as literal 'assistant' with `as const`",
    );
  });

  it('multi-turn requests reuse mutable sessionHistory instead of original spec history', () => {
    assert.ok(
      modal.includes('const sessionHistory = [...(options.existingSpec?.conversationHistory ?? [])]'),
      'Modal must keep a mutable sessionHistory array for iterative requests',
    );
    assert.ok(
      modal.includes('conversationHistory: sessionHistory'),
      'Outgoing request body must use the mutable sessionHistory array',
    );
    assert.ok(
      modal.includes('sessionHistory.push('),
      'Modal must append new user/assistant turns back into sessionHistory after success',
    );
    assert.ok(
      modal.includes('conversationHistory: [...sessionHistory]'),
      'Saved widget spec must persist the updated sessionHistory',
    );
  });
});

// ---------------------------------------------------------------------------
// 8. Vite proxy + URL helper
// ---------------------------------------------------------------------------
describe('proxy routing — widgetAgentUrl', () => {
  const proxy = src('src/utils/proxy.ts');
  const vite = src('vite.config.ts');

  it('widgetAgentUrl() exists in proxy.ts', () => {
    assert.ok(
      proxy.includes('widgetAgentUrl'),
      'widgetAgentUrl() must be defined in src/utils/proxy.ts',
    );
  });

  it('widgetAgentUrl returns /widget-agent in dev (for Vite proxy)', () => {
    assert.ok(
      proxy.includes("'/widget-agent'") || proxy.includes('"/widget-agent"'),
      'widgetAgentUrl must return /widget-agent in dev mode',
    );
  });

  it('widgetAgentUrl targets proxy.worldmonitor.app (not toRuntimeUrl)', () => {
    // The URL may be in a constant above the function; search the whole file
    assert.ok(
      proxy.includes('proxy.worldmonitor.app'),
      'Must target proxy.worldmonitor.app directly (sidecar destroys SSE via arrayBuffer)',
    );
    // Verify the function itself does not use toRuntimeUrl
    const fnIdx = proxy.indexOf('function widgetAgentUrl');
    assert.ok(fnIdx !== -1, 'widgetAgentUrl function not found');
    const fnBody = proxy.slice(fnIdx, fnIdx + 400);
    assert.ok(
      !fnBody.includes('toRuntimeUrl'),
      'widgetAgentUrl must NOT use toRuntimeUrl — sidecar buffers via arrayBuffer, destroying SSE',
    );
  });

  it('vite.config.ts proxies /widget-agent to proxy.worldmonitor.app', () => {
    assert.ok(
      vite.includes('/widget-agent'),
      'vite.config.ts must have proxy entry for /widget-agent',
    );
    assert.ok(
      vite.includes('proxy.worldmonitor.app'),
      'Vite proxy target must be proxy.worldmonitor.app',
    );
  });

  it('widgetAgentHealthUrl() exists and targets /widget-agent/health', () => {
    assert.ok(proxy.includes('widgetAgentHealthUrl'), 'widgetAgentHealthUrl() must be defined');
    assert.ok(proxy.includes('/widget-agent/health'), 'widgetAgentHealthUrl() must target /widget-agent/health');
  });
});

// ---------------------------------------------------------------------------
// 9. i18n completeness
// ---------------------------------------------------------------------------
describe('i18n — widgets section completeness', () => {
  const en = JSON.parse(src('src/locales/en.json'));

  const REQUIRED_KEYS = [
    'confirmDelete',
    'chatTitle',
    'modifyTitle',
    'inputPlaceholder',
    'addToDashboard',
    'applyChanges',
    'send',
    'modifyWithAi',
    'ready',
    'fetching',
    'requestTimedOut',
    'serverError',
    'unknownError',
    'generatedWidget',
    'checkingConnection',
    'preflightConnected',
    'preflightInvalidKey',
    'preflightUnavailable',
    'preflightAiUnavailable',
    'readyToGenerate',
    'readyToApply',
    'modifyHint',
    'generating',
    'examplesTitle',
    'previewTitle',
    'phaseChecking',
    'phaseReadyToPrompt',
    'phaseFetching',
    'phaseComposing',
    'phaseComplete',
    'phaseError',
    'previewCheckingHeading',
    'previewReadyHeading',
    'previewFetchingHeading',
    'previewComposingHeading',
    'previewErrorHeading',
    'previewCheckingCopy',
    'previewReadyCopy',
    'previewFetchingCopy',
    'previewComposingCopy',
    'previewErrorCopy',
  ];

  for (const key of REQUIRED_KEYS) {
    it(`widgets.${key} is defined and non-empty`, () => {
      assert.ok(
        en.widgets && typeof en.widgets[key] === 'string' && en.widgets[key].length > 0,
        `en.json must have non-empty widgets.${key}`,
      );
    });
  }

  it('confirmDelete text sounds permanent (not just hide)', () => {
    assert.ok(
      en.widgets.confirmDelete.toLowerCase().includes('remove') ||
      en.widgets.confirmDelete.toLowerCase().includes('delete') ||
      en.widgets.confirmDelete.toLowerCase().includes('permanent'),
      'confirmDelete must convey permanence — not just hide',
    );
  });

  it('widget UI sources labels from i18n keys instead of hardcoded English copy', () => {
    const modal = src('src/components/WidgetChatModal.ts');
    const panel = src('src/components/CustomWidgetPanel.ts');
    const events = src('src/app/event-handlers.ts');
    assert.ok(modal.includes("t('widgets.chatTitle')"), 'WidgetChatModal must use widgets.chatTitle');
    assert.ok(modal.includes("t('widgets.modifyTitle')"), 'WidgetChatModal must use widgets.modifyTitle');
    assert.ok(modal.includes("t('widgets.inputPlaceholder')"), 'WidgetChatModal must use widgets.inputPlaceholder');
    assert.ok(panel.includes("t('widgets.modifyWithAi')"), 'CustomWidgetPanel must use widgets.modifyWithAi');
    assert.ok(events.includes("t('widgets.confirmDelete')"), 'Delete confirmation must use widgets.confirmDelete');
  });

  it('prompt examples are defined and non-empty', () => {
    const exampleKeys = ['oilGold', 'cryptoMovers', 'flightDelays', 'conflictHotspots'];
    for (const key of exampleKeys) {
      assert.ok(
        typeof en.widgets.examples[key] === 'string' && en.widgets.examples[key].length > 0,
        `en.json must have non-empty widgets.examples.${key}`,
      );
    }
  });
});

// ---------------------------------------------------------------------------
// 10. CustomWidgetPanel
// ---------------------------------------------------------------------------
describe('CustomWidgetPanel — header buttons and events', () => {
  const panel = src('src/components/CustomWidgetPanel.ts');
  const sanitizer = src('src/utils/widget-sanitizer.ts');

  it('dispatches wm:widget-modify event from chat button', () => {
    assert.ok(
      panel.includes('wm:widget-modify'),
      'CustomWidgetPanel must dispatch wm:widget-modify CustomEvent',
    );
  });

  it('applies --widget-accent CSS variable', () => {
    assert.ok(
      panel.includes('--widget-accent'),
      'CustomWidgetPanel must apply --widget-accent CSS variable',
    );
  });

  it('renderWidget uses shared wrapped widget HTML helper', () => {
    assert.ok(
      panel.includes('wrapWidgetHtml'),
      'renderWidget must use wrapWidgetHtml() for shell + sanitization',
    );
    assert.ok(
      sanitizer.includes('sanitizeWidgetHtml'),
      'wrapWidgetHtml() must sanitize HTML internally',
    );
    assert.ok(
      sanitizer.includes('wm-widget-generated'),
      'wrapWidgetHtml() must provide a contained generated-widget wrapper',
    );
  });

  it('extends Panel (display-only widget with panel infrastructure)', () => {
    assert.ok(
      panel.includes('extends Panel'),
      'CustomWidgetPanel must extend Panel',
    );
  });

  it('renderWidget branches on tier — PRO uses wrapProWidgetHtml', () => {
    assert.ok(
      panel.includes('wrapProWidgetHtml'),
      "renderWidget must call wrapProWidgetHtml() for PRO tier",
    );
  });

  it('PRO badge rendered in header when tier is pro', () => {
    assert.ok(
      panel.includes('widget-pro-badge'),
      'CustomWidgetPanel must render .widget-pro-badge for PRO widgets',
    );
  });
});

// ---------------------------------------------------------------------------
// 11. PRO widget — relay
// ---------------------------------------------------------------------------
describe('PRO widget — relay auth and configuration', () => {
  const relay = src('scripts/ais-relay.cjs');

  it('PRO_WIDGET_KEY is read from env', () => {
    assert.ok(
      relay.includes('PRO_WIDGET_KEY'),
      'PRO_WIDGET_KEY must be defined from env',
    );
  });

  it('PRO_WIDGET_RATE_LIMIT is 20', () => {
    const match = relay.match(/PRO_WIDGET_RATE_LIMIT\s*=\s*(\d+)/);
    assert.ok(match, 'PRO_WIDGET_RATE_LIMIT constant not found');
    assert.equal(Number(match[1]), 20, 'PRO_WIDGET_RATE_LIMIT must be 20');
  });

  it('proWidgetRateLimitMap is a separate rate limit bucket from basic', () => {
    assert.ok(
      relay.includes('proWidgetRateLimitMap'),
      'PRO must use a separate rate limit map (proWidgetRateLimitMap)',
    );
    // Must also have the basic bucket
    assert.ok(
      relay.includes('widgetRateLimitMap'),
      'Basic must have its own rate limit map (widgetRateLimitMap)',
    );
    // Verify they are different variables
    assert.notEqual(
      relay.indexOf('proWidgetRateLimitMap'),
      relay.indexOf('widgetRateLimitMap'),
      'PRO and basic must use separate rate limit maps',
    );
  });

  it('x-pro-key header is read for PRO auth', () => {
    assert.ok(
      relay.includes("req.headers['x-pro-key']") || relay.includes('x-pro-key'),
      "Handler must read req.headers['x-pro-key'] for PRO auth",
    );
  });

  it('PRO request rejected with 403 when x-pro-key is wrong', () => {
    assert.ok(
      relay.includes('getWidgetAgentProvidedProKey'),
      'getWidgetAgentProvidedProKey function must be defined',
    );
    // The PRO key comparison is near the 403 rejection — find it directly
    const keyCompareIdx = relay.indexOf('!safeTokenEquals(providedProKey, PRO_WIDGET_KEY)');
    assert.ok(keyCompareIdx !== -1, 'PRO key comparison must be present');
    const region = relay.slice(keyCompareIdx, keyCompareIdx + 200);
    assert.ok(region.includes('403'), 'Wrong PRO key must return 403');
  });

  it('invalid tier value rejected with 400', () => {
    assert.ok(
      relay.includes("tier !== 'basic' && tier !== 'pro'") ||
      relay.includes("!['basic', 'pro'].includes(tier)") ||
      (relay.includes("tier === 'pro'") && relay.includes('400')),
      'Invalid tier must be rejected with 400',
    );
  });

  it('health endpoint includes proKeyConfigured boolean', () => {
    const healthIdx = relay.indexOf('getWidgetAgentStatus');
    assert.ok(healthIdx !== -1, 'getWidgetAgentStatus not found');
    const region = relay.slice(healthIdx, healthIdx + 400);
    assert.ok(
      region.includes('proKeyConfigured'),
      'Health/status response must include proKeyConfigured field',
    );
  });

  it('PRO uses claude-sonnet model (not haiku)', () => {
    assert.ok(
      relay.includes('claude-sonnet'),
      'PRO tier must use claude-sonnet model',
    );
  });

  it('PRO max_tokens is 8192', () => {
    // maxTokens is set via isPro ternary, then passed to max_tokens
    assert.ok(
      relay.includes('isPro ? 8192') || relay.includes('isPro?8192') || relay.includes('8192'),
      'PRO max_tokens must be 8192',
    );
    const tokenMatch = relay.match(/maxTokens\s*=\s*isPro\s*\?\s*8192/) || relay.match(/isPro\s*\?\s*8192/);
    assert.ok(tokenMatch, 'maxTokens must be set to 8192 when isPro');
  });

  it('WIDGET_PRO_SYSTEM_PROMPT exists and forbids DOCTYPE/html wrappers', () => {
    assert.ok(
      relay.includes('WIDGET_PRO_SYSTEM_PROMPT'),
      'WIDGET_PRO_SYSTEM_PROMPT constant must be defined',
    );
    // Use lastIndexOf to find the constant definition (not earlier references/usages)
    const promptIdx = relay.lastIndexOf('WIDGET_PRO_SYSTEM_PROMPT');
    const promptRegion = relay.slice(promptIdx, promptIdx + 2000);
    // PRO system prompt must instruct "body only" (no full page generation)
    assert.ok(
      promptRegion.includes('body') || promptRegion.includes('<body>'),
      'PRO system prompt must instruct generating body content only',
    );
  });

  it('PRO system prompt allows cdn.jsdelivr.net for Chart.js', () => {
    // Use lastIndexOf to find the constant definition
    const promptIdx = relay.lastIndexOf('WIDGET_PRO_SYSTEM_PROMPT');
    const promptRegion = relay.slice(promptIdx, promptIdx + 6000);
    assert.ok(
      promptRegion.includes('cdn.jsdelivr.net') || promptRegion.includes('chart.js') || promptRegion.includes('Chart.js'),
      'PRO system prompt must mention cdn.jsdelivr.net/Chart.js as allowed CDN',
    );
  });
});

// ---------------------------------------------------------------------------
// 12. PRO widget — store and sanitizer
// ---------------------------------------------------------------------------
describe('PRO widget — store and sanitizer', () => {
  const store = src('src/services/widget-store.ts');
  const san = src('src/utils/widget-sanitizer.ts');
  const sandbox = src('public/wm-widget-sandbox.html');

  it('MAX_HTML_CHARS_PRO is 80000', () => {
    const match = store.match(/MAX_HTML_CHARS_PRO\s*=\s*([\d_]+)/);
    assert.ok(match, 'MAX_HTML_CHARS_PRO constant not found');
    const val = Number(match[1].replace(/_/g, ''));
    assert.equal(val, 80000, 'MAX_HTML_CHARS_PRO must be 80,000');
  });

  it('PRO auth migrates wm-pro-key to an HttpOnly session instead of storing it', () => {
    const browserKeySession = src('src/services/browser-key-session.ts');
    assert.ok(
      store.includes("'wm-pro-key'"),
      "isProWidgetEnabled must know the legacy 'wm-pro-key' name for migration",
    );
    assert.ok(
      store.includes('isProWidgetEnabled'),
      'isProWidgetEnabled function must be exported',
    );
    assert.ok(
      store.includes('migrateLegacyKeysToHttpOnlySession') &&
        browserKeySession.includes('establishWmKeySession'),
      'PRO key writes must go through the server session endpoint',
    );
    assert.ok(
      !/localStorage\.setItem\(['"]wm-pro-key['"]/.test(store),
      'wm-pro-key must not be written to localStorage',
    );
    assert.ok(
      !/document\.cookie\s*=.*wm-pro-key.*encodeURIComponent\(.*key/s.test(store),
      'wm-pro-key must not be written to a JS-readable cookie',
    );
  });

  it('user identity migrates legacy wm-pro-key instead of reading localStorage directly', () => {
    const identity = src('src/services/user-identity.ts');
    assert.ok(
      identity.includes('readLegacySessionKey') && identity.includes('migrateLegacyKeysToHttpOnlySession'),
      'user-identity must route legacy wm-pro-key through the HttpOnly migration helper',
    );
    assert.ok(
      !/localStorage\.getItem\(['"]wm-pro-key['"]/.test(identity),
      'user-identity must not read wm-pro-key directly from localStorage',
    );
  });

  it('PRO HTML stored in separate wm-pro-html-{id} key', () => {
    assert.ok(
      store.includes('wm-pro-html-'),
      "PRO HTML must be stored in 'wm-pro-html-{id}' separate localStorage key",
    );
  });

  it('loadWidgets hydrates PRO HTML from separate key', () => {
    const loadIdx = store.indexOf('function loadWidgets');
    assert.ok(loadIdx !== -1, 'loadWidgets not found');
    const loadBody = store.slice(loadIdx, loadIdx + 600);
    assert.ok(
      loadBody.includes('proHtml') || loadBody.includes('wm-pro-html'),
      'loadWidgets must read PRO HTML from separate key',
    );
  });

  it("loadWidgets drops PRO entry when wm-pro-html-{id} is missing", () => {
    const loadIdx = store.indexOf('function loadWidgets');
    const loadBody = store.slice(loadIdx, loadIdx + 600);
    assert.ok(
      loadBody.includes('continue') || loadBody.includes('skip'),
      'loadWidgets must skip/drop PRO entries with missing HTML key',
    );
  });

  it('saveWidget for PRO uses raw localStorage.setItem (not saveToStorage helper)', () => {
    const saveIdx = store.indexOf('function saveWidget');
    assert.ok(saveIdx !== -1, 'saveWidget not found');
    const saveBody = store.slice(saveIdx, saveIdx + 800);
    assert.ok(
      saveBody.includes('localStorage.setItem'),
      'PRO saveWidget must use raw localStorage.setItem for atomicity-safe writes',
    );
  });

  it('saveWidget for PRO rolls back HTML key if metadata write fails', () => {
    const saveIdx = store.indexOf('function saveWidget');
    const saveBody = store.slice(saveIdx, saveIdx + 800);
    assert.ok(
      saveBody.includes('removeItem') || saveBody.includes('rollback'),
      'saveWidget must rollback (removeItem) PRO HTML key if metadata write throws',
    );
  });

  it('deleteWidget removes wm-pro-html-{id} key', () => {
    const deleteIdx = store.indexOf('function deleteWidget');
    assert.ok(deleteIdx !== -1, 'deleteWidget not found');
    const deleteBody = store.slice(deleteIdx, deleteIdx + 400);
    assert.ok(
      deleteBody.includes('wm-pro-html') || deleteBody.includes('proHtmlKey'),
      'deleteWidget must also remove the wm-pro-html-{id} key',
    );
  });

  it('wrapProWidgetHtml returns iframe with sandbox="allow-scripts" only', () => {
    assert.ok(san.includes('wrapProWidgetHtml'), 'wrapProWidgetHtml must be exported');
    // Use 1500 chars to cover the full function body including the long CSP meta tag
    const fnIdx = san.indexOf('wrapProWidgetHtml');
    const fnBody = san.slice(fnIdx, fnIdx + 1500);
    assert.ok(
      fnBody.includes('sandbox="allow-scripts"') || fnBody.includes("sandbox='allow-scripts'"),
      'iframe sandbox must be exactly "allow-scripts" — no allow-same-origin',
    );
    assert.ok(
      !fnBody.includes('allow-same-origin'),
      'sandbox must NOT include allow-same-origin',
    );
  });

  it('widget document builder places CSP as first head child (client-owned skeleton)', () => {
    assert.ok(
      san.includes('Content-Security-Policy'),
      'widget sanitizer must embed CSP in the document head',
    );
    // CSP meta should come before any style tag
    const cspPos = san.indexOf('Content-Security-Policy');
    const stylePos = san.indexOf('<style>');
    assert.ok(
      cspPos < stylePos,
      'CSP meta must appear before <style> in the generated HTML skeleton',
    );
  });

  it('widget document builder CSP restricts connect-src to cdn.jsdelivr.net only', () => {
    assert.ok(
      san.includes('connect-src https://cdn.jsdelivr.net'),
      'CSP connect-src must allow only cdn.jsdelivr.net (for Chart.js source maps) and nothing else',
    );
    assert.ok(
      !san.includes("connect-src 'none'") && !san.includes('connect-src *'),
      'CSP connect-src must not be wildcard or none',
    );
  });

  it('wrapProWidgetHtml uses sandbox page src (not srcdoc) for CSP isolation', () => {
    const fnIdx = san.indexOf('wrapProWidgetHtml');
    const fnBody = san.slice(fnIdx, fnIdx + 500);
    assert.ok(
      fnBody.includes('wm-widget-sandbox.html'),
      'wrapProWidgetHtml must load the dedicated sandbox page (not srcdoc) to get its own CSP',
    );
    assert.ok(
      !fnBody.includes('srcdoc'),
      'wrapProWidgetHtml must NOT use srcdoc — srcdoc inherits parent CSP',
    );
  });

  it('PRO widget iframe uses nonce handshake before posting HTML', () => {
    assert.ok(
      san.includes('data-wm-token') && san.includes('wm-widget-ready'),
      'parent must mint a per-widget token and wait for sandbox readiness',
    );
    assert.ok(
      san.includes('event.source !== iframe.contentWindow'),
      'parent must bind ready messages to the mounted iframe window',
    );
    assert.ok(
      san.includes('event.data.id !== mounted.id')
        && san.includes('event.data.token !== mounted.token'),
      'parent must verify ready message id and token before sending HTML',
    );
    assert.ok(
      sandbox.includes('e.source !== window.parent') && sandbox.includes('e.data.token !== widgetToken'),
      'sandbox must only accept HTML from its parent with the expected token',
    );
  });

  it('PRO widget postMessage targetOrigins match the opaque sandbox model', () => {
    const parentDelivery = san.match(
      /iframe\.contentWindow\?\.postMessage\(\s*\{[\s\S]*?type:\s*['"]wm-html['"][\s\S]*?\},\s*(['"])\*\1,\s*\)/,
    );
    assert.ok(
      parentDelivery,
      'parent-to-sandbox HTML delivery must use "*" because sandbox="allow-scripts" gives the iframe an opaque origin',
    );
    assert.ok(
      san.includes('origin is opaque') && san.includes('per-widget id/token'),
      'wildcard targetOrigin must be documented as required after source and nonce gating',
    );

    const readyDelivery = sandbox.match(
      /window\.parent\.postMessage\(\s*\{[\s\S]*?type:\s*['"]wm-widget-ready['"][\s\S]*?\},\s*parentOrigin,\s*\)/,
    );
    assert.ok(
      readyDelivery,
      'sandbox-to-parent readiness must target the parsed parentOrigin, not a wildcard',
    );
    assert.ok(
      !/window\.parent\.postMessage\(\s*\{[\s\S]*?type:\s*['"]wm-widget-ready['"][\s\S]*?\},\s*(['"])\*\1/.test(sandbox),
      'sandbox readiness postMessage must not use wildcard targetOrigin',
    );
  });

  it('widget sandbox allows approved Vercel previews and rejects lookalike origins', () => {
    assert.ok(
      sandbox.includes("url.hostname === 'worldmonitor.app'")
        && sandbox.includes("url.hostname.endsWith('.worldmonitor.app')"),
      'sandbox must parse hostname and allow the worldmonitor.app apex/subdomains only',
    );
    assert.ok(
      !sandbox.includes("endsWith('worldmonitor.app')") && !sandbox.includes('endsWith("worldmonitor.app")'),
      'sandbox must not use raw suffix checks that allow evilworldmonitor.app',
    );
    // The sandbox must source allowed Vercel team slugs from a single named
    // list — keeps the security invariant (team-slug gating) visible and
    // makes teammate-slug additions a one-line change rather than a regex
    // rewrite that could accidentally widen the match.
    const teamListMatch = sandbox.match(
      /var\s+ALLOWED_VERCEL_TEAM_SLUGS\s*=\s*\[([^\]]*)\];/,
    );
    assert.ok(teamListMatch, 'sandbox must declare ALLOWED_VERCEL_TEAM_SLUGS as a literal array');
    const slugs = teamListMatch[1]
      .split(',')
      .map((s) => s.trim().replace(/^['"]|['"]$/g, ''))
      .filter(Boolean);
    assert.ok(slugs.includes('eliewm'), 'project-owner team slug must remain in the allowlist');
    for (const slug of slugs) {
      assert.match(slug, /^[a-z0-9-]+$/, `team slug "${slug}" must be url-safe`);
    }
    // Reconstruct the actual match function and exercise it against the
    // production slug list — this is what protects against regex drift
    // when teammate slugs are added later. The team slug is the LAST hostname
    // segment before .vercel.app (eliewm team scope); keep this shape in lock-
    // step with isAllowedVercelPreview in public/wm-widget-sandbox.html.
    const matchesAllowedTeam = (hostname) =>
      slugs.some((team) =>
        new RegExp('^worldmonitor-[a-z0-9-]+-' + team + '\\.vercel\\.app$').test(hostname),
      );
    assert.equal(matchesAllowedTeam('worldmonitor-git-feature-eliewm.vercel.app'), true);
    assert.equal(matchesAllowedTeam('worldmonitor-abc123-eliewm.vercel.app'), true);
    assert.equal(matchesAllowedTeam('worldmonitor-feature-attacker.vercel.app'), false);
    assert.equal(matchesAllowedTeam('worldmonitor-git-feature-eliewm.vercel.app.evil.com'), false);
    assert.equal(matchesAllowedTeam('worldmonitor-feature-xeliewm.vercel.app'), false);
    assert.equal(matchesAllowedTeam('evilworldmonitor.app'), false);
    // The retired personal scope (worldmonitor-*-elie-<hash>) must no longer match.
    assert.equal(matchesAllowedTeam('worldmonitor-feature-elie-abc123.vercel.app'), false);
    // A teammate slug added to the list must extend coverage WITHOUT
    // matching look-alike teams whose slug merely starts with the same
    // letters.
    const withTeammate = ['eliewm', 'kieran'];
    const matchesWithTeammate = (hostname) =>
      withTeammate.some((team) =>
        new RegExp('^worldmonitor-[a-z0-9-]+-' + team + '\\.vercel\\.app$').test(hostname),
      );
    assert.equal(matchesWithTeammate('worldmonitor-feature-kieran.vercel.app'), true);
    assert.equal(matchesWithTeammate('worldmonitor-feature-kieranfake.vercel.app'), false);
  });

  it('widget sandbox behavior accepts Vercel previews and blocks spoofed parents', () => {
    const script = sandbox.match(/<script>\n([\s\S]*)\n<\/script>/)?.[1];
    assert.ok(script, 'sandbox inline script not found');

    function runSandbox(referrer) {
      const readyMessages = [];
      const writes = [];
      const listeners = new Map();
      const parent = {
        postMessage(payload, targetOrigin) {
          readyMessages.push({ payload, targetOrigin });
        },
      };
      const context = {
        URL,
        URLSearchParams,
        document: {
          referrer,
          open() {},
          write(html) {
            writes.push(html);
          },
          close() {},
        },
        window: {
          location: { hash: '#id=wm-1&token=test-token' },
          parent,
          addEventListener(type, listener) {
            listeners.set(type, listener);
          },
        },
      };
      vm.runInNewContext(script, context);
      const message = listeners.get('message');
      assert.equal(typeof message, 'function', 'message listener must be registered');
      return { parent, readyMessages, writes, message };
    }

    const allowed = runSandbox('https://worldmonitor-git-feature-eliewm.vercel.app/dashboard');
    assert.equal(allowed.readyMessages.length, 1);
    assert.equal(allowed.readyMessages[0].targetOrigin, 'https://worldmonitor-git-feature-eliewm.vercel.app');
    assert.deepEqual(JSON.parse(JSON.stringify(allowed.readyMessages[0].payload)), {
      type: 'wm-widget-ready',
      id: 'wm-1',
      token: 'test-token',
    });
    allowed.message({
      data: { type: 'wm-html', id: 'wm-1', token: 'test-token', html: '<p>ok</p>' },
      origin: 'https://worldmonitor-git-feature-eliewm.vercel.app',
      source: allowed.parent,
    });
    assert.deepEqual(allowed.writes, ['<p>ok</p>']);

    const spoofed = runSandbox('https://worldmonitor-git-feature-eliewm.vercel.app.evil.com/');
    assert.deepEqual(spoofed.readyMessages, []);
    spoofed.message({
      data: { type: 'wm-html', id: 'wm-1', token: 'test-token', html: '<p>bad</p>' },
      origin: 'https://worldmonitor-git-feature-eliewm.vercel.app.evil.com',
      source: spoofed.parent,
    });
    assert.deepEqual(spoofed.writes, []);
  });

  it('PRO widget message listener has AbortController cleanup wired to iframe removal', () => {
    // P1 (greptile #3912): the global `message` listener registered by
    // mountProWidget would otherwise retain a strong reference to the
    // iframe (and its ~80 KB HTML payload) for the lifetime of the page,
    // even after the iframe is removed from the DOM — a real leak in any
    // dashboard session that adds/removes widgets repeatedly. The fix is
    // an AbortController per iframe + a MutationObserver `removedNodes`
    // pass that calls `unmountProWidget`, which aborts the listener and
    // clears every per-iframe WeakMap entry.
    assert.ok(
      san.includes('iframeAbortStore') && san.includes('new AbortController()'),
      'mountProWidget must create an AbortController per iframe and store it',
    );
    assert.ok(
      san.includes("{ signal: controller.signal }"),
      'message listener must be registered with the AbortController signal so abort() removes it',
    );
    assert.ok(
      san.includes('function unmountProWidget') && san.includes('controller.abort')
        || (san.includes('function unmountProWidget') && san.includes('iframeAbortStore.get(iframe)?.abort()')),
      'unmountProWidget must abort the controller (tearing down the listener)',
    );
    assert.ok(
      san.includes('iframeAbortStore.delete(iframe)')
        && san.includes('iframeTokenStore.delete(iframe)')
        && san.includes('iframeHtmlStore.delete(iframe)'),
      'unmountProWidget must clear every per-iframe WeakMap entry to release the HTML payload',
    );
    assert.ok(
      san.includes('mut.removedNodes') && san.includes('unmountProWidget'),
      'MutationObserver must scan removedNodes and call unmountProWidget so the cleanup actually fires when widgets are removed',
    );
  });

  it('PRO widget re-deliveries are rate-limited to bound document.write storms', () => {
    // P2 (greptile #3912): a malicious widget that re-reads its token from
    // window.location.hash and re-posts wm-widget-ready could trigger an
    // unbounded document.write loop (parent responds → write replaces doc
    // → new doc re-posts ready → parent responds again). Rate-limiting
    // deliveries to once per second per iframe is the smallest fix that
    // bounds the loop while preserving legitimate drag/drop re-navigation
    // (which is human-paced and trivially clears the floor). Greptile's
    // suggested verbatim fix (delete iframeTokenStore after first delivery)
    // would break the documented re-navigation use case at the call site,
    // so we keep the token alive and gate on time instead.
    assert.ok(
      san.includes('MIN_DELIVERY_INTERVAL_MS') && san.includes('iframeLastDeliveryMs'),
      'must declare a per-iframe last-delivery timestamp store and a minimum interval',
    );
    const intervalMatch = san.match(/MIN_DELIVERY_INTERVAL_MS\s*=\s*(\d+)/);
    assert.ok(intervalMatch, 'MIN_DELIVERY_INTERVAL_MS must be a numeric literal');
    const interval = Number(intervalMatch[1]);
    assert.ok(
      interval >= 500 && interval <= 5000,
      `MIN_DELIVERY_INTERVAL_MS must be between 500ms and 5s (got ${interval}) — too low fails to bound a loop, too high breaks drag/drop`,
    );
    assert.ok(
      san.includes('now - last < MIN_DELIVERY_INTERVAL_MS'),
      'message handler must return early when called within the throttle window',
    );
    assert.ok(
      san.includes('iframeLastDeliveryMs.set(iframe, now)'),
      'message handler must record the delivery time so the next call is throttled',
    );
  });

  it('widget document builder injects panel CSS classes for design-system alignment', () => {
    assert.ok(san.includes('.panel-header'), 'must define .panel-header');
    assert.ok(san.includes('.panel-title'), 'must define .panel-title');
    assert.ok(san.includes('.panel-tabs'), 'must define .panel-tabs');
    assert.ok(san.includes('.panel-tab'), 'must define .panel-tab');
    assert.ok(san.includes('.disp-stats-grid'), 'must define .disp-stats-grid');
    assert.ok(san.includes('.disp-stat-box'), 'must define .disp-stat-box');
    assert.ok(san.includes('--accent'), 'must define --accent CSS variable');
  });

  it('widget document builder injects Chart.js from jsdelivr so new Chart() is available', () => {
    assert.ok(
      san.includes('cdn.jsdelivr.net') && san.includes('chart.js'),
      'widget sanitizer must inject Chart.js CDN script so widgets can call new Chart(...)',
    );
    // Script must appear before <body> so Chart is defined when body scripts run
    const scriptPos = san.indexOf('chart.js');
    const bodyPos = san.indexOf('<body>');
    assert.ok(
      scriptPos < bodyPos,
      'Chart.js script tag must be in <head>, before <body>',
    );
  });
});

// ---------------------------------------------------------------------------
// 13. PRO widget — modal and layout
// ---------------------------------------------------------------------------
describe('PRO widget — modal and layout integration', () => {
  const modal = src('src/components/WidgetChatModal.ts');
  const layout = src('src/app/panel-layout.ts');

  it('modal sends tier in request body', () => {
    const bodyIdx = modal.indexOf('JSON.stringify');
    assert.ok(bodyIdx !== -1);
    const bodyRegion = modal.slice(bodyIdx, bodyIdx + 400);
    assert.ok(bodyRegion.includes('tier'), "Request body must include 'tier' field");
  });

  it('modal sends X-Pro-Key header for PRO requests', () => {
    assert.ok(
      modal.includes('X-Pro-Key'),
      'Modal must send X-Pro-Key header for PRO tier requests',
    );
  });

  it('modal uses 120s timeout for PRO (vs 60s basic)', () => {
    assert.ok(
      modal.includes('120_000') || modal.includes('120000'),
      'PRO modal timeout must be 120 seconds',
    );
    assert.ok(
      modal.includes('60_000') || modal.includes('60000'),
      'Basic modal timeout must still be 60 seconds',
    );
  });

  it('modal shows preflightProUnavailable when proKeyConfigured is false', () => {
    assert.ok(
      modal.includes('proKeyConfigured') || modal.includes('preflightProUnavailable'),
      'Modal must handle proKeyConfigured=false from health endpoint',
    );
  });

  it('pendingSaveSpec includes tier field', () => {
    assert.ok(
      modal.includes('pendingSaveSpec'),
      'Modal must use pendingSaveSpec before saving',
    );
    // tier should be part of the spec being saved
    const specIdx = modal.indexOf('pendingSaveSpec');
    const specRegion = modal.slice(specIdx, specIdx + 200);
    assert.ok(
      specRegion.includes('tier') || modal.includes("tier: currentTier"),
      'pendingSaveSpec must include tier field',
    );
  });

  it('PRO example chips defined (separate from basic examples)', () => {
    assert.ok(
      modal.includes('PRO_EXAMPLE_PROMPT_KEYS'),
      'Modal must define PRO_EXAMPLE_PROMPT_KEYS for PRO example chips',
    );
  });

  it('layout has PRO create button when hasPremiumAccess', () => {
    assert.ok(
      layout.includes('hasPremiumAccess') || layout.includes('isProUser'),
      'panel-layout must import/call hasPremiumAccess (or isProUser)',
    );
    assert.ok(
      layout.includes('ai-widget-block-pro'),
      'panel-layout must render PRO create button (.ai-widget-block-pro)',
    );
  });

  it('layout PRO button opens modal with tier: pro', () => {
    const proButtonIdx = layout.indexOf('ai-widget-block-pro');
    assert.ok(proButtonIdx !== -1);
    // Use 1200 chars to cover the full button element including the click handler
    const proButtonRegion = layout.slice(proButtonIdx, proButtonIdx + 1200);
    assert.ok(
      proButtonRegion.includes("tier: 'pro'") || proButtonRegion.includes("tier:'pro'") || proButtonRegion.includes('"pro"'),
      "PRO button must open modal with tier: 'pro'",
    );
  });
});

// ---------------------------------------------------------------------------
// 14. PRO widget — i18n and CSS
// ---------------------------------------------------------------------------
describe('PRO widget — i18n keys and CSS', () => {
  const en = JSON.parse(src('src/locales/en.json'));
  const css = src('src/styles/main.css');

  const PRO_REQUIRED_KEYS = [
    'createInteractive',
    'proBadge',
    'preflightProUnavailable',
  ];

  for (const key of PRO_REQUIRED_KEYS) {
    it(`widgets.${key} is defined and non-empty`, () => {
      assert.ok(
        en.widgets && typeof en.widgets[key] === 'string' && en.widgets[key].length > 0,
        `en.json must have non-empty widgets.${key}`,
      );
    });
  }

  it('widgets.proExamples has all 4 example keys', () => {
    const exKeys = ['interactiveChart', 'sortableTable', 'animatedCounters', 'tabbedComparison'];
    for (const key of exKeys) {
      assert.ok(
        en.widgets?.proExamples?.[key] && en.widgets.proExamples[key].length > 0,
        `en.json must have non-empty widgets.proExamples.${key}`,
      );
    }
  });

  it('.widget-pro-badge CSS class defined', () => {
    assert.ok(
      css.includes('.widget-pro-badge'),
      'CSS must define .widget-pro-badge class for PRO pill badge',
    );
  });

  it('.wm-widget-pro iframe CSS sets 400px height', () => {
    assert.ok(
      css.includes('.wm-widget-pro'),
      'CSS must target .wm-widget-pro for PRO iframe container',
    );
    const proIdx = css.indexOf('.wm-widget-pro');
    const proRegion = css.slice(proIdx, proIdx + 300);
    assert.ok(
      proRegion.includes('400px') || css.includes('400px'),
      'PRO iframe must have 400px height defined in CSS',
    );
  });
});

// ---------------------------------------------------------------------------
// PRO widget — edge-proxy auth (Convex entitlement fallback for paid users)
// ---------------------------------------------------------------------------
//
// Dodo webhook does NOT sync Clerk publicMetadata.plan, so a paying subscriber's
// Clerk session.role stays 'free' indefinitely. The edge proxy at
// api/widget-agent.ts must accept EITHER Clerk role==='pro' OR Convex
// entitlement tier>=1, mirroring server/_shared/premium-check.ts::isCallerPremium
// and server/gateway.ts:521-526. A regression here surfaces as a misleading
// "PRO key rejected. Update wm-pro-key…" 403 in the modal — the user has no
// tester key, so the suggested action is a dead end.
describe('widget-agent edge proxy — Convex entitlement fallback', () => {
  const edge = src('api/widget-agent.ts');

  it('imports getEntitlements from server/_shared/entitlement-check', () => {
    assert.ok(
      /import\s*\{[^}]*\bgetEntitlements\b[^}]*\}\s*from\s*['"][^'"]*entitlement-check['"]/.test(edge),
      'api/widget-agent.ts must import getEntitlements for Dodo entitlement fallback',
    );
  });

  it('Clerk JWT path falls back to Convex entitlement when role !== "pro"', () => {
    const bearerIdx = edge.indexOf("authHeader?.startsWith('Bearer ')");
    assert.ok(bearerIdx !== -1, 'Bearer-token branch not found in api/widget-agent.ts');
    // Constrain the search to the bearer-token branch only.
    const region = edge.slice(bearerIdx, bearerIdx + 2000);
    assert.ok(
      region.includes('getEntitlements(session.userId)'),
      'Bearer-token branch must call getEntitlements(session.userId) when Clerk role !== "pro"',
    );
    assert.ok(
      /features\.tier\s*>=\s*1/.test(region),
      'Bearer-token branch must accept Convex entitlement tier >= 1',
    );
  });

  it('does NOT 403 immediately on session.role !== "pro"', () => {
    // The legacy shape `if (session.role !== 'pro') return 403` is the bug —
    // it would short-circuit before the Convex fallback. Lock it out.
    assert.ok(
      !/if\s*\(\s*session\.role\s*!==\s*['"]pro['"]\s*\)\s*\{\s*return\s+json\([^}]*403/.test(edge),
      'api/widget-agent.ts must NOT 403 on session.role !== "pro" without checking Convex entitlement',
    );
  });
});

// ---------------------------------------------------------------------------
// entitlement-check — cache-write failure must NOT collapse to "no entitlement"
// ---------------------------------------------------------------------------
//
// getEntitlements() returns null on three different failure modes — Convex
// said no, Convex unreachable, and (the trap this test guards) cache-write
// failed AFTER Convex confirmed the entitlement. Once Convex returns a valid
// entitlement, an Upstash hiccup or any error inside setCachedJson must NOT
// turn that yes into a null-meaning-no — that would 403 paying customers on
// every call path this file gates, including the widget-agent fallback PR
// #3505 just added.
// ---------------------------------------------------------------------------
// widget-agent relay — error classifier (no more opaque "Agent error")
// ---------------------------------------------------------------------------
//
// The relay used to swallow ALL agent errors as a generic "Agent error" SSE
// message. With nothing in Railway logs to grep and nothing useful in the
// client, real failures (auth, rate limit, model availability, payload shape)
// were impossible to triage. Lock in the classifier so future regressions
// can't re-collapse the error surface.
describe('widget-agent relay — error classifier', () => {
  const relay = src('scripts/ais-relay.cjs');

  it('classifyWidgetAgentError function is defined', () => {
    assert.ok(
      /function\s+classifyWidgetAgentError\s*\(/.test(relay),
      'classifyWidgetAgentError(err, model) helper must exist',
    );
  });

  it('catch block routes through classifyWidgetAgentError instead of hardcoded "Agent error"', () => {
    // Find the catch block in the widget-agent handler.
    const catchIdx = relay.indexOf("classifyWidgetAgentError");
    assert.ok(catchIdx !== -1, 'classifyWidgetAgentError must be called somewhere in the relay');
    // The catch site must NOT still emit the literal "Agent error" string as
    // its primary message — the classifier covers the fallback case itself.
    // Allow the literal in the classifier's last-resort branch only.
    const handlerStart = relay.indexOf('async function handleWidgetAgentRequest');
    const handlerEnd = relay.indexOf('async function ', handlerStart + 1);
    const handlerRegion = relay.slice(handlerStart, handlerEnd > handlerStart ? handlerEnd : handlerStart + 5000);
    assert.ok(
      !/sendWidgetSSE\([^)]*'error'[^)]*'Agent error'/.test(handlerRegion),
      'catch site must NOT hardcode "Agent error" — route through classifyWidgetAgentError so the client sees actionable diagnostics',
    );
  });

  it('classifier maps Anthropic 401 to an operator-facing API-key hint', () => {
    const fnIdx = relay.indexOf('function classifyWidgetAgentError');
    const region = relay.slice(fnIdx, fnIdx + 3000);
    assert.ok(
      /status\s*===\s*401|authentication_error/.test(region),
      'Classifier must branch on status 401 / authentication_error',
    );
    assert.ok(
      /ANTHROPIC_API_KEY|API key/i.test(region),
      'Classifier 401 branch must hint at the env-var/credential to check',
    );
  });

  it('classifier surfaces 400 invalid_request_error with the SDK message (capped)', () => {
    const fnIdx = relay.indexOf('function classifyWidgetAgentError');
    const region = relay.slice(fnIdx, fnIdx + 3000);
    assert.ok(
      /status\s*===\s*400|invalid_request_error/.test(region),
      'Classifier must branch on status 400 / invalid_request_error',
    );
    assert.ok(
      /Invalid request to AI backend/.test(region),
      'Classifier must include human-readable phrasing for invalid-request errors',
    );
  });

  it('classifier scrubs Claude API keys from any fallback message', () => {
    const fnIdx = relay.indexOf('function classifyWidgetAgentError');
    const region = relay.slice(fnIdx, fnIdx + 3000);
    assert.ok(
      /sk-(?:ant-)?\[A-Za-z0-9_-\]\{20,?\}/.test(region) || /sk-\(\?:ant-\)\?\[A-Za-z0-9_-\]/.test(region),
      'Classifier fallback must redact `sk-…` / `sk-ant-…` API keys before surfacing the message',
    );
    assert.ok(
      /\[REDACTED\]/.test(region),
      'Classifier must replace scrubbed token with a [REDACTED] sentinel',
    );
  });

  it('classifier scrubs API keys in the 400 branch (defence-in-depth on every rawMsg interpolation)', () => {
    // Round-trip the function for runtime check: the 400 message must redact a Claude key.
    const fnMatch = relay.match(/function classifyWidgetAgentError[\s\S]*?\n\}/);
    assert.ok(fnMatch, 'classifyWidgetAgentError function not extractable');
    const fn = new Function(`${fnMatch[0]}; return classifyWidgetAgentError;`)();
    const out = fn(
      { status: 400, error: { type: 'invalid_request_error' }, message: 'bad header sk-ant-api03-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA was rejected' },
      'claude-sonnet-4-6',
    );
    assert.ok(
      /\[REDACTED\]/.test(out),
      `400 branch must scrub sk-(ant-)? tokens before surfacing rawMsg, got: ${out}`,
    );
    assert.ok(
      !/sk-ant-api03-AAAAAAAAAA/.test(out),
      '400 branch must not leak the raw API key in any form',
    );
  });

  it('classifier handles Anthropic APITimeoutError (status 408) — does not fall through to fallback', () => {
    const fnMatch = relay.match(/function classifyWidgetAgentError[\s\S]*?\n\}/);
    assert.ok(fnMatch, 'classifyWidgetAgentError function not extractable');
    const fn = new Function(`${fnMatch[0]}; return classifyWidgetAgentError;`)();
    // Real Anthropic Node SDK timeout shape: name='APITimeoutError', status=408
    const apiTimeout = fn({ name: 'APITimeoutError', status: 408, message: 'Request timeout.' }, 'claude-sonnet-4-6');
    assert.equal(apiTimeout, 'AI backend timed out', 'APITimeoutError must classify as timed-out, not fallback');
    // AbortSignal.timeout() shape (DOMException): name='TimeoutError'
    const abortTimeout = fn({ name: 'TimeoutError', message: 'The operation timed out.' }, 'claude-sonnet-4-6');
    assert.equal(abortTimeout, 'AI backend timed out', 'AbortSignal TimeoutError must also classify as timed-out');
    // Bare 408 status (some HTTP layers expose only the code) — same branch.
    const bare408 = fn({ status: 408 }, 'claude-sonnet-4-6');
    assert.equal(bare408, 'AI backend timed out', 'Bare status 408 must classify as timed-out');
  });

  it('400 branch does NOT pre-empt timeout: APITimeoutError-with-status-400 stays a timeout (rare but defensive)', () => {
    // Belt-and-suspenders: the timeout check sits BEFORE the 400 branch, so
    // an APITimeoutError tagged with status 400 (defensive against future SDK
    // shape changes) still classifies as a timeout, not "Invalid request".
    const fnMatch = relay.match(/function classifyWidgetAgentError[\s\S]*?\n\}/);
    const fn = new Function(`${fnMatch[0]}; return classifyWidgetAgentError;`)();
    const out = fn({ name: 'APITimeoutError', status: 400, message: 'timeout' }, 'claude-sonnet-4-6');
    assert.equal(out, 'AI backend timed out', 'APITimeoutError must beat the 400 branch regardless of status');
  });

  it('handler logs structured error context with status + type + model', () => {
    const handlerStart = relay.indexOf('async function handleWidgetAgentRequest');
    const handlerEnd = relay.indexOf('async function ', handlerStart + 1);
    const region = relay.slice(handlerStart, handlerEnd > handlerStart ? handlerEnd : handlerStart + 8000);
    // Look for the structured console.error inside the catch.
    assert.ok(
      /console\.error\([^)]*\[widget-agent\][^)]*Error/.test(region),
      'Catch must log a `[widget-agent] Error:` line for Railway operators to grep',
    );
    assert.ok(
      /\bstatus\b/.test(region) && /\btype\b/.test(region) && /\bmodel\b/.test(region),
      'Structured error log must include status + type + model so Railway logs are diagnosable without server-side reproduction',
    );
  });

  it('toolCallCount is declared OUTSIDE the try whose catch reads it (scoping regression guard)', () => {
    // Bug history: an earlier revision declared `let toolCallCount = 0` INSIDE the
    // outer agent-loop try block but read it from the catch. JavaScript `let`/`const`
    // is block-scoped, so the catch's structured log threw a ReferenceError every
    // time, which the inner log-try then caught and emitted the useless
    // "[widget-agent] Error (log-failed)" fallback — defeating the entire
    // diagnostic value of this PR. Lock the declaration position.
    const handlerStart = relay.indexOf('async function handleWidgetAgentRequest');
    assert.ok(handlerStart !== -1, 'handler not found');
    const handlerEnd = relay.indexOf('async function ', handlerStart + 1);
    const region = relay.slice(handlerStart, handlerEnd > handlerStart ? handlerEnd : handlerStart + 12000);

    const declIdx = region.indexOf('let toolCallCount');
    assert.ok(declIdx !== -1, 'toolCallCount declaration not found');

    // The outer agent-loop try is the one whose first statement imports the
    // Anthropic SDK. Anchor on that specific shape so we ignore unrelated
    // try/catch blocks (request-body parse, search-tool fetch, log-fallback).
    const outerTryMatch = region.match(/try\s*\{\s*\n\s*const\s*\{\s*default:\s*Anthropic/);
    assert.ok(outerTryMatch, 'Outer agent-loop try block not found by anchor');
    const outerTryIdx = outerTryMatch.index;

    assert.ok(
      declIdx < outerTryIdx,
      `toolCallCount (declared at ${declIdx}) must be declared BEFORE the outer agent-loop try (try at ${outerTryIdx}). Putting it inside the try makes it inaccessible to the catch — the structured log throws ReferenceError and falls through to the useless "Error (log-failed)" fallback.`,
    );

    // Sanity-check: the catch payload still references toolCallCount, so this
    // test is actually guarding the load-bearing reference.
    assert.ok(
      /catch\s*\(\s*err[^)]*\)[\s\S]*?toolCallCount/.test(region),
      'Catch payload must still reference toolCallCount, otherwise this test is guarding nothing',
    );
  });
});

// ---------------------------------------------------------------------------
// panel-layout — Pro CTAs must re-evaluate on Convex entitlement updates
// ---------------------------------------------------------------------------
//
// "Create Interactive Widget" (proBlock) and "Connect MCP" (mcpBlock) are
// gated by applyProBlockGating(hasPremiumAccess(...)). For a paying Dodo
// subscriber whose Clerk publicMetadata.plan is never written, hasPremiumAccess
// only flips true once the Convex entitlement snapshot lands via
// onEntitlementChange — NOT via subscribeAuthState. Subscribing only to
// subscribeAuthState (the prior shape) meant the CTAs stayed display:none for
// the entire page lifetime for paying users. Lock the dual-subscription.
describe('panel-layout — Pro add-block gating reacts to entitlement updates', () => {
  const layout = src('src/app/panel-layout.ts');

  it('imports onEntitlementChange', () => {
    assert.ok(
      /import\s*\{[^}]*\bonEntitlementChange\b[^}]*\}\s*from\s*['"][^'"]*entitlements['"]/.test(layout),
      'panel-layout must import onEntitlementChange to re-evaluate Pro CTA gating on Convex snapshots',
    );
  });

  it('proBlock + mcpBlock gating subscribes to BOTH auth and entitlement changes', () => {
    // Anchor on the gating function to scope the search to its surroundings.
    const gateFnIdx = layout.indexOf('applyProBlockGating');
    assert.ok(gateFnIdx !== -1, 'applyProBlockGating not found in panel-layout');
    const region = layout.slice(gateFnIdx, gateFnIdx + 1500);
    assert.ok(
      region.includes('subscribeAuthState'),
      'Pro CTA gating must subscribe to subscribeAuthState (legacy auth-driven path)',
    );
    assert.ok(
      region.includes('onEntitlementChange'),
      'Pro CTA gating MUST subscribe to onEntitlementChange so paying Dodo users flip from hidden->visible when the Convex entitlement snapshot lands',
    );
  });

  it('teardown clears the entitlement subscription so a destroyed layout does not leak callbacks', () => {
    assert.ok(
      layout.includes('proBlockEntitlementUnsubscribe'),
      'panel-layout must hold a proBlockEntitlementUnsubscribe handle and clear it in destroy()',
    );
    // Look for the destroy() block
    const destroyIdx = layout.indexOf('destroy(): void {');
    assert.ok(destroyIdx !== -1, 'destroy() not found');
    const destroyRegion = layout.slice(destroyIdx, destroyIdx + 2000);
    assert.ok(
      destroyRegion.includes('proBlockEntitlementUnsubscribe'),
      'destroy() must invoke proBlockEntitlementUnsubscribe to avoid leaking callbacks across layout init/destroy cycles',
    );
  });
});

describe('entitlement-check — cache-write failure does not collapse confirmed entitlement', () => {
  const src_ = src('server/_shared/entitlement-check.ts');

  it('setCachedJson call is wrapped in its own try/catch', () => {
    // Find the success-path block: `if (result) { … setCachedJson(…) … return result }`
    const successIdx = src_.indexOf('if (result) {');
    assert.ok(successIdx !== -1, 'success-path "if (result)" branch not found');
    const region = src_.slice(successIdx, successIdx + 1500);

    const setIdx = region.indexOf('setCachedJson(');
    assert.ok(setIdx !== -1, 'setCachedJson call missing from success path');

    // Walk backward from setCachedJson to find the nearest enclosing `try {`
    // BEFORE the outer catch. The outer try is at the top of the function,
    // far away — we want a LOCAL try/catch around the cache write so the
    // safety property is explicit at the call site.
    const beforeSet = region.slice(0, setIdx);
    const lastTry = beforeSet.lastIndexOf('try {');
    const lastCatch = beforeSet.lastIndexOf('catch');
    assert.ok(
      lastTry !== -1 && lastTry > lastCatch,
      'setCachedJson must be inside a LOCAL try/catch within the success branch — relying on setCachedJson to swallow its own errors is fragile',
    );

    // The success-path return must come AFTER the try/catch, not inside the catch.
    const returnIdx = region.indexOf('return result', setIdx);
    assert.ok(
      returnIdx !== -1,
      '`return result` must follow the cache-write try/catch so a swallowed cache error still returns the confirmed entitlement',
    );
  });

  it('cache-write catch logs but does not return null or throw', () => {
    const successIdx = src_.indexOf('if (result) {');
    const region = src_.slice(successIdx, successIdx + 1500);
    // The catch block for cache write must NOT contain `return null` — that
    // would re-introduce the bug. It also must not rethrow.
    const cacheCatchMatch = region.match(/catch\s*\(\s*cacheErr[^)]*\)\s*\{([^}]*)\}/);
    assert.ok(cacheCatchMatch, 'cache-write catch block must be named distinctly (e.g. cacheErr) so future readers see the intent');
    const cacheCatchBody = cacheCatchMatch[1];
    assert.ok(
      !/return\s+null/.test(cacheCatchBody),
      'cache-write catch must NOT return null — a confirmed entitlement must survive cache-write failure',
    );
    assert.ok(
      !/throw\b/.test(cacheCatchBody),
      'cache-write catch must NOT rethrow — that would bubble to the outer catch and collapse to null',
    );
  });
});

describe('WidgetChatModal — preflight 403 message branches on auth mode', () => {
  const modal = src('src/components/WidgetChatModal.ts');
  const en = JSON.parse(src('src/locales/en.json'));

  it('buildWidgetAuthHeaders returns usedTesterKey flag', () => {
    assert.ok(
      modal.includes('usedTesterKey'),
      'buildWidgetAuthHeaders must report whether a tester key was used so the 403 message can branch',
    );
  });

  it('resolvePreflightMessage takes usedTesterKey and branches Clerk path on isPro', () => {
    const fnIdx = modal.indexOf('function resolvePreflightMessage');
    assert.ok(fnIdx !== -1, 'resolvePreflightMessage not found');
    const region = modal.slice(fnIdx, fnIdx + 1200);
    assert.ok(
      region.includes('usedTesterKey'),
      'resolvePreflightMessage must take usedTesterKey to branch on auth mode',
    );
    assert.ok(
      region.includes('preflightProSubscriptionRequired'),
      'Clerk-auth 403 (isPro=true) must surface preflightProSubscriptionRequired',
    );
    assert.ok(
      region.includes('preflightProRequired'),
      'Clerk-auth 403 (isPro=false, free user) must surface preflightProRequired (clean upgrade ask, no "just upgraded" language)',
    );
  });

  it('en.json defines widgets.preflightProSubscriptionRequired (just-upgraded / outage)', () => {
    assert.ok(
      typeof en.widgets?.preflightProSubscriptionRequired === 'string'
        && en.widgets.preflightProSubscriptionRequired.length > 0,
      'en.json must define widgets.preflightProSubscriptionRequired',
    );
    assert.ok(
      !/wm-pro-key/i.test(en.widgets.preflightProSubscriptionRequired),
      'preflightProSubscriptionRequired must not mention wm-pro-key — Clerk users have no tester key',
    );
  });

  it('en.json defines widgets.preflightProRequired (free-user upgrade ask, no "just upgraded" language)', () => {
    assert.ok(
      typeof en.widgets?.preflightProRequired === 'string'
        && en.widgets.preflightProRequired.length > 0,
      'en.json must define widgets.preflightProRequired',
    );
    assert.ok(
      !/wm-pro-key/i.test(en.widgets.preflightProRequired),
      'preflightProRequired must not mention wm-pro-key',
    );
    assert.ok(
      !/just upgraded|refresh the page|contact support/i.test(en.widgets.preflightProRequired),
      'preflightProRequired is for genuinely-free users — must not include "just upgraded / refresh / contact support" language',
    );
  });
});

// ---------------------------------------------------------------------------
// widget-agent edge proxy — observability for fail-closed entitlement 403s
// ---------------------------------------------------------------------------
//
// When getEntitlements returns null, callers can't tell "user genuinely not
// entitled" from "entitlement service degraded" — both shapes 403 paying users
// during a Convex/Upstash outage. Emit a structured log at the 403 site so
// on-call can grep Vercel logs and disambiguate incident vs not-entitled
// without waiting for refund tickets.
describe('widget-agent edge proxy — fail-closed observability', () => {
  const edge = src('api/widget-agent.ts');

  it('403 site emits a structured log with reason + userId + entitlementTier', () => {
    const idx = edge.indexOf("error: 'Pro subscription required'");
    assert.ok(idx !== -1, 'Pro-required 403 site not found');
    // Walk backward from the 403 to find the preceding console.warn — must
    // sit in the same allowed-check block, not in some unrelated error path.
    const before = edge.slice(Math.max(0, idx - 1500), idx);
    assert.ok(
      /console\.warn\([^)]*widget-agent[^)]*pro-required/i.test(before),
      'A console.warn naming "widget-agent" + "pro-required" must precede the 403 return',
    );
    assert.ok(before.includes('reason'), 'Structured log must include "reason" field (not_entitled vs service_unavailable)');
    assert.ok(before.includes('userId'), 'Structured log must include userId for grep/correlation');
    assert.ok(
      before.includes('service_unavailable') && before.includes('not_entitled'),
      'Structured log must distinguish service_unavailable (Convex/Redis down) from not_entitled (real free user)',
    );
  });
});
