// @ts-check
/**
 * Generic SX page test runner.
 *
 * Discovers *.test.sx files next to components, parses defsuite/deftest
 * forms, and executes them as Playwright tests against a real server.
 *
 * SX test format:
 *
 *   (defsuite "name"
 *     :url "/sx/(geography.(isomorphism.streaming))"
 *     ;; :stream true          — don't wait for data-sx-ready
 *     ;; :timeout 60000        — suite-level timeout
 *
 *     (deftest "all slots resolve"
 *       (wait-for "[data-suspense='stream-fast']" :text "Fast source" :timeout 15000)
 *       (click "button")
 *       (assert-text "h1" "Streaming")))
 *
 * Primitives:
 *   (wait-for <sel> [:text t] [:visible] [:timeout ms] [:count n])
 *   (click <sel> [:text t] [:nth n])
 *   (fill <sel> <value>)
 *   (assert-text <sel> <text> [:timeout ms])
 *   (assert-not-text <sel> <text>)
 *   (assert-visible <sel> [:timeout ms])
 *   (assert-hidden <sel> [:timeout ms])
 *   (assert-count <sel> <n> [:timeout ms])
 *   (assert-no-errors)
 *   (wait <ms>)
 *   (snapshot <sel>)
 *   (assert-changed <sel>)
 */
const { test, expect } = require('playwright/test');
const { SiteServer } = require('./site-server');
const fs = require('fs');
const path = require('path');

const PROJECT_ROOT = path.resolve(__dirname, '../..');

// ---------------------------------------------------------------------------
// Discover *.test.sx files
// ---------------------------------------------------------------------------

function findTestFiles(dir, acc = []) {
  if (!fs.existsSync(dir)) return acc;
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
    const full = path.join(dir, entry.name);
    if (entry.isDirectory()) findTestFiles(full, acc);
    else if (entry.name.endsWith('.test.sx')) acc.push(full);
  }
  return acc;
}

const SEARCH_DIRS = ['sx/sx', 'shared/sx/templates'];
const testFiles = [];
for (const d of SEARCH_DIRS) findTestFiles(path.join(PROJECT_ROOT, d), testFiles);

// ---------------------------------------------------------------------------
// Minimal SX parser — just enough for test spec structure
// ---------------------------------------------------------------------------

function parseSx(src) {
  let pos = 0;

  function skip() {
    while (pos < src.length) {
      if (src[pos] === ';') { while (pos < src.length && src[pos] !== '\n') pos++; }
      else if (/\s/.test(src[pos])) pos++;
      else break;
    }
  }

  function read() {
    skip();
    if (pos >= src.length) return null;

    if (src[pos] === '(') {
      pos++;
      const list = [];
      while (true) {
        skip();
        if (pos >= src.length || src[pos] === ')') { pos++; return list; }
        list.push(read());
      }
    }

    if (src[pos] === '"') {
      pos++;
      let s = '';
      while (pos < src.length && src[pos] !== '"') {
        if (src[pos] === '\\') { pos++; s += src[pos] || ''; }
        else s += src[pos];
        pos++;
      }
      pos++;
      return { t: 's', v: s };
    }

    let tok = '';
    while (pos < src.length && !/[\s()";]/.test(src[pos])) tok += src[pos++];

    if (tok === 'true') return true;
    if (tok === 'false') return false;
    if (/^-?\d+(\.\d+)?$/.test(tok)) return Number(tok);
    if (tok[0] === ':') return { t: 'k', v: tok.slice(1) };
    return { t: 'y', v: tok };
  }

  const forms = [];
  while (pos < src.length) {
    skip();
    if (pos < src.length) { const f = read(); if (f !== null) forms.push(f); }
  }
  return forms;
}

function sym(node, name) { return node && node.t === 'y' && node.v === name; }
function isKey(node) { return node && node.t === 'k'; }
function strVal(node) { return node && node.t === 's' ? node.v : node; }

// ---------------------------------------------------------------------------
// Parse test file into suites
// ---------------------------------------------------------------------------

function parseTestFile(filePath) {
  const forms = parseSx(fs.readFileSync(filePath, 'utf8'));
  const suites = [];

  for (const form of forms) {
    if (!Array.isArray(form) || !sym(form[0], 'defsuite')) continue;

    const suite = { name: strVal(form[1]) || path.basename(filePath), url: '', stream: false, timeout: 30000, tests: [], file: filePath };
    let i = 2;

    // keyword args
    while (i < form.length && isKey(form[i])) {
      const k = form[i].v; i++;
      if (k === 'url') { suite.url = strVal(form[i]); i++; }
      else if (k === 'stream') { suite.stream = form[i] !== false; i++; }
      else if (k === 'timeout') { suite.timeout = form[i]; i++; }
      else i++;
    }

    // deftest forms
    for (; i < form.length; i++) {
      if (!Array.isArray(form[i]) || !sym(form[i][0], 'deftest')) continue;
      const dt = form[i];
      const t = { name: strVal(dt[1]) || `test-${suite.tests.length}`, steps: [] };
      for (let j = 2; j < dt.length; j++) {
        if (Array.isArray(dt[j])) t.steps.push(parseStep(dt[j]));
      }
      suite.tests.push(t);
    }

    suites.push(suite);
  }
  return suites;
}

function parseStep(form) {
  const cmd = form[0].v;
  const args = [];
  const opts = {};
  for (let i = 1; i < form.length; i++) {
    if (isKey(form[i])) { const k = form[i].v; i++; opts[k] = strVal(form[i]); }
    else args.push(strVal(form[i]));
  }
  return { cmd, args, opts };
}

// ---------------------------------------------------------------------------
// Step executor — maps SX primitives to Playwright calls
// ---------------------------------------------------------------------------

async function executeStep(page, step, state) {
  const { cmd, args, opts } = step;
  const timeout = opts.timeout ? Number(opts.timeout) : 10000;

  switch (cmd) {
    case 'wait-for': {
      const loc = page.locator(args[0]);
      if (opts.text) await expect(loc.first()).toContainText(String(opts.text), { timeout });
      else if (opts.visible) await expect(loc.first()).toBeVisible({ timeout });
      else if (opts.count !== undefined) await expect(loc).toHaveCount(Number(opts.count), { timeout });
      else await loc.first().waitFor({ timeout });
      break;
    }

    case 'click': {
      let loc = page.locator(args[0]);
      if (opts.text) loc = loc.filter({ hasText: String(opts.text) });
      if (opts.nth !== undefined) await loc.nth(Number(opts.nth)).click();
      else if (opts.last) await loc.last().click();
      else await loc.first().click();
      break;
    }

    case 'fill': {
      await page.locator(args[0]).first().fill(String(args[1]));
      break;
    }

    case 'assert-text': {
      await expect(page.locator(args[0]).first()).toContainText(String(args[1]), { timeout });
      break;
    }

    case 'assert-not-text': {
      await expect(page.locator(args[0]).first()).not.toContainText(String(args[1]), { timeout: 3000 });
      break;
    }

    case 'assert-visible': {
      await expect(page.locator(args[0]).first()).toBeVisible({ timeout });
      break;
    }

    case 'assert-hidden': {
      await expect(page.locator(args[0]).first()).toBeHidden({ timeout });
      break;
    }

    case 'assert-count': {
      await expect(page.locator(args[0])).toHaveCount(Number(args[1]), { timeout });
      break;
    }

    case 'assert-no-errors': {
      // Marker — handled by test wrapper
      break;
    }

    case 'wait': {
      await page.waitForTimeout(Number(args[0]));
      break;
    }

    case 'snapshot': {
      state[args[0]] = await page.locator(args[0]).first().textContent();
      break;
    }

    case 'assert-changed': {
      const current = await page.locator(args[0]).first().textContent();
      expect(current, `Expected ${args[0]} text to change`).not.toBe(state[args[0]]);
      state[args[0]] = current;
      break;
    }

    default:
      throw new Error(`Unknown page test step: ${cmd}`);
  }
}

// ---------------------------------------------------------------------------
// Shared server — one for all test files
// ---------------------------------------------------------------------------

const USE_EXTERNAL = !!process.env.SX_TEST_URL;
let server;

if (!USE_EXTERNAL) {
  test.beforeAll(async () => {
    server = new SiteServer();
    await server.start();
  });
  test.afterAll(async () => {
    if (server) server.stop();
  });
}

function baseUrl() {
  return USE_EXTERNAL ? process.env.SX_TEST_URL : server.baseUrl;
}

// ---------------------------------------------------------------------------
// Register discovered tests
// ---------------------------------------------------------------------------

if (testFiles.length === 0) {
  test('no page tests found', () => {
    console.log('No *.test.sx files found in:', SEARCH_DIRS.join(', '));
  });
}

for (const file of testFiles) {
  const suites = parseTestFile(file);
  const relPath = path.relative(PROJECT_ROOT, file);

  for (const suite of suites) {
    test.describe(`${suite.name} (${relPath})`, () => {
      test.describe.configure({ timeout: suite.timeout });


      for (const t of suite.tests) {
        test(t.name, async ({ page }) => {
          // ── Diagnostics capture ──
          const diag = { console: [], network: [], errors: [] };
          page.on('console', msg => {
            const entry = `[${msg.type()}] ${msg.text()}`;
            diag.console.push(entry);
            if (msg.type() === 'error') diag.errors.push(msg.text());
          });
          page.on('pageerror', e => {
            diag.errors.push('PAGE_ERROR: ' + e.message);
            diag.console.push('[pageerror] ' + e.message);
          });
          page.on('response', res => {
            const url = res.url();
            // Skip data: URLs
            if (!url.startsWith('data:')) {
              diag.network.push(`${res.status()} ${res.request().method()} ${url.replace(baseUrl(), '')}`);
            }
          });
          page.on('requestfailed', req => {
            const url = req.url();
            if (!url.startsWith('data:')) {
              diag.network.push(`FAILED ${req.method()} ${url.replace(baseUrl(), '')} ${req.failure()?.errorText || ''}`);
            }
          });

          // ── Navigate ──
          const waitUntil = suite.stream ? 'commit' : 'domcontentloaded';
          await page.goto(baseUrl() + suite.url, { waitUntil, timeout: 30000 });

          // Wait for hydration on non-streaming pages
          if (!suite.stream) {
            try {
              await page.waitForSelector('html[data-sx-ready]', { timeout: 15000 });
            } catch (_) { /* continue with test steps */ }
          }

          // ── Execute steps, dump diagnostics on failure ──
          const state = {};
          try {
            for (const step of t.steps) {
              await executeStep(page, step, state);
            }
          } catch (err) {
            // Dump diagnostics on step failure
            console.log('\n═══ DIAGNOSTICS ═══');
            console.log('URL:', suite.url);
            console.log('\n── Network (' + diag.network.length + ' requests) ──');
            for (const n of diag.network) console.log('  ' + n);
            console.log('\n── Console (' + diag.console.length + ' entries) ──');
            for (const c of diag.console) console.log('  ' + c);
            // DOM snapshot — first 3000 chars of body
            try {
              const bodySnap = await page.evaluate(() => {
                const body = document.body;
                if (!body) return '(no body)';
                return body.innerHTML.substring(0, 3000);
              });
              console.log('\n── DOM (first 3000 chars) ──');
              console.log(bodySnap);
            } catch (_) {}
            console.log('═══════════════════\n');
            throw err;
          }

          // Auto-check console errors (filter network noise)
          const real = diag.errors.filter(e =>
            !e.includes('net::ERR') &&
            !e.includes('Failed to fetch') &&
            !e.includes('Failed to load resource') &&
            !e.includes('404')
          );
          if (real.length > 0) {
            console.log('Console errors:', real);
          }
        });
      }
    });
  }
}
