import {
  type Browser,
  type BrowserContext,
  type Locator,
  type Page,
  expect,
} from '@playwright/test';
import { SuperSyncPage, type SuperSyncConfig } from '../pages/supersync.page';
import { WorkViewPage } from '../pages/work-view.page';
import { waitForAppReady } from './waits';
import {
  HEALTH_CHECK_TIMEOUT,
  API_REQUEST_TIMEOUT,
  UI_VISIBLE_TIMEOUT,
  UI_VISIBLE_TIMEOUT_LONG,
  UI_SETTLE_SMALL,
  UI_SETTLE_MEDIUM,
  UI_SETTLE_STANDARD,
  UI_SETTLE_EXTENDED,
  RETRY_BASE_DELAY,
  TASK_POLL_INTERVAL,
  TASK_WAIT_TIMEOUT,
  UI_VISIBLE_TIMEOUT_SHORT,
} from './e2e-constants';
import {
  assertNoRuntimeBrowserErrors,
  attachPageErrorCollector,
  installDevErrorDialogHandler,
  type RuntimeBrowserError,
} from './runtime-errors';

/**
 * SuperSync server URL for E2E tests.
 * Server must be running with TEST_MODE=true.
 * Defaults to port 1901 for e2e tests (dev server uses 1900).
 */
export const SUPERSYNC_BASE_URL =
  process.env.SUPERSYNC_E2E_URL || 'http://localhost:1901';

/**
 * Test user credentials returned from the server.
 */
export interface TestUser {
  email: string;
  token: string;
  userId: number;
}

/**
 * A simulated client for E2E sync tests.
 * Wraps a browser context, page, and page objects.
 */
export interface SimulatedE2EClient {
  context: BrowserContext;
  page: Page;
  workView: WorkViewPage;
  sync: SuperSyncPage;
  clientName: string;
  runtimeErrors: RuntimeBrowserError[];
}

/**
 * Create a test user on the SuperSync server.
 * Requires server to be running with TEST_MODE=true.
 *
 * @param testId - Unique test identifier for user email
 * @returns Test user with email and JWT token
 */
export const createTestUser = async (
  testId: string,
  maxRetries = 3,
): Promise<TestUser> => {
  const email = `test-${testId}@e2e.local`;
  const password = 'TestPassword123!';

  const headers = new Headers();
  headers.set('Content-Type', 'application/json');

  for (let attempt = 0; attempt < maxRetries; attempt++) {
    const response = await fetch(`${SUPERSYNC_BASE_URL}/api/test/create-user`, {
      method: 'POST',
      headers,
      body: JSON.stringify({ email, password }),
    });

    // Handle rate limiting with exponential backoff
    if (response.status === 429 && attempt < maxRetries - 1) {
      const delay = RETRY_BASE_DELAY * Math.pow(2, attempt);
      console.log(`[createTestUser] Rate limited (429), retrying in ${delay}ms...`);
      await new Promise((r) => setTimeout(r, delay));
      continue;
    }

    if (!response.ok) {
      const text = await response.text();
      throw new Error(`Failed to create test user: ${response.status} - ${text}`);
    }

    const data = await response.json();
    return {
      email,
      token: data.token,
      userId: data.userId,
    };
  }

  throw new Error(`Max retries (${maxRetries}) exceeded for createTestUser`);
};

/**
 * Clean up all test data on the server.
 * Call this in test teardown if needed.
 */
export const cleanupTestData = async (): Promise<void> => {
  const response = await fetch(`${SUPERSYNC_BASE_URL}/api/test/cleanup`, {
    method: 'POST',
  });

  if (!response.ok) {
    console.warn(`Cleanup failed: ${response.status}`);
  }
};

/**
 * Delete a specific test user account on the SuperSync server.
 * Used to test account deletion and re-registration scenarios.
 *
 * @param userId - The user ID to delete
 */
export const deleteTestUser = async (userId: number): Promise<void> => {
  const response = await fetch(`${SUPERSYNC_BASE_URL}/api/test/user/${userId}`, {
    method: 'DELETE',
  });

  if (!response.ok && response.status !== 404) {
    const text = await response.text();
    throw new Error(`Failed to delete test user: ${response.status} - ${text}`);
  }
};

/**
 * Check if the SuperSync server is running, healthy, AND has test mode enabled.
 * Tests require TEST_MODE=true on the server for the /api/test/* endpoints.
 */
export const isServerHealthy = async (): Promise<boolean> => {
  try {
    // First check basic health
    const healthResponse = await fetch(`${SUPERSYNC_BASE_URL}/health`, {
      method: 'GET',
      signal: AbortSignal.timeout(HEALTH_CHECK_TIMEOUT),
    });
    if (!healthResponse.ok) {
      return false;
    }

    // Then verify test mode is enabled by trying to create a dummy user
    // This is the only reliable way to check if test endpoints exist
    const headers = new Headers();
    headers.set('Content-Type', 'application/json');
    const testModeResponse = await fetch(`${SUPERSYNC_BASE_URL}/api/test/create-user`, {
      method: 'POST',
      headers,
      body: JSON.stringify({
        email: `health-check-${Date.now()}@test.local`,
        password: 'HealthCheck123!',
      }),
      signal: AbortSignal.timeout(API_REQUEST_TIMEOUT),
    });

    // If test mode is disabled, the route won't exist (404)
    // If test mode is enabled, we'll get 201 (created) or 409 (conflict) or similar
    if (testModeResponse.status === 404) {
      console.warn('SuperSync server is running but TEST_MODE is not enabled');
      return false;
    }

    return true;
  } catch {
    return false;
  }
};

/**
 * Get SuperSync configuration for a test user.
 */
export const getSuperSyncConfig = (user: TestUser): SuperSyncConfig => {
  return {
    baseUrl: SUPERSYNC_BASE_URL,
    accessToken: user.token,
    isEncryptionEnabled: true,
    password: 'e2e-default-encryption-pw',
  };
};

/**
 * Create a simulated E2E client with its own isolated browser context.
 *
 * Each client has:
 * - Separate browser context (isolated IndexedDB, localStorage)
 * - Unique clientId generated by the app on first load
 * - WorkViewPage for task operations
 * - SuperSyncPage for sync operations
 *
 * @param browser - Playwright browser instance
 * @param baseURL - App base URL (e.g., http://localhost:4242)
 * @param clientName - Human-readable name for debugging (e.g., "A", "B")
 * @param testPrefix - Test prefix for task naming
 */
export const createSimulatedClient = async (
  browser: Browser,
  baseURL: string,
  clientName: string,
  testPrefix: string,
  options: { allowExampleTasks?: boolean } = {},
): Promise<SimulatedE2EClient> => {
  const { allowExampleTasks = false } = options;
  // Use provided baseURL or fall back to localhost:4242 (Playwright fixture may be undefined)
  const effectiveBaseURL = baseURL || 'http://localhost:4242';

  const context = await browser.newContext({
    storageState: undefined, // Clean slate - no shared state
    userAgent: `PLAYWRIGHT SYNC-CLIENT-${clientName}`,
    baseURL: effectiveBaseURL,
    viewport: { width: 1920, height: 1080 },
  });

  const page = await context.newPage();
  const runtimeErrors = attachPageErrorCollector(page, `Client ${clientName}`);
  installDevErrorDialogHandler(page, `Client ${clientName}`);

  // Skip onboarding, hints, and example tasks before the app boots.
  // This runs before any page JavaScript, so Angular sees the flags immediately.
  // Tests of the example-task sync gate opt back in via { allowExampleTasks: true }
  // so first-run onboarding tasks are actually created.
  await page.addInitScript((allowExamples) => {
    localStorage.setItem('SUP_ONBOARDING_PRESET_DONE', 'true');
    localStorage.setItem('SUP_ONBOARDING_HINTS_DONE', 'true');
    localStorage.setItem('SUP_IS_SHOW_TOUR', 'true');
    if (!allowExamples) {
      localStorage.setItem('SUP_EXAMPLE_TASKS_CREATED', 'true');
    }
  }, allowExampleTasks);

  page.on('console', (msg) => {
    if (msg.type() === 'error') {
      console.error(`[Client ${clientName}] Console error:`, msg.text());
    } else if (process.env.E2E_VERBOSE) {
      console.log(`[Client ${clientName}] Console ${msg.type()}:`, msg.text());
    }
  });

  // Navigate to app with retry for transient ERR_CONNECTION_REFUSED.
  // Under parallel load (many workers × 2-3 browser contexts each), the Angular
  // dev server can temporarily refuse connections. Retrying recovers from this
  // without failing the test outright.
  let lastGotoError: Error | null = null;
  for (let attempt = 0; attempt < 3; attempt++) {
    try {
      await page.goto('/');
      lastGotoError = null;
      break;
    } catch (e) {
      lastGotoError = e as Error;
      const isConnectionRefused = lastGotoError.message.includes(
        'ERR_CONNECTION_REFUSED',
      );
      if (attempt < 2 && isConnectionRefused) {
        const delay = 1000 * (attempt + 1);
        console.log(
          `[Client ${clientName}] page.goto('/') failed (attempt ${attempt + 1}/3): ERR_CONNECTION_REFUSED — retrying in ${delay}ms`,
        );
        await new Promise((r) => setTimeout(r, delay));
      } else {
        break; // Non-connection error or last attempt — let it throw below
      }
    }
  }
  if (lastGotoError) throw lastGotoError;

  await waitForAppReady(page);

  const workView = new WorkViewPage(page, `${clientName}-${testPrefix}`);
  const sync = new SuperSyncPage(page);

  return {
    context,
    page,
    workView,
    sync,
    clientName,
    runtimeErrors,
  };
};

/**
 * Close a simulated client and clean up resources.
 * Safely handles already-closed contexts.
 */
export const closeClient = async (client: SimulatedE2EClient): Promise<void> => {
  try {
    // Check if page is still open before trying to close context
    if (!client.page.isClosed()) {
      // Add timeout wrapper to prevent cleanup from blocking test completion.
      // context.close() can hang waiting for trace files to be written.
      const closePromise = client.context.close();
      let timeoutId: ReturnType<typeof setTimeout>;
      const timeoutPromise = new Promise<void>((_, reject) => {
        timeoutId = setTimeout(() => reject(new Error('Cleanup timeout')), 5000);
      });

      // Prevent unhandled rejection if closePromise rejects after timeout wins the race.
      // Without this, the abandoned promise rejection causes Playwright to mark the test
      // as failed with "error was not a part of any test", masking the real test error.
      closePromise.catch(() => {});

      try {
        await Promise.race([closePromise, timeoutPromise]);
      } finally {
        clearTimeout(timeoutId!);
      }
    }
  } catch (error) {
    // Always ignore cleanup errors - they should never mask the actual test error.
    // Common scenarios:
    // - Test timeout: Playwright force-closes contexts, cleanup gets "Protocol error"
    // - ENOENT: Trace file finalization fails for manually-created contexts
    // - Context already closed: Race between test timeout and cleanup
    // - Cleanup timeout: context.close() hung waiting for trace artifacts
    console.warn(
      `[closeClient] Cleanup error (ignored): ${error instanceof Error ? error.message : String(error)}`,
    );
  }

  // Assert AFTER close so pageerrors emitted during teardown (Angular destroy
  // hooks, late RxJS errors) are still captured before we throw.
  assertNoRuntimeBrowserErrors(client.runtimeErrors, `Client ${client.clientName}`);
};

/**
 * Wait for a task with given name to appear on the page.
 * Uses longer timeout by default for sync operations which can be slow.
 * Includes retry logic for better reliability after sync operations.
 */
export const waitForTask = async (
  page: Page,
  taskName: string,
  timeout = TASK_WAIT_TIMEOUT,
): Promise<void> => {
  const escapedName = taskName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  const startTime = Date.now();

  // Retry loop to handle DOM update delays
  while (Date.now() - startTime < timeout) {
    // Check if page is still open before each iteration
    if (page.isClosed()) {
      throw new Error(`Page was closed while waiting for task "${taskName}"`);
    }

    try {
      await page.waitForSelector(`task:has-text("${escapedName}")`, {
        timeout: UI_VISIBLE_TIMEOUT,
        state: 'visible',
      });
      return; // Success
    } catch (error) {
      // Check if page is closed before waiting
      if (page.isClosed()) {
        throw new Error(`Page was closed while waiting for task "${taskName}"`);
      }
      // Only retry on timeout errors; re-throw other errors (page crashes, etc.)
      const isTimeoutError =
        error instanceof Error &&
        (error.message.includes('Timeout') || error.name === 'TimeoutError');
      if (!isTimeoutError) {
        throw error;
      }
      // Wait a bit and retry
      await page.waitForTimeout(TASK_POLL_INTERVAL);
    }
  }

  // Final attempt with full remaining timeout
  if (page.isClosed()) {
    throw new Error(`Page was closed while waiting for task "${taskName}"`);
  }
  const remaining = Math.max(timeout - (Date.now() - startTime), RETRY_BASE_DELAY);
  await page.waitForSelector(`task:has-text("${escapedName}")`, {
    timeout: remaining,
    state: 'visible',
  });
};

/**
 * Count tasks matching a pattern on the page.
 */
export const countTasks = async (page: Page, pattern?: string): Promise<number> => {
  if (pattern) {
    const escapedPattern = pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
    return page.locator(`task:has-text("${escapedPattern}")`).count();
  }
  return page.locator('task').count();
};

/**
 * Check if a task exists on the page.
 */
export const hasTask = async (page: Page, taskName: string): Promise<boolean> => {
  const escapedName = taskName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  const count = await page.locator(`task:has-text("${escapedName}")`).count();
  return count > 0;
};

// ============================================================================
// Task Element Helpers
// ============================================================================

/**
 * Escape special characters in a string for use in CSS :has-text() selector.
 */
const escapeForSelector = (text: string): string => {
  return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
};

/**
 * Get a task element locator by task name.
 * This replaces the common pattern: `client.page.locator(\`task:has-text("${taskName}")\`)`
 *
 * @param client - The simulated E2E client
 * @param taskName - The task name to search for
 * @returns Locator for the task element
 */
export const getTaskElement = (client: SimulatedE2EClient, taskName: string): Locator => {
  const escapedName = escapeForSelector(taskName);
  return client.page.locator(`task:has-text("${escapedName}")`);
};

/**
 * Get a task element locator from a page by task name.
 * Use this when you have a page but not a client.
 *
 * @param page - The Playwright page
 * @param taskName - The task name to search for
 * @returns Locator for the task element
 */
export const getTaskElementFromPage = (page: Page, taskName: string): Locator => {
  const escapedName = escapeForSelector(taskName);
  return page.locator(`task:has-text("${escapedName}")`);
};

/**
 * Get a subtask element (task without subtasks) by name.
 * Useful for targeting subtasks without matching their parent.
 *
 * @param client - The simulated E2E client
 * @param taskName - The subtask name to search for
 * @returns Locator for the subtask element
 */
export const getSubtaskElement = (
  client: SimulatedE2EClient,
  taskName: string,
): Locator => {
  const escapedName = escapeForSelector(taskName);
  return client.page.locator(`task.hasNoSubTasks:has-text("${escapedName}")`);
};

/**
 * Get a done task element by name.
 *
 * @param client - The simulated E2E client
 * @param taskName - The task name to search for
 * @returns Locator for the done task element
 */
export const getDoneTaskElement = (
  client: SimulatedE2EClient,
  taskName: string,
): Locator => {
  const escapedName = escapeForSelector(taskName);
  return client.page.locator(`task.isDone:has-text("${escapedName}")`);
};

/**
 * Get a done subtask element by name.
 *
 * @param client - The simulated E2E client
 * @param taskName - The subtask name to search for
 * @returns Locator for the done subtask element
 */
export const getDoneSubtaskElement = (
  client: SimulatedE2EClient,
  taskName: string,
): Locator => {
  const escapedName = escapeForSelector(taskName);
  return client.page.locator(`task.hasNoSubTasks.isDone:has-text("${escapedName}")`);
};

/**
 * Get an undone subtask element by name.
 *
 * @param client - The simulated E2E client
 * @param taskName - The subtask name to search for
 * @returns Locator for the undone subtask element
 */
export const getUndoneSubtaskElement = (
  client: SimulatedE2EClient,
  taskName: string,
): Locator => {
  const escapedName = escapeForSelector(taskName);
  return client.page.locator(
    `task.hasNoSubTasks:not(.isDone):has-text("${escapedName}")`,
  );
};

/**
 * Get a parent task element (task with subtasks) by name.
 *
 * @param client - The simulated E2E client
 * @param taskName - The task name to search for
 * @returns Locator for the parent task element
 */
export const getParentTaskElement = (
  client: SimulatedE2EClient,
  taskName: string,
): Locator => {
  const escapedName = escapeForSelector(taskName);
  return client.page.locator(`task:not(.hasNoSubTasks):has-text("${escapedName}")`);
};

// ============================================================================
// Task Action Helpers
// ============================================================================

/**
 * Mark a task as done by hovering and clicking the done button.
 *
 * Waits for the `.isDone` class to appear before returning. `TaskService.toggleDoneWithAnimation`
 * schedules the actual `setDone` dispatch via `setTimeout(200ms)` for the mark-done animation,
 * so without this wait a following sync can start before the updateTask op is captured — the
 * late op then lands during the sync's upload, leaving `hasPendingOps=true` and the check icon
 * never appears.
 *
 * @param client - The simulated E2E client
 * @param taskName - The task name to mark as done
 */
export const markTaskDone = async (
  client: SimulatedE2EClient,
  taskName: string,
): Promise<void> => {
  const task = getTaskElement(client, taskName);
  await task.hover();
  await task.locator('done-toggle').click();
  await expect(getDoneTaskElement(client, taskName).first()).toBeVisible({
    timeout: UI_VISIBLE_TIMEOUT,
  });
};

/**
 * Mark a subtask as done by hovering and clicking the done button.
 * Uses getSubtaskElement to avoid matching parent tasks.
 *
 * Waits for `.isDone` for the same reason as `markTaskDone` — the 200ms animation delay in
 * `toggleDoneWithAnimation` would otherwise race the following sync and leave pending ops.
 *
 * @param client - The simulated E2E client
 * @param subtaskName - The subtask name to mark as done
 */
export const markSubtaskDone = async (
  client: SimulatedE2EClient,
  subtaskName: string,
): Promise<void> => {
  const subtask = getSubtaskElement(client, subtaskName);
  await subtask.hover();
  await subtask.locator('done-toggle').click();
  await expect(getDoneSubtaskElement(client, subtaskName).first()).toBeVisible({
    timeout: UI_VISIBLE_TIMEOUT,
  });
};

/**
 * Expand a parent task to show its subtasks.
 *
 * @param client - The simulated E2E client
 * @param taskName - The parent task name
 */
export const expandTask = async (
  client: SimulatedE2EClient,
  taskName: string,
): Promise<void> => {
  const task = getTaskElement(client, taskName);
  const expandBtn = task.locator('.expand-btn');
  if (await expandBtn.isVisible()) {
    await expandBtn.click();
  }
};

/**
 * Delete a task via keyboard shortcut (Backspace) and confirm if dialog appears.
 *
 * @param client - The simulated E2E client
 * @param taskName - The task name to delete
 */
export const deleteTask = async (
  client: SimulatedE2EClient,
  taskName: string,
): Promise<void> => {
  const task = getTaskElement(client, taskName);
  // Focus the task element directly without entering title edit mode.
  // Clicking the task body can land on the task-title, opening the textarea editor,
  // which causes Backspace to delete text instead of triggering the delete shortcut.
  await task.focus();
  await client.page.keyboard.press('Backspace');

  // Confirm deletion if dialog appears
  const confirmBtn = client.page.locator('mat-dialog-actions button:has-text("Delete")');
  const dialogAppeared = await confirmBtn
    .waitFor({ state: 'visible', timeout: UI_VISIBLE_TIMEOUT_SHORT })
    .then(() => true)
    .catch(() => false);

  if (dialogAppeared) {
    await confirmBtn.click();
    // Wait for dialog to close (not just click the button)
    // This ensures the close animation completes before continuing
    await client.page
      .locator('mat-dialog-container')
      .waitFor({ state: 'hidden', timeout: UI_VISIBLE_TIMEOUT });
  }

  // Wait for UI to settle after deletion
  await client.page.waitForTimeout(UI_SETTLE_STANDARD);
};

/**
 * Rename a task by double-clicking and filling the input.
 *
 * @param client - The simulated E2E client
 * @param oldName - The current task name
 * @param newName - The new task name
 */
export const renameTask = async (
  client: SimulatedE2EClient,
  oldName: string,
  newName: string,
): Promise<void> => {
  const task = getTaskElement(client, oldName);
  // Wait for the task element to be stable before interacting — prevents
  // "element detached from DOM" errors when Angular re-renders the task list
  await task.waitFor({ state: 'attached', timeout: 5000 });

  // Wait for Angular enter animations to complete. On slower/loaded CI machines the
  // task element may still carry the `ng-animating` class when first visible,
  // causing clicks to be swallowed and `task-title` to be temporarily absent.
  await task
    .evaluate((el) =>
      el.classList.contains('ng-animating')
        ? new Promise<void>((resolve) => {
            const observer = new MutationObserver(() => {
              if (!el.classList.contains('ng-animating')) {
                observer.disconnect();
                resolve();
              }
            });
            observer.observe(el, { attributes: true, attributeFilter: ['class'] });
            setTimeout(() => {
              observer.disconnect();
              resolve();
            }, 2000);
          })
        : undefined,
    )
    .catch(() => {});

  // Enter edit mode by dispatching a click event directly on the task-title element.
  // Using dispatchEvent avoids pointer-events:none and Playwright actionability issues.
  // Retry up to 3 times: Angular may still re-render the component briefly after
  // the animation class is removed, causing the click to not open edit mode.
  const taskTitle = task.locator('task-title').first();
  const textarea = client.page.locator('task-title textarea').first();
  for (let attempt = 0; attempt < 3; attempt++) {
    await taskTitle.dispatchEvent('click');
    try {
      await textarea.waitFor({ state: 'visible', timeout: 3000 });
      break;
    } catch {
      if (attempt === 2) {
        throw new Error(
          `renameTask: textarea did not appear after 3 click attempts on "${oldName}"`,
        );
      }
      await client.page.waitForTimeout(500);
    }
  }

  // Type directly into the textarea via evaluate to avoid focus/detach races
  await textarea.evaluate((el: HTMLTextAreaElement, name: string) => {
    el.focus();
    el.value = name;
    el.dispatchEvent(new Event('input', { bubbles: true }));
  }, newName);
  // Blur to commit the change
  await textarea.evaluate((el: HTMLTextAreaElement) => {
    el.dispatchEvent(new Event('blur', { bubbles: true }));
  });
  await client.page.waitForTimeout(UI_SETTLE_MEDIUM);
};

/**
 * Start time tracking on a task.
 *
 * @param client - The simulated E2E client
 * @param taskName - The task name to start tracking
 */
export const startTimeTracking = async (
  client: SimulatedE2EClient,
  taskName: string,
): Promise<void> => {
  const task = getTaskElement(client, taskName);
  await task.hover();
  const startBtn = task.locator('.start-task-btn');
  await startBtn.waitFor({ state: 'visible', timeout: UI_VISIBLE_TIMEOUT });
  await startBtn.click();
};

/**
 * Stop time tracking on a task.
 *
 * @param client - The simulated E2E client
 * @param taskName - The task name to stop tracking
 */
export const stopTimeTracking = async (
  client: SimulatedE2EClient,
  taskName: string,
): Promise<void> => {
  const task = getTaskElement(client, taskName);
  await task.hover();
  const pauseBtn = task.locator('button:has(mat-icon:has-text("pause"))');
  await pauseBtn.waitFor({ state: 'visible', timeout: UI_VISIBLE_TIMEOUT });
  await pauseBtn.click();
  await client.page.mouse.move(0, 0);
  await task
    .locator('task-hover-controls')
    .waitFor({ state: 'detached', timeout: UI_VISIBLE_TIMEOUT_SHORT })
    .catch(() => {});
};

// ============================================================================
// Task Query Helpers
// ============================================================================

/**
 * Get the task count on a client.
 *
 * @param client - The simulated E2E client
 * @returns The number of tasks
 */
export const getTaskCount = async (client: SimulatedE2EClient): Promise<number> => {
  return client.page.locator('task').count();
};

/**
 * Get all task titles as an array.
 * Useful for comparing task order between clients.
 *
 * @param client - The simulated E2E client
 * @returns Array of task titles in order
 */
export const getTaskTitles = async (client: SimulatedE2EClient): Promise<string[]> => {
  const tasks = client.page.locator('task .task-title');
  const count = await tasks.count();
  const titles: string[] = [];
  for (let i = 0; i < count; i++) {
    const text = await tasks.nth(i).innerText();
    titles.push(text.trim());
  }
  return titles;
};

/**
 * Get the tracked time display text for a task.
 *
 * @param client - The simulated E2E client
 * @param taskName - The task name
 * @returns The time display text or null if not present
 */
export const getTaskTimeDisplay = async (
  client: SimulatedE2EClient,
  taskName: string,
): Promise<string | null> => {
  const task = getTaskElement(client, taskName);
  const timeVal = task.locator('.time-wrapper .time-val').first();
  if ((await timeVal.count()) > 0) {
    return timeVal.textContent();
  }
  return null;
};

/**
 * Wait for a task's tracked time text to be present.
 *
 * The task row intentionally hides `.time-wrapper` while hover controls are mounted,
 * so time-tracking assertions should read the rendered text instead of requiring
 * visual visibility.
 *
 * @param client - The simulated E2E client
 * @param taskName - The task name
 * @param timeout - Maximum time to wait for non-empty time text
 * @returns The trimmed time display text
 */
export const waitForTaskTimeDisplay = async (
  client: SimulatedE2EClient,
  taskName: string,
  timeout = UI_VISIBLE_TIMEOUT,
): Promise<string> => {
  await expect
    .poll(
      async () => {
        const text = await getTaskTimeDisplay(client, taskName);
        return text?.trim() ?? '';
      },
      {
        timeout,
        intervals: [250, 500, 1000],
      },
    )
    .not.toBe('');

  return (await getTaskTimeDisplay(client, taskName))!.trim();
};

/**
 * Get a task's persisted timeSpent from the app state cache.
 *
 * This avoids relying on the task row's time label: sub-minute values render as
 * "-" in the UI, and the label can be hidden while hover controls are mounted.
 *
 * @param client - The simulated E2E client
 * @param taskName - The task name
 * @returns The persisted timeSpent value in milliseconds, or null if not found
 */
export const getTaskTimeSpentFromState = async (
  client: SimulatedE2EClient,
  taskName: string,
): Promise<number | null> =>
  client.page.evaluate(async (name) => {
    const isRecord = (value: unknown): value is Record<string, unknown> =>
      typeof value === 'object' && value !== null;

    const getTimeSpentFromRootState = (state: Record<string, unknown>): number | null => {
      const taskState = state.tasks ?? state.task;
      if (!isRecord(taskState) || !isRecord(taskState.entities)) {
        return null;
      }

      for (const task of Object.values(taskState.entities)) {
        if (
          isRecord(task) &&
          typeof task.title === 'string' &&
          task.title.includes(name)
        ) {
          return typeof task.timeSpent === 'number' ? task.timeSpent : 0;
        }
      }

      return null;
    };

    type StoreSubscription = { unsubscribe: () => void };
    type StoreLike = {
      subscribe: (next: (state: unknown) => void) => StoreSubscription;
    };

    const helpers = (
      window as unknown as {
        __e2eTestHelpers?: {
          store?: StoreLike;
        };
      }
    ).__e2eTestHelpers;

    if (helpers?.store) {
      const liveState = await new Promise<Record<string, unknown> | null>((resolve) => {
        let isDone = false;
        const subscriptionRef: { current?: StoreSubscription } = {};
        const finish = (state: unknown): void => {
          if (isDone) {
            return;
          }
          isDone = true;
          window.setTimeout(() => subscriptionRef.current?.unsubscribe());
          resolve(isRecord(state) ? state : null);
        };

        subscriptionRef.current = helpers.store.subscribe(finish);
        window.setTimeout(() => finish(null), 1000);
      });

      if (liveState) {
        const timeSpent = getTimeSpentFromRootState(liveState);
        if (timeSpent !== null) {
          return timeSpent;
        }
      }
    }

    const db = await new Promise<IDBDatabase>((resolve, reject) => {
      const request = indexedDB.open('SUP_OPS');
      request.onsuccess = () => resolve(request.result);
      request.onerror = () => reject(request.error);
    });

    const stateCacheEntry = await new Promise<
      { state?: Record<string, unknown> } | undefined
    >((resolve, reject) => {
      const tx = db.transaction('state_cache', 'readonly');
      const store = tx.objectStore('state_cache');
      const request = store.get('current');
      request.onsuccess = () =>
        resolve(
          isRecord(request.result)
            ? (request.result as { state?: Record<string, unknown> })
            : undefined,
        );
      request.onerror = () => reject(request.error);
    });
    db.close();

    return stateCacheEntry?.state
      ? getTimeSpentFromRootState(stateCacheEntry.state)
      : null;
  }, taskName);

/**
 * Wait for a task's persisted timeSpent to be greater than zero.
 *
 * @param client - The simulated E2E client
 * @param taskName - The task name
 * @param timeout - Maximum time to wait for timeSpent
 * @returns The persisted timeSpent value in milliseconds
 */
export const waitForTaskTimeSpent = async (
  client: SimulatedE2EClient,
  taskName: string,
  timeout = UI_VISIBLE_TIMEOUT,
): Promise<number> => {
  await expect
    .poll(async () => (await getTaskTimeSpentFromState(client, taskName)) ?? 0, {
      timeout,
      intervals: [250, 500, 1000],
    })
    .toBeGreaterThan(0);

  return (await getTaskTimeSpentFromState(client, taskName))!;
};

/**
 * Check if a task has the given text (exists on client).
 *
 * @param client - The simulated E2E client
 * @param taskName - The task name to check
 * @returns true if the task exists
 */
export const hasTaskOnClient = async (
  client: SimulatedE2EClient,
  taskName: string,
): Promise<boolean> => {
  return hasTask(client.page, taskName);
};

// ============================================================================
// Test Setup Helpers
// ============================================================================

/**
 * Generate a unique test run ID for data isolation.
 * This replaces the common `generateTestRunId` function in test files.
 *
 * @param workerIndex - The Playwright worker index from testInfo
 * @returns A unique test run ID string
 */
export const generateTestRunId = (workerIndex: number): string => {
  return `${Date.now()}-${workerIndex}`;
};

// ============================================================================
// UI HELPERS - For direct DOM interactions during tests
// ============================================================================

/**
 * Reliably create a project through the UI.
 * Handles sidebar state, hover-to-reveal buttons, and dialog interaction.
 *
 * @param page - The Playwright page
 * @param projectName - The name for the new project
 */
export const createProjectReliably = async (
  page: Page,
  projectName: string,
): Promise<void> => {
  await page.goto('/#/tag/TODAY/work');
  await page.waitForLoadState('networkidle');
  await page.waitForTimeout(UI_SETTLE_EXTENDED);

  // Ensure sidebar is in full mode (visible labels)
  const navSidenav = page.locator('.nav-sidenav');
  if (await navSidenav.isVisible()) {
    const isCompact = await navSidenav.evaluate((el) =>
      el.classList.contains('compactMode'),
    );
    if (isCompact) {
      const toggleBtn = navSidenav.locator('.mode-toggle');
      if (await toggleBtn.isVisible()) {
        await toggleBtn.click();
        await page.waitForTimeout(UI_SETTLE_STANDARD);
      }
    }
  }

  // Find the Projects section wrapper
  const projectsTree = page
    .locator('nav-list-tree')
    .filter({ hasText: 'Projects' })
    .first();
  await projectsTree.waitFor({ state: 'visible' });

  // Hover the group header to make additional buttons visible and clickable
  // (buttons have pointer-events: none by default, only enabled on hover)
  const groupNavItem = projectsTree.locator('nav-item').first();
  await groupNavItem.hover();
  await page.waitForTimeout(UI_SETTLE_SMALL);

  // Target the button element (not the mat-icon inside it)
  const addBtn = projectsTree.locator(
    '.additional-btns button[mat-icon-button]:has(mat-icon:text("add"))',
  );
  try {
    await addBtn.waitFor({ state: 'visible', timeout: 5000 });
    await addBtn.click();
  } catch {
    await addBtn.click({ force: true });
  }

  // Dialog
  const nameInput = page.getByRole('textbox', { name: 'Project Name' });
  await nameInput.waitFor({ state: 'visible', timeout: UI_VISIBLE_TIMEOUT_LONG });
  await nameInput.fill(projectName);

  const submitBtn = page.locator('dialog-create-project button[type=submit]').first();
  await submitBtn.click();

  // Wait for dialog to close
  await nameInput.waitFor({ state: 'hidden', timeout: UI_VISIBLE_TIMEOUT });

  // Wait for project to appear
  await page.waitForTimeout(UI_SETTLE_EXTENDED);
};

// ============================================================================
// ARCHIVE & WORKLOG HELPERS - For archive-related E2E tests
// ============================================================================

/**
 * Mark a task as done by pressing 'd' key.
 * The task must be visible and focusable on the current page.
 *
 * @param client - The simulated E2E client
 * @param taskName - The task name to mark as done
 */
export const markTaskDoneByKey = async (
  client: SimulatedE2EClient,
  taskName: string,
): Promise<void> => {
  const escapedName = escapeForSelector(taskName);
  const task = client.page
    .locator(`task:not(.ng-animating):has-text("${escapedName}")`)
    .first();
  await task.waitFor({ state: 'visible', timeout: UI_VISIBLE_TIMEOUT });
  await task.focus();
  await client.page.waitForTimeout(UI_SETTLE_SMALL);
  await task.press('d');
  // Wait for task to show done state
  await expect(task).toHaveClass(/isDone/, { timeout: UI_VISIBLE_TIMEOUT });
};

/**
 * Archive done tasks via the Daily Summary flow.
 * Navigates to Daily Summary, clicks "Save & Go Home" to archive all done tasks.
 *
 * @param client - The simulated E2E client
 */
export const archiveDoneTasks = async (client: SimulatedE2EClient): Promise<void> => {
  // Click the "Finish Day" button to go to Daily Summary.
  // The button is a routerLink inside an @if (hasDoneTasks()) / @else swap, so
  // marking a task done re-creates the element; a single click can land in the
  // window before Angular wires up the routerLink and silently no-ops (30s hang).
  // Racing waitForURL against a one-shot click doesn't cover that gap, so re-issue
  // the click until navigation actually starts.
  const finishDayBtn = client.page.locator('.e2e-finish-day');
  await finishDayBtn.waitFor({ state: 'visible', timeout: UI_VISIBLE_TIMEOUT });
  await expect(async () => {
    await finishDayBtn.click();
    await client.page.waitForURL(/daily-summary/, { timeout: 2000 });
  }).toPass({ timeout: UI_VISIBLE_TIMEOUT });

  // Click "Save & Go Home" button (has sun icon wb_sunny)
  const saveAndGoHomeBtn = client.page.locator(
    'daily-summary button[mat-flat-button]:has(mat-icon:has-text("wb_sunny"))',
  );
  await saveAndGoHomeBtn.waitFor({ state: 'visible', timeout: UI_VISIBLE_TIMEOUT });
  // Wait for navigation back to work view.
  // Use negative lookahead: /(active\/tasks|tag\/TODAY)/ would match the
  // current /daily-summary URL; adding (?!\/daily-summary) ensures we wait
  // for a real navigation. Also handles tag/TODAY without /tasks suffix.
  await Promise.all([
    client.page.waitForURL(/(active\/tasks|tag\/TODAY(?!\/daily-summary))/),
    saveAndGoHomeBtn.click(),
  ]);
  await client.page.waitForTimeout(UI_SETTLE_STANDARD);
};

/**
 * Archive a specific task by marking it done and going through Daily Summary.
 * Combines markTaskDoneByKey and archiveDoneTasks for convenience.
 *
 * @param client - The simulated E2E client
 * @param taskName - The task name to archive
 */
export const archiveTask = async (
  client: SimulatedE2EClient,
  taskName: string,
): Promise<void> => {
  await markTaskDoneByKey(client, taskName);
  await archiveDoneTasks(client);
};

/**
 * Navigate to worklog and check for a specific task in archived entries.
 * Skips navigation if already on the worklog page to avoid re-render issues
 * when checking multiple tasks in succession.
 *
 * @param client - The simulated E2E client
 * @param taskName - The task name to search for in worklog
 * @returns true if the task is found in worklog
 */
export const hasTaskInWorklog = async (
  client: SimulatedE2EClient,
  taskName: string,
): Promise<boolean> => {
  // Only navigate if not already on worklog page
  const currentUrl = client.page.url();
  if (!currentUrl.includes('/history')) {
    await client.page.goto('/#/tag/TODAY/history');
    await client.page.waitForLoadState('networkidle');
    await client.page.waitForTimeout(UI_SETTLE_EXTENDED);
  }

  // Expand week rows that aren't already expanded
  const weekRows = client.page.locator('.week-row');
  const weekCount = await weekRows.count();
  for (let i = 0; i < Math.min(weekCount, 5); i++) {
    const row = weekRows.nth(i);
    if (await row.isVisible()) {
      const isExpanded = await row.evaluate((el) => el.classList.contains('isExpanded'));
      if (!isExpanded) {
        await row.click().catch(() => {});
        await client.page.waitForTimeout(UI_SETTLE_STANDARD);
      }
    }
  }

  // Wait for expanded task table, then search for the task
  const escapedName = escapeForSelector(taskName);
  const taskEntry = client.page.locator(
    `.task-summary-table .task-title:has-text("${escapedName}")`,
  );

  return taskEntry
    .first()
    .waitFor({ state: 'visible', timeout: 10000 })
    .then(() => true)
    .catch(() => false);
};

/**
 * Assert that a task appears in the worklog.
 *
 * @param client - The simulated E2E client
 * @param taskName - The task name to verify in worklog
 */
export const expectTaskInWorklog = async (
  client: SimulatedE2EClient,
  taskName: string,
): Promise<void> => {
  const found = await hasTaskInWorklog(client, taskName);
  if (!found) {
    throw new Error(`Expected task "${taskName}" to be in worklog, but it was not found`);
  }
};

/**
 * Assert that a task does NOT appear in the worklog.
 *
 * @param client - The simulated E2E client
 * @param taskName - The task name that should NOT be in worklog
 */
export const expectTaskNotInWorklog = async (
  client: SimulatedE2EClient,
  taskName: string,
): Promise<void> => {
  const found = await hasTaskInWorklog(client, taskName);
  if (found) {
    throw new Error(`Expected task "${taskName}" NOT to be in worklog, but it was found`);
  }
};

/**
 * Get the count of worklog entries (archived tasks).
 *
 * @param client - The simulated E2E client
 * @returns The number of task entries in worklog
 */
export const getWorklogTaskCount = async (
  client: SimulatedE2EClient,
): Promise<number> => {
  // Navigate to worklog
  await client.page.goto('/#/tag/TODAY/history');
  await client.page.waitForLoadState('networkidle');
  await client.page.waitForTimeout(UI_SETTLE_STANDARD);

  // Expand week rows
  const weekRows = client.page.locator('.week-row');
  const weekCount = await weekRows.count();
  for (let i = 0; i < Math.min(weekCount, 3); i++) {
    const row = weekRows.nth(i);
    if (await row.isVisible()) {
      await row.click().catch(() => {});
      await client.page.waitForTimeout(UI_SETTLE_SMALL);
    }
  }

  // Count task entries
  const taskEntries = await client.page
    .locator('.task-summary-table .task-title, .worklog-task, worklog-task')
    .count()
    .catch(() => 0);

  return taskEntries;
};

/**
 * Navigate back to work view from worklog or other pages.
 *
 * @param client - The simulated E2E client
 */
export const navigateToWorkView = async (client: SimulatedE2EClient): Promise<void> => {
  await client.page.goto('/#/tag/TODAY/tasks');
  await client.page.waitForLoadState('networkidle');
  await client.page.waitForTimeout(UI_SETTLE_STANDARD);
};

// ============================================================================
// ENCRYPTION DIALOG HELPERS
// ============================================================================

/**
 * Handle the encryption warning dialog that appears when importing a backup
 * with different encryption settings than the current app state.
 *
 * The dialog (dialog-import-encryption-warning) has a warn-colored confirm button.
 * If the dialog does not appear within the timeout, this is a no-op.
 *
 * @param page - The Playwright page
 * @param label - Optional label for console logging (e.g. "[importBackup]")
 */
export const handleEncryptionWarningDialog = async (
  page: Page,
  label = '[handleEncryptionWarningDialog]',
): Promise<void> => {
  const encryptionWarning = page.locator('dialog-import-encryption-warning');
  const warningAppeared = await encryptionWarning
    .waitFor({ state: 'visible', timeout: UI_VISIBLE_TIMEOUT_SHORT })
    .then(() => true)
    .catch(() => false);

  if (warningAppeared) {
    console.log(`${label} Encryption warning dialog appeared - confirming import`);
    const confirmBtn = encryptionWarning.locator('button[color="warn"]');
    await confirmBtn.click();
    await encryptionWarning.waitFor({
      state: 'hidden',
      timeout: UI_VISIBLE_TIMEOUT_LONG,
    });
  }
};
