import type { ProcessActions } from '../process-actions.js';
import { expect, test } from '../../fixtures.js';
import * as apps from '../../helpers/applications.js';
import { convexRun } from '../../helpers/convex.js';

/**
 * Manual status changes (`accept`, `reject`, `invalid`, `withdrawn`)
 * with reason capture, and the toggle paths between them. Drives the
 * full state-machine spec
 * (`openspec/changes/refactor-application-status-state-machine`):
 *
 *   - each manual transition is wholesale overwriting; no trace of the
 *     prior decision remains in the document,
 *   - withdrawn is now a full manual decision with optional reason
 *     (not the legacy confirm-only flow),
 *   - reset clears the decision so the application is indistinguishable
 *     from a never-decided one in the same status.
 *
 * Process-agnostic: every process registered through the suite-builder
 * picks this spec up. Skip is handled centrally by
 * `defineProcessTestSuite`.
 */
export function registerStatusLifecycleSpec(actions: ProcessActions): void {
  test.describe(`Status Lifecycle for ${actions.process}`, () => {
    // Each test seeds an application, drives stage 1 through the field
    // chain, and writes ~5 Convex mutations per step. Running them in
    // parallel against a single dev Convex deployment regularly stacks
    // up enough latency to push field auto-save past the assertion
    // timeout. Serial execution keeps the per-test contention low and
    // matches how the suite is run on staging (one Playwright project
    // per customer, single Convex backend per project).
    test.describe.configure({ mode: 'serial' });

    test.beforeEach(({ customerConfig }) => {
      test.skip(
        customerConfig.name !== actions.customer,
        `Suite for ${actions.process} only runs against ${actions.customer} (got ${customerConfig.name})`
      );
    });

    test('manual accept → reject → accept toggles the application status', async ({
      adminPage,
    }) => {
      const application = await actions.seedApplication();
      try {
        await actions.driveToAcceptableState(adminPage, application);

        await apps.setApplicationStatus(
          adminPage,
          application._id,
          'rejected',
          'manual reject for E2E lifecycle'
        );
        await apps.expectApplicationStatus(
          adminPage,
          application._id,
          'rejected'
        );

        await apps.setApplicationStatus(
          adminPage,
          application._id,
          'accepted',
          'reverting to accept for E2E lifecycle'
        );
        await apps.expectApplicationStatus(
          adminPage,
          application._id,
          'accepted'
        );
      } finally {
        await apps.deleteApplication(application._id);
      }
    });

    test('stage-1 reject keeps status in_progress until stage-2 admin step is done', async ({
      adminPage,
    }) => {
      // Only processes that opt in via `statusLifecycleDeferredFinalization`
      // have a post-Stage-1 admin step. Processes without it complete the
      // status flip during Stage 1 (no Stage 2 work to defer to) and the
      // assertion below would fail by design.
      // ADR-0018 Update 2026-05-11.
      if (!actions.capabilities?.statusLifecycleDeferredFinalization) {
        test.skip(
          true,
          `process ${actions.process} has no deferred finalisation (no post-Stage-1 admin step); behaviour is identical to legacy lifecycle`
        );
      }

      const application = await actions.seedApplication();
      try {
        // Drive Stage 1 to a reject outcome. After this the underlying
        // step output carries `decision: 'reject'` but the engine
        // keeps the application `in_progress` because the post-
        // decision admin step (BMT `s2.export`) is still open.
        await actions.driveToRejectableState(adminPage, application);
        await apps.expectApplicationStatus(
          adminPage,
          application._id,
          'in_progress'
        );

        // openspec/denormalize-active-step-keys: after Stage 1 closes
        // with a rejection-decision, only the post-decision admin step
        // (BMT `s2.export`) remains active. The denormalised array
        // on the application doc must reflect exactly that — Stage 1
        // tokens are gone, the post-decision step token is present.
        const tokensAfterReject = await convexRun<string[] | null>(
          'e2e:getApplicationActiveStageStepKeys',
          { applicationId: application._id }
        );
        expect(tokensAfterReject).not.toBeNull();
        expect(tokensAfterReject).toContain('s2:export');
        expect(
          tokensAfterReject!.some((k) => k.startsWith('s1:'))
        ).toBe(false);

        // Sanity: the application must still appear in the stage-2
        // step view. Before the fix the implicit status post-filter
        // hid rejected apps from this list and the admin had no way
        // to find their pending TUMonline booking.
        await apps.gotoApplicationList(adminPage, actions.process);
        const searchInput = adminPage
          .locator(
            '[data-testid="application-list-search"], masterev-search-input input'
          )
          .first();
        await searchInput.fill(application.applicationId);
        await expect(
          adminPage.locator(
            `[data-testid="application-row-${application._id}"]`
          )
        ).toBeVisible({ timeout: 10_000 });

        // Detail view: while the application is still in_progress the
        // stage-1 result panel shows "Zwischenergebnis" (interim) and
        // the per-step action-footer label switches to "Vorläufiges
        // Ergebnis" so the admin sees the rejection is not yet final.
        await apps.openApplicationFromList(adminPage, application._id);
        await apps.expectApplicationStatus(
          adminPage,
          application._id,
          'in_progress'
        );
        // Stage-1 result panel rendered. The decision section appears
        // once the calc preview lands; wait for it explicitly so the
        // text assertion below does not race a partial render.
        await expect(
          adminPage.locator('[data-testid="application-result-decision"]')
        ).toBeVisible({ timeout: 15_000 });
        await expect(
          adminPage.getByText(/Zwischenergebnis|Interim result/)
        ).toBeVisible({ timeout: 10_000 });

        // Complete the post-decision step. Engine now finalises the
        // status to `rejected` because all required steps are done.
        await actions.driveToFinalStage(adminPage, application);
        await apps.expectApplicationStatus(
          adminPage,
          application._id,
          'rejected'
        );

        // openspec/denormalize-active-step-keys: with every step
        // completed the trigger drains the denormalised set, so
        // `activeStageStepKeys` is the empty array (the application
        // doc still carries the field, just empty).
        const tokensAfterFinal = await convexRun<string[] | null>(
          'e2e:getApplicationActiveStageStepKeys',
          { applicationId: application._id }
        );
        expect(tokensAfterFinal).toEqual([]);

        // Result label switches back to "Endergebnis" once finalised.
        await expect(
          adminPage.getByText(/Zwischenergebnis|Interim result/)
        ).toHaveCount(0, { timeout: 10_000 });
      } finally {
        await apps.deleteApplication(application._id);
      }
    });

    test('manual reject → invalid → withdrawn moves through finalised states', async ({
      adminPage,
    }) => {
      const application = await actions.seedApplication();
      try {
        await actions.driveToRejectableState(adminPage, application);

        await apps.setApplicationStatus(
          adminPage,
          application._id,
          'invalid',
          'invalid for E2E lifecycle'
        );
        await apps.expectApplicationStatus(
          adminPage,
          application._id,
          'invalid'
        );

        await apps.setApplicationStatus(
          adminPage,
          application._id,
          'withdrawn',
          'withdrawn for E2E lifecycle'
        );
        await apps.expectApplicationStatus(
          adminPage,
          application._id,
          'withdrawn'
        );
      } finally {
        await apps.deleteApplication(application._id);
      }
    });

    test('re-decision flips the status menu label to edit mode and pre-fills the reason', async ({
      adminPage,
    }) => {
      const application = await actions.seedApplication();
      try {
        await actions.driveToAcceptableState(adminPage, application);
        await apps.setApplicationStatus(
          adminPage,
          application._id,
          'rejected',
          'first reject'
        );

        // Reopen the menu: the rejection entry should now read
        // "Edit rejection reason" because the application carries
        // `manualDecision.type = 'rejected'`. We match either German
        // or English wording.
        await adminPage
          .locator('[data-testid="application-status-menu"]')
          .click();
        const editRejectItem = adminPage.getByRole('menuitem', {
          name: /Edit rejection reason|Ablehnungsgrund bearbeiten/i,
        });
        await expect(editRejectItem).toBeVisible();
        await editRejectItem.click();

        // The reject dialog should be pre-filled with the prior
        // reason.
        const reasonField = adminPage.locator(
          '[data-testid="reject-dialog-reason"]'
        );
        await expect(reasonField).toHaveValue('first reject');

        // Submit a new reason to verify the in-place edit path.
        await reasonField.fill('updated reject reason');
        await adminPage
          .locator('[data-testid="reject-dialog-submit"]')
          .click();
        await apps.expectApplicationStatus(
          adminPage,
          application._id,
          'rejected'
        );
      } finally {
        await apps.deleteApplication(application._id);
      }
    });

    test('withdrawn dialog accepts a free-text reason', async ({
      adminPage,
    }) => {
      const application = await actions.seedApplication();
      try {
        await apps.gotoApplicationDetail(
          adminPage,
          actions.process,
          application._id
        );
        await apps.setApplicationStatus(
          adminPage,
          application._id,
          'withdrawn',
          'student emailed cancellation'
        );
        await apps.expectApplicationStatus(
          adminPage,
          application._id,
          'withdrawn'
        );
      } finally {
        await apps.deleteApplication(application._id);
      }
    });

    test('reset clears the manual decision and the status returns to new', async ({
      adminPage,
    }) => {
      const application = await actions.seedApplication();
      try {
        await actions.driveToAcceptableState(adminPage, application);
        await apps.setApplicationStatus(
          adminPage,
          application._id,
          'invalid',
          'invalid before reset'
        );

        // Navigate to the results page where the reset SplitButton
        // lives. `driveToAcceptableState` already started the
        // application, so the button reads "Reset application", not
        // "Start application".
        await apps.gotoApplicationDetail(
          adminPage,
          actions.process,
          application._id
        );
        await apps.resetApplicationViaUi(adminPage, application._id);
        await apps.expectApplicationStatus(
          adminPage,
          application._id,
          'new'
        );
      } finally {
        await apps.deleteApplication(application._id);
      }
    });

    test('cancelling the invalid dialog keeps the application unchanged', async ({
      adminPage,
    }) => {
      // The "Ungültig"-dialog requires a reason; closing it with Cancel
      // must NOT call `setStatus`. We assert the application stays at
      // its pre-dialog status (`in_progress`) so a stale click on the
      // status menu cannot accidentally finalise a fresh application.
      // The dialog itself is owned by ui-applications-convex and used
      // by every Convex-based detail page, so the assertion belongs in
      // the framework rather than the BMT-specific spec where it
      // originally lived.
      //
      // Contract dependency: this test assumes `actions.seedApplication()`
      // returns an application whose initial status is `in_progress`. The
      // pre-dialog assertion below makes that assumption explicit, so a
      // future process whose `seedApplication()` returns `new` (or any
      // other non-in_progress status) fails fast with a clear message
      // rather than producing a misleading "Cancel mutated the status"
      // failure on the post-dialog assertion.
      const application = await actions.seedApplication();
      try {
        await apps.gotoApplicationDetail(
          adminPage,
          actions.process,
          application._id
        );
        await apps.expectApplicationStatus(
          adminPage,
          application._id,
          'in_progress'
        );

        await adminPage
          .locator('[data-testid="application-status-menu"]')
          .click();
        await adminPage
          .getByRole('menuitem', {
            name: /invalid|ungültig|ungultig/i,
          })
          .first()
          .click();
        await adminPage
          .getByRole('button', { name: /Cancel|Abbrechen/i })
          .click();

        await apps.expectApplicationStatus(
          adminPage,
          application._id,
          'in_progress'
        );
      } finally {
        await apps.deleteApplication(application._id);
      }
    });
  });
}
