/**
 * Settings Filter Behavior Tests
 *
 * The Settings page renders 400+ controls (`.ldr-settings-item` rows). The
 * `#settings-search` input filters that list in-place via `handleSearchInput`
 * in `static/js/components/settings.js`. The implementation rebuilds the DOM
 * with the matching subset on each `input` event — items don't get hidden
 * via `display: none`, they're re-rendered out of the tree entirely. That
 * makes a count-of-`.ldr-settings-item` assertion the right behavioral check.
 *
 * Existing tests cover the page visually (responsive screenshots) but never
 * exercise the search filter. A regression that broke `handleSearchInput`
 * (typo in the field list, accidental early-return, broken organizeSettings,
 * etc.) would silently ship — settings rows would just stop responding to
 * the search box.
 *
 * What this spec verifies:
 *
 *   1. The search input exists and starts empty
 *   2. Typing a query that matches a real setting key (`iterations`)
 *      reduces the rendered set, and every surviving row matches the query
 *   3. Typing a nonsense query reduces the rendered set to zero
 *   4. Clearing the input restores the full count
 *
 * Note: Authentication is handled by auth.setup.js via storageState.
 */

import { test, expect } from '@playwright/test';

const SEARCH_INPUT = '#settings-search';
const SETTING_ROW = '.ldr-settings-item';

// "iterations" is a stable substring across multiple known settings keys
// (search.iterations, langgraph_agent.max_iterations,
// focused_iteration.previous_searches_limit, etc.). Choosing a token tied
// to real keys (not just labels) lets us assert on `data-key` directly and
// avoids breakage when labels are reworded.
const MATCHING_QUERY = 'iterations';
const NONSENSE_QUERY = 'zzz_no_such_setting_xyz';

async function gotoSettings(page) {
  await page.goto('/settings/');
  // Use `state: 'attached'` because on mobile the section bodies are collapsed
  // by default — the rows are in the DOM but `display: none`, which makes the
  // default `state: 'visible'` wait time out. We're asserting on counts, not
  // pixel visibility, so DOM-attached is the right gate.
  await page.waitForSelector(SETTING_ROW, { state: 'attached', timeout: 15000 });
  // The settings page fetches its content asynchronously; allSettings is
  // populated after the initial render. Wait until at least the first burst
  // of rows is on the page before measuring the "before" count.
  await expect.poll(
    async () => page.locator(SETTING_ROW).count(),
    { timeout: 10000, message: 'settings rows should finish initial render' },
  ).toBeGreaterThan(50);
}

async function setSearch(page, value) {
  // Set the input value and dispatch `input` directly so the test isn't
  // sensitive to whether keystrokes are debounced — `handleSearchInput`
  // listens on the `input` event and runs synchronously.
  await page.evaluate(([selector, v]) => {
    const el = document.querySelector(selector);
    el.focus();
    el.value = v;
    el.dispatchEvent(new Event('input', { bubbles: true }));
  }, [SEARCH_INPUT, value]);
}

test.describe('Settings Filter Behavior', () => {
  test('search input is present and starts empty', async ({ page }) => {
    await gotoSettings(page);
    const input = page.locator(SEARCH_INPUT);
    await expect(input).toBeVisible();
    await expect(input).toHaveValue('');
  });

  test('matching query narrows the rendered set and all rows match', async ({ page }) => {
    await gotoSettings(page);
    const before = await page.locator(SETTING_ROW).count();
    expect(before, 'baseline row count').toBeGreaterThan(50);

    await setSearch(page, MATCHING_QUERY);

    // After the input event fires, the DOM is rebuilt; poll until the row
    // count actually drops so we don't race the synchronous re-render.
    await expect.poll(
      async () => page.locator(SETTING_ROW).count(),
      { timeout: 3000, message: `row count should drop after typing "${MATCHING_QUERY}"` },
    ).toBeLessThan(before);

    const after = await page.locator(SETTING_ROW).count();
    expect(after, 'filtered set should be non-empty for a known token').toBeGreaterThan(0);

    // Every surviving row's data-key OR its visible text should contain the
    // search token. `handleSearchInput` searches over key/name/description/
    // category, so a row matching only on description is legitimate — fall
    // back to the rendered text in that case.
    const survivors = await page.locator(SETTING_ROW).evaluateAll(
      (rows, token) => rows.map((r) => ({
        key: r.getAttribute('data-key') || '',
        text: (r.textContent || '').toLowerCase(),
        matches: (r.getAttribute('data-key') || '').toLowerCase().includes(token)
          || (r.textContent || '').toLowerCase().includes(token),
      })),
      MATCHING_QUERY,
    );
    const nonMatches = survivors.filter((r) => !r.matches);
    expect(nonMatches, `all surviving rows should contain "${MATCHING_QUERY}"`).toEqual([]);
  });

  test('nonsense query removes all rows', async ({ page }) => {
    await gotoSettings(page);
    await setSearch(page, NONSENSE_QUERY);

    await expect.poll(
      async () => page.locator(SETTING_ROW).count(),
      { timeout: 3000, message: 'no rows should match a nonsense query' },
    ).toBe(0);
  });

  test('clearing the search restores the full list', async ({ page }) => {
    await gotoSettings(page);
    const before = await page.locator(SETTING_ROW).count();

    await setSearch(page, MATCHING_QUERY);
    await expect.poll(
      async () => page.locator(SETTING_ROW).count(),
      { timeout: 3000 },
    ).toBeLessThan(before);

    await setSearch(page, '');

    // Empty value re-renders by the active tab — that should bring the
    // count back to the same baseline we measured before filtering.
    await expect.poll(
      async () => page.locator(SETTING_ROW).count(),
      { timeout: 5000, message: 'row count should return to baseline after clearing' },
    ).toBe(before);
  });
});
