import { expect, UI_SETTLE_TIMEOUT, type Page } from '../fixtures.js';
import type { Locator } from '@playwright/test';
import { convexRun } from './convex.js';

/**
 * Shared CRUD helpers for the settings/system page specs. One copy of
 * the search-and-settle, dialog-save and delete-confirm logic so every
 * staging-flakiness lesson (re-fill on poll, reload retry, generous
 * propagation budgets) lands in all specs at once instead of drifting
 * per-file copies.
 *
 * Honesty contract: every helper HARD-ASSERTS its outcome. A dialog
 * that does not close, a row that never appears, a delete that does not
 * stick — all of these throw and fail the test. `test.skip` is reserved
 * for feature-flag gating; missing data is seeded (see the `seed*`
 * helpers), never skipped over.
 */

export const DIALOG = '.p-dialog-mask .p-dialog';

export const unique = (): string =>
  Date.now().toString(36) + Math.random().toString(36).slice(2, 6);

/** Default row selector for `masterev-infinite-data-table` pages. */
const DEFAULT_ROW_SELECTOR = '[data-testid="data-table"] [class*="border-b"]';

export interface SearchOptions {
  /**
   * Row selector for pages that do not use the infinite data table
   * (e.g. tag-settings' `[data-testid="tag-row"]`).
   */
  rowSelector?: string;
}

/** Locator for the data rows matching `text` under the page's table. */
export function matchingRows(
  page: Page,
  text: string,
  opts: SearchOptions = {}
): Locator {
  return page
    .locator(opts.rowSelector ?? DEFAULT_ROW_SELECTOR)
    .filter({ hasText: text });
}

async function fillSearchUntilRowVisible(
  page: Page,
  text: string,
  rows: Locator,
  timeout: number
): Promise<void> {
  const searchInput = page.locator('masterev-search-input input').first();
  await expect(searchInput).toBeVisible({ timeout: 10_000 });
  // Re-fill the search box on each poll: a just-saved row that arrives
  // after the first fill still gets picked up under staging load, where
  // Convex reactivity + the search index can lag the mutation.
  await expect(async () => {
    await searchInput.fill('');
    await searchInput.fill(text);
    await expect(rows.first()).toBeVisible({ timeout: 5_000 });
  }).toPass({ timeout });
}

/**
 * Search for `text` and wait until a matching row is visible, so a
 * downstream row-scoped `edit-button` click reliably hits the filtered
 * row (the bare `.first()` races the debounced filter and previously
 * edited whatever row topped the unfiltered list).
 *
 * Retries once via reload (Convex search-index lag on loaded staging),
 * then HARD-FAILS: a row that never appears means the create/update was
 * rejected — a bug, not a skip.
 */
export async function searchAndFind(
  page: Page,
  text: string,
  opts: SearchOptions = {}
): Promise<Locator> {
  const rows = matchingRows(page, text, opts);
  try {
    await fillSearchUntilRowVisible(page, text, rows, UI_SETTLE_TIMEOUT);
  } catch {
    // One reload, then a second settle budget: reactivity occasionally
    // needs a fresh query subscription on loaded staging.
    await page.goto(page.url().split('?')[0] ?? page.url());
    await fillSearchUntilRowVisible(page, text, rows, UI_SETTLE_TIMEOUT);
  }
  return rows;
}

/**
 * After a delete: search for `text` and assert it never (re)appears.
 * Reloads first so the assertion runs against a fresh subscription.
 *
 * The absence check is anchored on an AFFIRMATIVE loaded state first:
 * right after navigation the table trivially has zero rows while the
 * query is still in flight, so a bare `toHaveCount(0)` would pass
 * vacuously and green-light a delete the server silently rejected.
 * Both table variants render `[data-testid="table-empty-state"]` only
 * once loading finished; alternatively some OTHER row may render (the
 * search can partially match unrelated rows).
 */
export async function expectGoneFromSearch(
  page: Page,
  text: string,
  opts: SearchOptions = {}
): Promise<void> {
  await page.goto(page.url().split('?')[0] ?? page.url());
  const searchInput = page.locator('masterev-search-input input').first();
  await expect(searchInput).toBeVisible({ timeout: 10_000 });
  await searchInput.fill(text);

  // Wait for the table to be LOADED: either the empty state rendered or
  // some data row did. Only then is a zero match count meaningful.
  const anyRow = page.locator(opts.rowSelector ?? DEFAULT_ROW_SELECTOR).first();
  const emptyState = page.locator('[data-testid="table-empty-state"]').first();
  await expect(
    anyRow.or(emptyState).first(),
    'table must finish loading (empty state or rows) before the absence check'
  ).toBeVisible({ timeout: UI_SETTLE_TIMEOUT });

  const rows = matchingRows(page, text, opts);
  await expect(rows, `"${text}" must disappear after delete`).toHaveCount(0, {
    timeout: UI_SETTLE_TIMEOUT,
  });
}

/**
 * Commit a value into a PrimeNG InputNumber. A plain `fill()`
 * intermittently leaves the form control untouched (PrimeNG parses
 * keystrokes via its own input handler), which keeps required fields
 * invalid and the save button disabled. Click, clear, type the digits,
 * then blur via the dialog's first input to run change detection.
 */
export async function fillNumberInput(
  dialog: Locator,
  input: Locator,
  value: string
): Promise<void> {
  await input.click();
  await input.fill('');
  await input.pressSequentially(value);
  await dialog.locator('input').first().click();
}

/** Open the page's create dialog and return its locator. */
export async function openCreateDialog(page: Page): Promise<Locator> {
  const createButton = page.locator('[data-testid="create-button"]').first();
  await expect(createButton).toBeVisible({ timeout: 10_000 });
  await createButton.click();
  const dialog = page.locator(DIALOG).first();
  await expect(dialog).toBeVisible({ timeout: 5_000 });
  return dialog;
}

/**
 * Resolve a `data-testid` action button to its NATIVE <button>. The
 * testid sits either directly on a native button or on a PrimeNG
 * <p-button> host — and the host never carries the disabled state, so
 * enabled/disabled checks against it silently pass and a click can land
 * on a disabled inner button as a no-op.
 */
function nativeActionButton(dialog: Locator, testId: string): Locator {
  return dialog
    .locator(`button[data-testid="${testId}"], [data-testid="${testId}"] button`)
    .first();
}

/**
 * Click save and require the dialog to close. A dialog that stays open
 * means the server rejected the mutation (or a validation error is
 * showing) — that is a product bug and MUST fail the test, never be
 * dismissed with Escape and skipped.
 */
export async function saveDialog(dialog: Locator): Promise<void> {
  const saveButton = nativeActionButton(dialog, 'save-button');
  // Full settle budget: async validators + Convex round-trips keep the
  // button disabled noticeably longer when parallel workers load the
  // backend. A form that is genuinely invalid still fails here — with a
  // message that says so — just after the generous budget.
  await expect(
    saveButton,
    'save button must be enabled (a disabled save button means the form is invalid)'
  ).toBeEnabled({ timeout: UI_SETTLE_TIMEOUT });
  await saveButton.click();
  await expect(
    dialog,
    'save dialog must close — a dialog that stays open means the mutation was rejected'
  ).not.toBeVisible({ timeout: UI_SETTLE_TIMEOUT });
}

/**
 * Search for `text` and open its row's edit dialog. When `hydratedValue`
 * is given (default: `text`), waits for the dialog's first input to show
 * it — confirming the edit form opened the RIGHT entity and finished
 * hydrating before the caller interacts with other fields.
 */
export async function openRowEditor(
  page: Page,
  text: string,
  opts: SearchOptions & { hydratedValue?: string | null } = {}
): Promise<Locator> {
  const rows = await searchAndFind(page, text, opts);
  await rows.first().locator('[data-testid="edit-button"]').first().click();
  const dialog = page.locator(DIALOG).first();
  await expect(dialog).toBeVisible({ timeout: 5_000 });
  const hydratedValue = opts.hydratedValue === undefined ? text : opts.hydratedValue;
  if (hydratedValue !== null) {
    await expect(dialog.locator('input').first()).toHaveValue(hydratedValue, {
      timeout: 5_000,
    });
  }
  return dialog;
}

/**
 * Click the edit dialog's delete button, accept the PrimeNG confirm
 * dialog and require the edit dialog to close. A confirm flow that does
 * not close the dialog means the deletion was rejected — fail, don't skip.
 */
export async function deleteViaDialog(page: Page, dialog: Locator): Promise<void> {
  const deleteButton = nativeActionButton(dialog, 'delete-button');
  await expect(deleteButton).toBeVisible({ timeout: 5_000 });
  await expect(deleteButton).toBeEnabled({ timeout: 5_000 });
  await deleteButton.click();

  const confirmDialog = page.locator('[role="alertdialog"]:visible').first();
  await expect(confirmDialog).toBeVisible({ timeout: 5_000 });
  await confirmDialog
    .locator('.p-confirmdialog-accept-button, .p-confirm-dialog-accept')
    .first()
    .click();

  await expect(
    dialog,
    'edit dialog must close after confirmed delete — staying open means the deletion was rejected'
  ).not.toBeVisible({ timeout: UI_SETTLE_TIMEOUT });
}

// ---------------------------------------------------------------------------
// Seeding: data preconditions are SEEDED, never skipped over. A spec that
// needs two teams creates them; "customer has no teams" is not a reason to
// skip — it is a reason to call these.
// ---------------------------------------------------------------------------

/**
 * Ensure at least `count` teams exist by seeding uniquely-named ones.
 * Idempotent per (customer, short); returns the created/existing ids.
 */
export async function seedTeams(
  customer: string,
  count: number,
  label = 'E2E Seed Team'
): Promise<string[]> {
  const ids: string[] = [];
  for (let i = 0; i < count; i += 1) {
    const id = unique();
    ids.push(
      await convexRun<string>('e2e:createTeam', {
        customer,
        name: `${label} ${id}`,
        short: `s${id.slice(-4)}${i}`,
      })
    );
  }
  return ids;
}

/**
 * Seed a team WITH a member: creates a team plus a USER assigned to it.
 * Used by specs that need "a team that has users" (e.g. the
 * delete-button-disabled assertion) instead of hunting ambient staging
 * data and skipping when none is found.
 */
export async function seedTeamWithMember(
  customer: string
): Promise<{ teamId: string; userId: string; teamName: string }> {
  const id = unique();
  const teamName = `E2E Member Team ${id}`;
  const teamId = await convexRun<string>('e2e:createTeam', {
    customer,
    name: teamName,
    short: `m${id.slice(-4)}`,
  });
  const userId = await convexRun<string>('e2e:createUser', {
    customer,
    name: `E2E Team Member ${id}`,
    username: `e2e-member-${id}`,
    role: 'USER',
    teamIds: [teamId],
  });
  return { teamId, userId, teamName };
}
