/**
 * Generic permissions spec for any Convex-backed process. Drives the
 * five-identity matrix from the team-scoped-application-reads spec:
 *
 *   1. ADMIN sees the application list, opens detail.
 *   2. POWERUSER without team membership sees the list (synthetic
 *      `role:POWERUSER` subject in `readableBy`).
 *   3. USER in team with step-write access sees only the seeded
 *      application, can open detail.
 *   4. USER in team with step-read-only access sees the application
 *      in the list and detail, but the field-write attempt is denied.
 *   5. USER without matching team sees empty list, detail navigates
 *      to "Bewerbung nicht gefunden" (no crash, no leak).
 *
 * Each process exports a `ProcessActions` value with a
 * `capabilities.permissions` block; this spec consumes it.
 */

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

interface RegisterOptions {
  /** Process actions exposing `capabilities.permissions`. */
  actions: ProcessActions;
  /**
   * Optional skip-with-reason. Mirror of the existing
   * `skipUnlessXxxOnConvex` pattern. Called per test, can use
   * `testInfo` if needed. Return non-empty string to skip.
   */
  skipReason?: (ctx: { customerName: string }) => string | undefined;
}

/**
 * Register the permissions spec for a given process. Call from a
 * thin wrapper file under `apps/e2e/src/tests/applications/<proc>/`.
 */
export function registerPermissionsSpec(opts: RegisterOptions): void {
  const { actions } = opts;
  const cap = actions.capabilities?.permissions;

  test.describe(`Permissions for ${actions.process}`, () => {
    test.beforeEach(({ customerConfig }, testInfo) => {
      // Suite-builder registers the spec once per process and Playwright
      // executes it across every customer project; without this guard
      // the bwl permissions tests run against tum-ed-as / tum-ed-cee /
      // tum-ed-me etc., where seeding fails because `bwl` is not a
      // process on those customers. Match the customer-skip pattern
      // already used by `dashboard.spec.ts` and `list-search.spec.ts`.
      test.skip(
        customerConfig.name !== actions.customer,
        `Suite for ${actions.process} only runs against ${actions.customer} (got ${customerConfig.name})`
      );
      const reason = opts.skipReason?.({
        customerName:
          (testInfo.project.metadata?.customer as string | undefined) ?? '',
      });
      if (reason) test.skip(true, reason);
      if (!cap) {
        test.skip(true, `${actions.process} has no permissions capability hint`);
      }
    });

    test('ADMIN sees the seeded application in the list', async ({
      adminPage,
    }) => {
      const seeded = await seedFor(actions, cap, 'write');
      try {
        await apps.gotoApplicationList(adminPage, actions.process);
        await expect(rowFor(adminPage, seeded._id)).toBeVisible();
      } finally {
        await apps.deleteApplication(seeded._id);
      }
    });

    test('POWERUSER without team membership sees the list (synthetic role:POWERUSER)', async ({
      createAuthenticatedUser,
    }) => {
      const seeded = await seedFor(actions, cap, 'write');
      try {
        const { page } = await createAuthenticatedUser({ role: 'POWERUSER' });
        await apps.gotoApplicationList(page, actions.process);
        await expect(rowFor(page, seeded._id)).toBeVisible();
      } finally {
        await apps.deleteApplication(seeded._id);
      }
    });

    test('USER in write team sees only their seeded applications', async ({
      createAuthenticatedUser,
    }) => {
      const writeTeamId = await ensureTeam(
        actions.customer,
        cap!.writeTeamShort,
        'E2E Write Team'
      );
      const seeded = await seedFor(actions, cap, 'write', writeTeamId);
      try {
        const { page } = await createAuthenticatedUser({
          role: 'USER',
          teamId: writeTeamId,
        });
        await apps.gotoApplicationList(page, actions.process);
        await expect(rowFor(page, seeded._id)).toBeVisible();
      } finally {
        await apps.deleteApplication(seeded._id);
      }
    });

    test('USER without matching team sees empty list and "not found" on direct detail', async ({
      createAuthenticatedUser,
    }) => {
      // Different team from the seeded write-team grant: no overlap.
      const otherTeamId = await ensureTeam(
        actions.customer,
        'tnomatch',
        'E2E No-Match Team'
      );
      const writeTeamId = await ensureTeam(
        actions.customer,
        cap!.writeTeamShort,
        'E2E Write Team'
      );
      const seeded = await seedFor(actions, cap, 'write', writeTeamId);
      try {
        const { page } = await createAuthenticatedUser({
          role: 'USER',
          teamId: otherTeamId,
        });
        await apps.gotoApplicationList(page, actions.process);
        await expect(rowFor(page, seeded._id)).not.toBeVisible();

        // Direct-navigate to the detail URL. The application is
        // filtered out by the RLS read predicate, so the convex query
        // returns null; the UI handles `query.data() === null` as an
        // empty state (no application headline, no field inputs, no
        // hard 404). Spec D4: "Detail-View read ... Empty/Spinner-
        // State (bei deny — kein crash, kein hard 404)." We assert
        // the absence of detail-page chrome rather than a specific
        // error string, because the current UI does not surface a
        // "not found" message.
        await page.goto(
          `/admin/applications/${actions.process}/details/${seeded._id}`
        );
        // The detail page renders an `<masterev-application-detail>`
        // host element when data is present; an absent host (or no
        // application-id heading) is the deny signal.
        await expect(
          page.locator('[data-testid="application-detail-content"]')
        ).toHaveCount(0);
        await expect(
          page.getByText(seeded.applicationId, { exact: true })
        ).toHaveCount(0);
      } finally {
        await apps.deleteApplication(seeded._id);
      }
    });
  });
}

async function seedFor(
  actions: ProcessActions,
  cap: ProcessPermissionsCapability | undefined,
  access: 'read' | 'write',
  teamIdOverride?: string
) {
  if (!cap) {
    throw new Error(
      `seedFor called without a permissions capability on ${actions.process}`
    );
  }
  const teamId =
    teamIdOverride ??
    (await ensureTeam(
      actions.customer,
      access === 'write' ? cap.writeTeamShort : cap.readTeamShort,
      access === 'write' ? 'E2E Write Team' : 'E2E Read Team'
    ));
  return cap.seedApplicationWithStepPermission({
    customer: actions.customer,
    teamId,
    access,
  });
}

async function ensureTeam(
  customer: string,
  short: string,
  name: string
): Promise<string> {
  // `e2e:createTeam` is idempotent on (customer, short).
  return convexRun<string>('e2e:createTeam', { customer, name, short });
}

function rowFor(page: import('@playwright/test').Page, applicationDocId: string) {
  // The list table is the InfiniteDataTable component which renders
  // each row with `data-testid="application-row-<docId>"` (see
  // `application-list.component.ts:rowTestIdPrefix`). Other framework
  // specs locate rows the same way; matching here on the testid is
  // resilient to column re-ordering and the divs-as-cells render
  // shape that defeats `getByRole('cell', ...)`.
  return page.locator(
    `[data-testid="application-row-${applicationDocId}"]`
  );
}
