'use strict';

// directory-search widget: references a directory-board by id and renders an
// interactive walk-up search page. Verifies the source board's entries are
// safely inlined for client-side filtering, that a missing/wrong source shows a
// friendly fallback (not a 500), and that entry/category text can't break out
// of the inlined <script> (it's set via textContent at runtime).

const test = require('node:test');
const assert = require('node:assert/strict');
const Database = require('better-sqlite3');

process.env.JWT_SECRET = 'test-secret-dirsearch';

const db = new Database(':memory:');
db.exec(`CREATE TABLE widgets (id TEXT PRIMARY KEY, widget_type TEXT, config TEXT, workspace_id TEXT);`);
const dbModulePath = require.resolve('../db/database');
require.cache[dbModulePath] = { id: dbModulePath, filename: dbModulePath, loaded: true, exports: { db } };

const express = require('express');
const widgetsRouter = require('../routes/widgets');
const app = express();
app.use('/api/widgets', widgetsRouter);
const server = app.listen(0);
let base;
test.before(async () => { await new Promise(r => server.listening ? r() : server.once('listening', r)); base = `http://127.0.0.1:${server.address().port}`; });
test.after(() => { server.close(); db.close(); });

const seed = (id, type, config) => db.prepare('INSERT INTO widgets (id, widget_type, config, workspace_id) VALUES (?,?,?,?)').run(id, type, JSON.stringify(config), 'ws1');
const fetchRender = async (id) => { const r = await fetch(`${base}/api/widgets/${id}/render`); return { status: r.status, html: await r.text() }; };

const BOARD = {
  title: 'Lincoln Warehouse',
  categories: [
    { name: 'First Floor', entries: [
      { identifier: '101', name: 'Acme Co', subtitle: 'Suite A', available: false },
      { identifier: '102', name: 'Available Unit', subtitle: '', available: true },
    ] },
    { name: 'Second Floor', entries: [
      { identifier: '201', name: 'Globex', subtitle: 'Logistics', available: false },
    ] },
  ],
};

test('directory-search renders a search page and inlines the source board entries', async () => {
  seed('board1', 'directory-board', BOARD);
  seed('search1', 'directory-search', { source_widget_id: 'board1', title: 'Find a Tenant', show_onscreen_keyboard: true });
  const { status, html } = await fetchRender('search1');
  assert.equal(status, 200);
  assert.ok(html.includes('id="q"'), 'has a search input');
  assert.ok(html.includes('id="results"'), 'has a results container');
  assert.ok(html.includes('Acme Co') && html.includes('Globex') && html.includes('101'), 'source entries embedded for client-side filtering');
  assert.ok(html.includes('Find a Tenant'), 'search title present');
});

test('show_onscreen_keyboard flag is carried into the page config', async () => {
  seed('search_kb_off', 'directory-search', { source_widget_id: 'board1', show_onscreen_keyboard: false });
  const { html } = await fetchRender('search_kb_off');
  assert.ok(html.includes('"show_onscreen_keyboard":false'), 'keyboard flag inlined (page hides the keyboard when false)');
});

test('missing source -> friendly fallback page, not a 500', async () => {
  seed('search_missing', 'directory-search', { source_widget_id: 'does-not-exist' });
  const { status, html } = await fetchRender('search_missing');
  assert.equal(status, 200);
  assert.ok(html.includes('Directory source not found'), 'friendly message shown instead of an error');
});

test('non-directory-board source -> friendly fallback page', async () => {
  seed('clockX', 'clock', {});
  seed('search_wrongtype', 'directory-search', { source_widget_id: 'clockX' });
  const { status, html } = await fetchRender('search_wrongtype');
  assert.equal(status, 200);
  assert.ok(html.includes('Directory source not found'), 'friendly message for a wrong source type');
});

test('XSS: entry/category text cannot break out of the inlined script', async () => {
  seed('board_xss', 'directory-board', {
    categories: [{
      name: '</script><script>window.__pwned=1</script>',
      entries: [{ identifier: '<img src=x onerror=alert(1)>', name: '"><b>bold</b>', subtitle: 'amp & lt < gt >', available: false }],
    }],
  });
  seed('search_xss', 'directory-search', { source_widget_id: 'board_xss' });
  const { status, html } = await fetchRender('search_xss');
  assert.equal(status, 200);
  assert.ok(!html.includes('</script><script>window.__pwned'), 'raw </script> breakout neutralized');
  assert.ok(html.includes('\\u003c/script>'), 'angle brackets escaped in the inlined JSON blob');
});

// ---- live sync: GET /:id/data.json feed the search page polls ----
const fetchData = async (id) => fetch(`${base}/api/widgets/${id}/data.json`);

test('data.json returns the source board categories, CORS-open for polling', async () => {
  const r = await fetchData('board1'); // seeded in the first test
  assert.equal(r.status, 200);
  assert.equal(r.headers.get('access-control-allow-origin'), '*', 'readable from a null-origin sandboxed iframe');
  assert.equal(r.headers.get('cache-control'), 'no-store');
  const body = await r.json();
  assert.ok(Array.isArray(body.categories) && body.categories.length === 2, 'returns the categories array');
  assert.equal(body.categories[0].entries[0].name, 'Acme Co');
});

test('data.json 404s for a missing widget (poll keeps last-good data)', async () => {
  assert.equal((await fetchData('does-not-exist')).status, 404);
});

test('data.json 404s for a non-directory-board widget', async () => {
  assert.equal((await fetchData('clockX')).status, 404); // clockX seeded earlier
});

test('search page wires the live-sync poll to its source board', async () => {
  const { html } = await fetchRender('search1');
  assert.ok(html.includes('"source_widget_id":"board1"'), 'source board id inlined into the page');
  assert.ok(html.includes('/data.json'), 'page polls the data.json feed');
});

// The on-screen keyboard must be sized against the VIEWPORT, not in fixed px. A panel's CSS
// viewport is its resolution over its density, so a 1080p screen at 240dpi presents 1280x720 CSS
// px — where a keyboard laid out for 1920x1080 ate ~37% of the height instead of ~24%. The clamp()
// maxima are the ORIGINAL fixed values, so a 1080-tall viewport must stay pixel-identical.
test('the on-screen keyboard scales with the viewport instead of using fixed pixels', async () => {
  const bid = 'kb-board', sid = 'kb-search';
  seed(bid, 'directory-board', BOARD);
  seed(sid, 'directory-search', { source_widget_id: bid });
  const { status, html } = await fetchRender(sid);
  assert.equal(status, 200);

  const keyRule = html.match(/\.key\s*\{[^}]*\}/s);
  assert.ok(keyRule, '.key rule is present');
  assert.match(keyRule[0], /height:clamp\(/, 'key height is clamped to the viewport');
  assert.match(keyRule[0], /font-size:clamp\(/, 'key font-size is clamped to the viewport');
  assert.ok(!/height:\s*56px/.test(keyRule[0]), 'no bare fixed height survives');

  // vh terms must exceed their max at 1080 tall, so existing 1080 panels render unchanged.
  const h = keyRule[0].match(/height:clamp\(([\d.]+)px,\s*([\d.]+)vh,\s*([\d.]+)px\)/);
  assert.ok(h, 'height clamp is well-formed');
  const [, hMin, hVh, hMax] = h.map(Number);
  assert.ok(hVh * 1080 / 100 >= hMax, 'at a 1080-tall viewport the height clamps to its max (no visual change)');
  assert.ok(hVh * 720 / 100 < hMax, 'at a 720-tall viewport the height actually scales down');
  assert.ok(hMin >= 30, 'keys stay tappable on very short viewports');
});

test('the narrow breakpoint no longer pins the key size back to fixed pixels', async () => {
  const bid = 'kb2-board', sid = 'kb2-search';
  seed(bid, 'directory-board', BOARD);
  seed(sid, 'directory-search', { source_widget_id: bid });
  const { html } = await fetchRender(sid);
  const mq = html.match(/@media \(max-width:700px\)\s*\{[^}]*\}[^}]*\}/s);
  assert.ok(mq, 'the narrow breakpoint still exists');
  assert.ok(!/\.key\s*\{[^}]*height:\s*\d+px/.test(mq[0]),
    'the breakpoint must not re-pin .key to a fixed height and undo the clamp');
});
