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

/**
 * Generic action library for Convex-backed application processes
 * (ADR-0025). Process-specific UI flows live in
 * `apps/e2e/src/process-actions/<process>-actions.ts` and build on the
 * helpers here.
 *
 * Conventions:
 * - Data setup goes through Convex internal mutations
 *   (`internal.e2e.createConvexApplication` / `.deleteApplication`).
 *   UI-driven setup is too slow and depends on the import path that
 *   itself needs E2E coverage separately.
 * - Workflow steps (filling fields, completing a step, changing status)
 *   go through the UI. That is the contract of these helpers: prove
 *   the user can drive the application from the rendered admin pages.
 * - Locators key off `data-testid` attributes set by the generic
 *   components in `libs/angular-ui/src/ui-applications-convex/`.
 */

export interface Stammdaten {
  gender?: string;
  givenName?: string;
  familyName?: string;
  email?: string;
  phone?: string;
  birthDate?: string;
  birthPlace?: string;
  nationality?: string;
  semester?: string;
  comments?: string;
}

export interface CreateApplicationArgs {
  customer: string;
  process: string;
  applicationId?: string;
  applicantId?: string;
  stammdaten?: Stammdaten;
  meta?: Record<string, unknown>;
  /** Default true: transitions status from 'new' to 'in_progress' and creates initial deps-free steps. */
  started?: boolean;
}

export interface CreatedApplication {
  /** Convex doc id, used for deletion. */
  _id: string;
  /** 10-digit application number used in admin URLs. */
  applicationId: string;
}

let uniqueCounter = 0;
function uniqueApplicationId(prefix: string): string {
  uniqueCounter += 1;
  // 10-char string with the spec-name prefix tail + millisecond + counter.
  // Keeps unique across parallel workers without coordination.
  const ts = Date.now().toString(36).slice(-5).toUpperCase();
  const tail = uniqueCounter.toString(36).padStart(2, '0').toUpperCase();
  return `${prefix.slice(0, 3).toUpperCase()}${ts}${tail}`.slice(0, 10);
}

/**
 * Create a Convex application via the e2e backend mutation. Returns
 * the Convex doc id and the public 10-digit applicationId. Idempotent
 * on `applicationId`; if you pass one explicitly and it already
 * exists, the existing application is returned.
 *
 * Defaults populate sensible Stammdaten so every test starts with a
 * recognisable applicant.
 */
export async function createApplication(
  args: CreateApplicationArgs
): Promise<CreatedApplication> {
  const applicationId =
    args.applicationId ?? uniqueApplicationId(args.process);
  const stammdaten: Stammdaten = {
    givenName: 'E2E',
    familyName: `Bewerber-${applicationId.slice(-4)}`,
    email: `e2e+${applicationId.toLowerCase()}@example.com`,
    nationality: 'DEU',
    semester: 'WS2627',
    ...args.stammdaten,
  };
  return await convexRun<CreatedApplication>(
    'e2e:createConvexApplication',
    {
      customer: args.customer,
      process: args.process,
      applicationId,
      applicantId: args.applicantId,
      stammdaten,
      meta: args.meta,
      started: args.started,
    }
  );
}

/** Hard-delete an application and all dependent records (steps, fields, outputs). */
export async function deleteApplication(applicationDocId: string): Promise<void> {
  await convexRun('e2e:deleteApplication', {
    applicationId: applicationDocId,
  });
}

// ---------------------------------------------------------------------------
// Navigation
// ---------------------------------------------------------------------------

export async function gotoApplicationDashboard(
  page: Page,
  process: string
): Promise<void> {
  await page.goto(`/admin/applications/${process}/dashboard`);
  await expect(page.locator('masterev-dashboard-section').first()).toBeVisible({
    timeout: 10_000,
  });
}

export async function gotoApplicationList(
  page: Page,
  process: string,
  status?: string
): Promise<void> {
  // The Convex routes use `/list/:stage` (`total` is the all-stages
  // bucket); status is a query param. The legacy `/list` redirects to
  // `/list/total`, but going there directly avoids the redirect race.
  const path = status
    ? `/admin/applications/${process}/list/total?status=${status}`
    : `/admin/applications/${process}/list/total`;
  await page.goto(path);
  await expect(
    page.locator('[data-testid="application-list-table"]')
  ).toBeVisible({ timeout: 10_000 });
}

export async function gotoApplicationDetail(
  page: Page,
  process: string,
  applicationDocId: string
): Promise<void> {
  await page.goto(
    `/admin/applications/${process}/details/${applicationDocId}`
  );
  await expect(
    page.locator(`[data-testid="application-detail-${applicationDocId}"]`)
  ).toBeVisible({ timeout: 10_000 });
}

/** Click a row in the application list and wait for the detail page. */
export async function openApplicationFromList(
  page: Page,
  applicationDocId: string
): Promise<void> {
  await page.locator(`[data-testid="application-row-${applicationDocId}"]`).click();
  await expect(
    page.locator(`[data-testid="application-detail-${applicationDocId}"]`)
  ).toBeVisible({ timeout: 10_000 });
}

// ---------------------------------------------------------------------------
// Step interactions
// ---------------------------------------------------------------------------

/**
 * Parse `<stage>.<step>` from a fully-qualified Convex field key
 * (`<stage>.<step>.<sub>`). Returns `null` if the key is malformed.
 */
function parseStepRefFromFieldKey(
  fieldKey: string
): { stage: string; step: string } | null {
  const parts = fieldKey.split('.');
  if (parts.length < 3) return null;
  const [stage, step] = parts;
  if (!stage || !step) return null;
  return { stage, step };
}

/**
 * Ensure the application detail page is showing the step input form
 * for `<stage>.<step>` rather than a sibling sub-route (e.g.
 * `/results`, `/<otherStage>/<otherStep>`).
 *
 * Background: `BwlDetailComponent` (and parity components for other
 * processes) auto-redirect a bare `/details/:id` to `/details/:id/results`
 * so admins land on the overview. Step input forms only render when the
 * URL is `/details/:id/<stage>/<step>`. Tests that previously relied on
 * the bare-URL fallthrough now must navigate to the step explicitly.
 *
 * The helper is a no-op when the matching `step-host-<stage>.<step>`
 * is already in the DOM, so callers that have already done their own
 * navigation pay nothing extra.
 */
export async function ensureStepView(
  page: Page,
  stage: string,
  step: string
): Promise<void> {
  const stepHost = page.locator(
    `[data-testid="step-host-${stage}.${step}"]`
  );
  if ((await stepHost.count()) > 0) {
    if (await stepHost.first().isVisible()) return;
  }
  // Derive `/admin/applications/<process>/details/<docId>` prefix from
  // the current URL. Pattern: `/admin/applications/:process/details/:id[...]`.
  const url = new URL(page.url());
  const match = url.pathname.match(
    /^(\/admin\/applications\/[^/]+\/details\/[^/]+)/
  );
  if (!match) {
    throw new Error(
      `ensureStepView: cannot derive detail URL from ${url.pathname}`
    );
  }
  await page.goto(`${match[1]}/${stage}/${step}`);
  await expect(stepHost).toBeVisible({ timeout: 10_000 });
}

/**
 * Type a value into a `<masterev-reactive-field>` input identified by
 * the persisted Convex `fieldKey` (`<stage>.<step>.<sub>`). Drives
 * real key events (focus + select-all + type + Tab) so PrimeNG's
 * `p-inputNumber` keystroke handler fires — `Locator.fill()` ignores
 * that handler and leaves `data-field-status` at `'required'`.
 *
 * After the blur, polls `data-field-status` first into the running
 * set (`saving|ok`) and then into the success set (`ok`) so the next
 * action does not race the auto-save. A field that reaches `'error'`
 * is treated as a hard failure: the helper throws, instead of
 * silently moving on with stale data.
 *
 * Works for `string`, `number`, and `textarea` types. Use
 * {@link fillReactiveDateField} for `date` fields — `p-datepicker`'s
 * input does not parse typed text reliably across locales.
 */
export async function fillReactiveField(
  page: Page,
  fieldKey: string,
  value: string | number
): Promise<void> {
  const ref = parseStepRefFromFieldKey(fieldKey);
  if (ref) await ensureStepView(page, ref.stage, ref.step);
  const field = page.locator(
    `masterev-reactive-field[data-field-key="${fieldKey}"]`
  );
  await field.scrollIntoViewIfNeeded();
  const input = field.locator('input, textarea').first();
  await input.focus();
  await input.press('ControlOrMeta+a');
  await input.press('Delete');
  await input.pressSequentially(String(value));
  await input.press('Tab');
  // The autosave is debounced (800 ms) and then fires a Convex
  // mutation. The reactive field reflects this in `data-field-status`:
  //   1. Right after typing it is `idle` (debounce armed, no save yet)
  //   2. While the mutation is in flight: `saving`
  //   3. On success: `ok`; on failure: `error`
  // Wait for the field to leave `idle` (i.e. the debounce fires + the
  // mutation lands). The 30s budget tolerates the BMT detail page's
  // `d3.listDocuments` action fanout (one external fetch to
  // dms.zv.tum.de per detail mount, multiplied by every parallel
  // worker) saturating Convex's action runner; in CI against staging
  // this typically completes in <2s, but the local dev server can
  // queue the actions when the suite runs with the default
  // worker count.
  await expect(field).toHaveAttribute('data-field-status', 'ok', {
    timeout: 30_000,
  });
}

/**
 * Set a date-typed `<masterev-reactive-field>` by opening its
 * `p-datepicker` popup and clicking today's calendar cell. We do not
 * accept a date value: typed input is locale-fragile and no current
 * spec asserts on the persisted date — they only need the field to
 * be filled so the step's `completeStep` button enables.
 */
export async function fillReactiveDateField(
  page: Page,
  fieldKey: string
): Promise<void> {
  const ref = parseStepRefFromFieldKey(fieldKey);
  if (ref) await ensureStepView(page, ref.stage, ref.step);
  const field = page.locator(
    `masterev-reactive-field[data-field-key="${fieldKey}"]`
  );
  await field.scrollIntoViewIfNeeded();
  // The trigger button is the only element with `aria-haspopup="dialog"`
  // inside a p-datepicker host.
  await field.locator('p-datepicker [aria-haspopup="dialog"]').first().click();
  const popup = page.locator('.p-datepicker-panel:visible').first();
  await expect(popup).toBeVisible({ timeout: 5_000 });
  // PrimeNG v18 day cells render as `<td class="p-datepicker-today">`
  // with the actual click handler on the inner
  // `<span class="p-datepicker-day">`. Click the span; clicking the
  // TD does not fire the day-select event.
  await popup
    .locator('.p-datepicker-today .p-datepicker-day')
    .first()
    .click();
  await expect(popup).not.toBeVisible({ timeout: 5_000 });
  await expect(field).toHaveAttribute('data-field-status', 'ok', {
    timeout: 15_000,
  });
}

/** Click "Schritt abschliessen" on a step host and wait for the result tag to flip. */
export async function completeStep(
  page: Page,
  stage: string,
  step: string
): Promise<void> {
  await ensureStepView(page, stage, step);
  const ref = `${stage}.${step}`;
  const button = page.locator(`[data-testid="complete-step-${ref}"]`);
  await button.click();
  // Wait for the result attribute on the host to flip to 'done'. The
  // longer 20s budget tolerates the engine-pass + Convex query
  // round-trip when many parallel tests share the same backend.
  await expect(
    page.locator(`[data-testid="step-host-${ref}"]`)
  ).toHaveAttribute('data-step-result', 'done', { timeout: 20_000 });
}

/** Click "Schritt erneut oeffnen" (admin-only). */
export async function reopenStep(
  page: Page,
  stage: string,
  step: string,
  options: { confirm?: boolean } = {}
): Promise<void> {
  await ensureStepView(page, stage, step);
  const ref = `${stage}.${step}`;
  // Two patterns coexist in the codebase:
  //   * BMT detail (Convex): a step menu (hamburger) with a "Reopen"
  //     entry, addressed by `data-testid="step-menu-<ref>"`.
  //   * Generic `<masterev-step-host>`: a dedicated outline button
  //     with `data-testid="reopen-step-<ref>"`.
  // Race the two locators; whichever shows up first is the right path
  // for this customer.
  const stepMenuTrigger = page.locator(`[data-testid="step-menu-${ref}"]`);
  const directReopenButton = page.locator(`[data-testid="reopen-step-${ref}"]`);
  await Promise.race([
    stepMenuTrigger.first().waitFor({ state: 'visible', timeout: 10_000 }),
    directReopenButton.first().waitFor({ state: 'visible', timeout: 10_000 }),
  ]);
  if ((await stepMenuTrigger.count()) > 0) {
    await stepMenuTrigger.click();
    await page
      .getByRole('menuitem', { name: /erneut bearbeiten|erneut öffnen|reopen/i })
      .first()
      .click();
  } else {
    await directReopenButton.click();
  }
  if (options.confirm ?? true) {
    await page
    .getByRole('alertdialog')
    .getByRole('button', { name: /^(OK|Ja|Bestätigen|Confirm|Yes|Löschen|Delete)$/i })
    .click();
  }
  await expect(
    page.locator(`[data-testid="step-host-${ref}"]`)
  ).toHaveAttribute('data-step-result', /^(new|in_progress)$/, {
    timeout: 10_000,
  });
}

/**
 * Set application status via the status menu. The menu is admin-only;
 * tests must run as ADMIN.
 *
 * Every manual status (accept / reject / invalid / withdrawn) opens a
 * dedicated dialog with optional preset + free-text reason. `invalid`
 * enforces a required reason at the dialog validator; the others
 * accept `reason === undefined` (submits with no reason text).
 *
 * The status menu items are labelled "Manual approval / Manually reject
 * / Mark as invalid / Withdrawn" for the fresh case and "Edit <X>
 * reason" once an existing `manualDecision` of matching type is present.
 * Both label forms match the regex below.
 */
export async function setApplicationStatus(
  page: Page,
  applicationDocId: string,
  status: 'accepted' | 'rejected' | 'invalid' | 'withdrawn',
  reason?: string
): Promise<void> {
  await page.locator('[data-testid="application-status-menu"]').click();
  const labelPattern =
    status === 'accepted'
      ? /approval|annehmen|annahme|zulass/i
      : status === 'rejected'
        ? /reject|ablehn/i
        : status === 'invalid'
          ? /invalid|ungültig|ungultig/i
          : /withdrawn|zurückg|zuruckg/i;
  // PrimeNG v18 renders menu items with role=menuitem; the legacy
  // `.p-menu .p-menuitem-link` selector no longer matches. getByRole
  // is the version-agnostic locator and survives PrimeNG bumps.
  await page
    .getByRole('menuitem', { name: labelPattern })
    .first()
    .click();

  const dialogPrefix =
    status === 'accepted'
      ? 'accept-dialog'
      : status === 'rejected'
        ? 'reject-dialog'
        : status === 'invalid'
          ? 'invalid-dialog'
          : 'withdrawn-dialog';
  if (reason !== undefined) {
    await page.locator(`[data-testid="${dialogPrefix}-reason"]`).fill(reason);
  }
  await page.locator(`[data-testid="${dialogPrefix}-submit"]`).click();

  await expect(
    page.locator(`[data-testid="application-detail-${applicationDocId}"]`)
  ).toHaveAttribute('data-application-status', status, { timeout: 10_000 });
}

/**
 * Click "Reset application" and confirm. Only available when the
 * application is `startedAt`-set (the split-button is otherwise the
 * "Start Application" variant). Asserts the status flips to `'new'`.
 */
export async function resetApplicationViaUi(
  page: Page,
  applicationDocId: string
): Promise<void> {
  await page
    .locator('[data-testid="application-action-reset"]')
    .first()
    .click();
  await page
    .getByRole('alertdialog')
    .getByRole('button', { name: /^(OK|Ja|Bestätigen|Confirm|Yes)$/i })
    .click();
  await expect(
    page.locator(`[data-testid="application-detail-${applicationDocId}"]`)
  ).toHaveAttribute('data-application-status', 'new', { timeout: 10_000 });
}

// ---------------------------------------------------------------------------
// Assertions
// ---------------------------------------------------------------------------

export async function expectApplicationStatus(
  page: Page,
  applicationDocId: string,
  status: 'new' | 'in_progress' | 'accepted' | 'rejected' | 'invalid' | 'withdrawn'
): Promise<void> {
  await expect(
    page.locator(`[data-testid="application-detail-${applicationDocId}"]`)
  ).toHaveAttribute('data-application-status', status, { timeout: 10_000 });
}

export async function expectStepResult(
  page: Page,
  stage: string,
  step: string,
  result: 'new' | 'in_progress' | 'skipped' | 'done'
): Promise<void> {
  await ensureStepView(page, stage, step);
  await expect(
    page.locator(`[data-testid="step-host-${stage}.${step}"]`)
  ).toHaveAttribute('data-step-result', result, { timeout: 10_000 });
}

/**
 * Assert the live preview panel shows the given decision badge for
 * the step (per ADR-0018 the calc returns `'accept' | 'reject' |
 * undefined`; this checks the rendered badge severity).
 */
export async function expectPreviewDecision(
  page: Page,
  stage: string,
  step: string,
  decision: 'accept' | 'reject'
): Promise<void> {
  await ensureStepView(page, stage, step);
  const stepHost = page.locator(`[data-testid="step-host-${stage}.${step}"]`);
  // PrimeNG v18 renders `<p-tag>` with the severity class on the host
  // element itself (e.g. `class="p-component p-tag p-tag-success"`),
  // not on an inner child. Asserting on a `.p-tag` descendant therefore
  // always misses; the class lives on the `<p-tag>` itself.
  const tag = stepHost.locator('.preview-panel p-tag').first();
  const expectedClass =
    decision === 'accept' ? 'p-tag-success' : 'p-tag-danger';
  await expect(tag).toHaveClass(new RegExp(expectedClass), {
    timeout: 10_000,
  });
}

/** Select a step in the legacy-parity progress sidebar by its visible label. */
export async function selectStepByLabel(
  page: Page,
  label: string
): Promise<void> {
  await page.getByRole('button', { name: label }).click();
}

/**
 * Soft-delete an application via the toolbar dropdown on the result
 * page. Uses the SplitButton chevron + the "Delete" menu item, then
 * accepts the confirm dialog. Asserts the deleted banner is shown
 * afterwards.
 */
export async function softDeleteApplicationViaUi(
  page: Page,
  applicationDocId: string
): Promise<void> {
  // SplitButton chevron has no stable text/testid in PrimeNG v18 — rely
  // on the .p-splitbutton-dropdown class scoped to the testid'd host.
  const splitButton = page
    .locator(
      '[data-testid="application-action-start"], [data-testid="application-action-reset"]'
    )
    .first();
  // PrimeNG v18 renders the chevron either as `.p-splitbutton-dropdown`
  // or, in newer minor versions, as a button with `aria-haspopup`. Try
  // both via .or() for resilience.
  const dropdown = splitButton
    .locator('.p-splitbutton-dropdown')
    .or(splitButton.getByRole('button').nth(1));
  await dropdown.first().click();
  await page
    .getByRole('menuitem', { name: /delete|löschen|loschen/i })
    .first()
    .click();
  await page
    .getByRole('alertdialog')
    .getByRole('button', { name: /^(OK|Ja|Bestätigen|Confirm|Yes|Löschen|Delete)$/i })
    .click();
  await expect(
    page.locator('[data-testid="application-deleted-banner"]')
  ).toBeVisible({ timeout: 10_000 });
  await expect(
    page.locator(`[data-testid="application-detail-${applicationDocId}"]`)
  ).toHaveAttribute('data-application-status', 'deleted', { timeout: 10_000 });
}

/** Switch to the e-mails tab and assert the canonical mail-list is rendered. */
export async function switchToMailsTab(page: Page): Promise<void> {
  await page.locator('[data-testid="results-tab-mails"]').click();
  // The reused `MailSentListComponent` from `ui-api-mails` renders its
  // list via `<masterev-infinite-data-table>`; the inner data-table div
  // carries `data-testid="data-table"`.
  await expect(
    page
      .locator('masterev-mail-sent-list [data-testid="data-table"]')
      .first()
  ).toBeVisible({ timeout: 10_000 });
}

/**
 * Open the canonical compose dialog, send a mail, assert it shows up in
 * the list. The compose dialog (`MailComposeComponent` from
 * `ui-api-mails`) uses Quill for the HTML body and PrimeNG inputs for
 * the rest, so we drive it via accessible roles rather than testids.
 */
export async function sendMailViaCompose(
  page: Page,
  args: { subject: string; body: string }
): Promise<void> {
  await page
    .getByRole('button', { name: /Verfassen|Compose/i })
    .first()
    .click();
  const dialog = page.getByRole('dialog');
  // Subject is the only single-line text input that surfaces a label
  // matching "Betreff/Subject"; recipient is pre-filled by the caller.
  // Reusable mail-compose dialog has no `<label for>` linkage, so
  // address fields by their formControlName (PrimeNG forwards it onto
  // the rendered input/textarea).
  await dialog
    .locator('input[formcontrolname="subject"]')
    .fill(args.subject);
  // Quill's contenteditable is a `.ql-editor` div.
  await dialog.locator('.ql-editor').first().fill(args.body);
  await dialog
    .getByRole('button', { name: /^(Absenden|Senden|Send)$/i })
    .click();
  await expect(
    page
      .locator('masterev-mail-sent-list', { hasText: args.subject })
      .first()
  ).toBeVisible({ timeout: 10_000 });
}

/** Click a status card on the dashboard and verify the list filters down to that status. */
export async function clickDashboardCard(
  page: Page,
  process: string,
  status: string
): Promise<void> {
  // Match the i18n labels (`components.stats.totals.*`):
  //   succeeded -> "Zugelassen"
  //   failed    -> "Abgelehnt"
  // plus English fallbacks for non-German installs.
  const label =
    status === 'accepted'
      ? /Zugelassen|angenommen|accepted|succeeded|erfolgreich/i
      : status === 'rejected'
        ? /Abgelehnt|rejected|failed/i
        : new RegExp(status, 'i');
  await page.locator('masterev-stats-item', { hasText: label }).first().click();
  // Stats-item links use the path-status alias when one is registered
  // (`/list/succeeded`, `/list/failed`, ...), and only fall back to
  // `/list/total?status=...` for statuses without an alias. See
  // PATH_STATUS_ALIASES in `application-list-route.component.ts`.
  const aliasFor: Record<string, string> = {
    accepted: 'succeeded',
    rejected: 'failed',
  };
  const segment = aliasFor[status] ?? status;
  await expect(page).toHaveURL(
    new RegExp(
      `/admin/applications/${process}/list/(${segment}|total\\?(.*&)?status=${status}(&|$))`
    )
  );
  await expect(
    page.locator('[data-testid="application-list-table"]')
  ).toBeVisible({ timeout: 10_000 });
}
