import { type Page, type Response } from '@playwright/test';
import fs from 'node:fs';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
import { test, expect, api, fixture, runDir, evidence } from './helpers';

// This file is deliberately last: extra users/rows never alter the base actors.
// No route interception, response stubs, auth-store writes or forged credentials.
// Disable Playwright secret-bearing traces/screenshots for this credential flow.
test.use({ trace: 'off', screenshot: 'off', video: 'off' });

type InviteActor = { id: string; email: string; password: string };
type Invite = { id: string; store_id: string; invited_user_id: string; token: string };
type Invites = {
  actors: { recipient: InviteActor; rejected: InviteActor };
  invitations: { accepted: Invite; wrong: Invite; revoked: Invite };
};
type Readback = {
  invitations: { id: string; store_id: string; invited_user_id: string; accepted_at: string | null; revoked_at: string | null }[];
  memberships: { id: string; user_id: string; store_id: string; role: string; status: string }[];
};
let invites: Invites;

function fixtureCommand(command: 'seed' | 'readback'): string {
  // Pass only the runner's existing allowlisted isolated environment, not the
  // Playwright worker's ambient environment. PHP independently checks actual PDO.
  const env = JSON.parse(fs.readFileSync(path.join(runDir, 'environment.json'), 'utf8'));
  if (env.QA_RUN_DIR !== runDir || env.QA_MARKER !== fixture.marker) throw new Error('Invitation environment identity mismatch');
  try {
    return execFileSync('/opt/homebrew/bin/php', [path.join(__dirname, 'invite-fixtures.php'), command], {
      env, cwd: __dirname, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
    });
  } catch {
    // Never rethrow child-process buffers: exception context may contain secrets.
    throw new Error(`Guarded invitation fixture ${command} failed`);
  }
}

const readback = (): Readback => JSON.parse(fixtureCommand('readback'));
const isAccept = (response: Response) => new URL(response.url()).pathname === '/v1/invitations/accept' && response.request().method() === 'POST';
const isDiscovery = (response: Response) => new URL(response.url()).pathname === '/v1/me/memberships' && response.request().method() === 'GET';

async function landing(page: Page, invite: Invite) {
  // Deliver the real notification token into the browser's URL before app code
  // runs. The harmless navigation marker keeps the secret out of Next's HTTP
  // access log (a literal token-bearing GET would be logged by next dev).
  // Only fixture delivery is synthetic: capture/scrub, persistence, login,
  // acceptance, discovery and navigation all run the unmodified application.
  await page.addInitScript(({ token }) => {
    if (location.pathname === '/invitations/accept' && location.search === '?qa_invitation_landing=1') {
      history.replaceState(history.state, '', '/invitations/accept?token=' + encodeURIComponent(token));
    }
  }, { token: invite.token });
  await page.goto('/invitations/accept?qa_invitation_landing=1');
  await expect(page.getByRole('heading', { name: 'Join your store', exact: true })).toBeVisible();
  // Assertions contain no raw token even if capture/scrub regresses.
  expect(await page.evaluate(() => location.pathname === '/invitations/accept' && location.search === '' && location.hash === '')).toBe(true);
  expect(await page.evaluate(() => !localStorage.getItem('auth_token'))).toBe(true);
  expect(await page.evaluate(() => !!sessionStorage.getItem('alqove.pending-invitation'))).toBe(true);
  await expect(page.getByRole('link', { name: 'Sign in', exact: true })).toHaveAttribute('href', '/login?next=/invitations/accept');
}

async function continueLogin(page: Page, actor: InviteActor) {
  await page.getByRole('link', { name: 'Sign in', exact: true }).click();
  expect(await page.evaluate(() => new URLSearchParams(location.search).get('next') === '/invitations/accept' && !location.search.includes('token'))).toBe(true);
  try {
    await page.getByLabel('Email', { exact: true }).fill(actor.email);
    await page.getByLabel('Password', { exact: true }).fill(actor.password);
  } catch {
    throw new Error('Invitation login fields unavailable (credential details suppressed)');
  }
  const discovered = page.waitForResponse(isDiscovery);
  await page.getByRole('button', { name: 'Sign In', exact: true }).click();
  const discovery = await discovered;
  expect(discovery.status()).toBe(200);
  const body = await discovery.json();
  await page.waitForURL(url => url.pathname === '/invitations/accept' && url.search === '');
  await expect(page.getByRole('heading', { name: 'Store invitation', exact: true })).toBeVisible();
  await expect(page.getByText(`Signed in as ${actor.email}.`, { exact: false })).toBeVisible();
  return body.data as { id: string; store_id: string; role: string; capabilities: string[] }[];
}

function assertUnaccepted(state: Readback, invite: Invite, actor: InviteActor) {
  const row = state.invitations.find(row => row.id === invite.id);
  expect(row).toMatchObject({ store_id: invite.store_id, invited_user_id: invite.invited_user_id, accepted_at: null });
  expect(state.memberships.filter(row => row.user_id === actor.id && row.store_id === invite.store_id)).toEqual([]);
}

test.beforeAll(() => {
  fixtureCommand('seed');
  invites = JSON.parse(fs.readFileSync(path.join(runDir, 'manifest.json'), 'utf8')).inviteFixtures;
  if (!invites) throw new Error('Invitation fixture manifest missing');
});

test.afterAll(async () => {
  if (!invites) return;
  const secrets = [
    ...Object.values(invites.invitations).map(invite => invite.token),
    ...Object.values(invites.actors).map(actor => actor.password),
  ];
  const checked: string[] = [];
  const logs = ['api.log', 'web.log', 'fixtures.log'];
  const storageLogs = path.join(runDir, 'storage/logs');
  if (fs.existsSync(storageLogs)) {
    logs.push(...fs.readdirSync(storageLogs).filter(name => name.endsWith('.log')).map(name => `storage/logs/${name}`));
  }
  for (const name of logs) {
    const filename = path.join(runDir, name);
    if (!fs.existsSync(filename)) continue;
    const body = fs.readFileSync(filename, 'utf8');
    expect(secrets.some(secret => body.includes(secret)), `Secret hygiene failed in ${name} (values suppressed)`).toBe(false);
    checked.push(name);
  }
  await evidence('invitations-secret-hygiene', { checked_logs: checked, invitation_tokens_and_passwords_absent: true });
});

test('invitations: existing account continues login, explicitly accepts, discovers and selects staff store', async ({ page }) => {
  const invite = invites.invitations.accepted;
  const actor = invites.actors.recipient;
  let acceptCount = 0;
  page.on('request', request => {
    if (new URL(request.url()).pathname === '/v1/invitations/accept' && request.method() === 'POST') acceptCount++;
  });
  assertUnaccepted(readback(), invite, actor);
  await landing(page, invite);
  const before = await continueLogin(page, actor);
  expect(before.map(row => row.store_id)).toEqual([fixture.stores.a.id]);
  expect(acceptCount, 'Login must not implicitly accept an invitation').toBe(0);
  assertUnaccepted(readback(), invite, actor);

  // Register both real response observers before the explicit UI command.
  const accepted = page.waitForResponse(isAccept);
  const discovered = page.waitForResponse(isDiscovery);
  await page.getByRole('button', { name: 'Accept invitation', exact: true }).click();
  const [acceptance, discovery] = await Promise.all([accepted, discovered]);
  expect(acceptance.status()).toBe(201);
  const membership = (await acceptance.json()).data;
  // MembershipResource intentionally omits status; prove active in SQL below.
  expect(membership).toMatchObject({ store_id: invite.store_id, role: 'sales' });
  expect(membership.capabilities).toEqual(['team.view', 'schedule.view']);
  expect(discovery.status()).toBe(200);
  const memberships = (await discovery.json()).data as { id: string; store_id: string }[];
  expect(memberships.map(row => row.store_id).sort()).toEqual([fixture.stores.a.id, fixture.stores.b.id].sort());
  expect(memberships.find(row => row.id === membership.id)?.store_id).toBe(invite.store_id);
  await page.waitForURL(url => url.pathname === '/staff');
  await expect(page.getByRole('heading', { name: 'Your staff workspace', exact: true })).toBeVisible();
  const store = page.getByRole('combobox', { name: 'Store', exact: true });
  await expect(store).toHaveValue(invite.store_id);
  expect(await page.evaluate(id => localStorage.getItem(`selected_store:${id}`), actor.id)).toBe(invite.store_id);
  expect(await page.evaluate(() => sessionStorage.getItem('alqove.pending-invitation') === null)).toBe(true);
  const state = readback();
  const row = state.invitations.find(row => row.id === invite.id)!;
  expect(row.accepted_at).not.toBeNull();
  expect(row.revoked_at).toBeNull();
  const exactMembers = state.memberships.filter(row => row.user_id === actor.id && row.store_id === invite.store_id);
  expect(exactMembers).toEqual([{ id: membership.id, user_id: actor.id, store_id: invite.store_id, role: 'sales', status: 'active' }]);
  await page.reload();
  await expect(page.getByRole('heading', { name: 'Your staff workspace', exact: true })).toBeVisible();
  await expect(store).toHaveValue(invite.store_id);
  expect(acceptCount).toBe(1);
  await evidence('invitations-accepted', { invitation: row, membership: exactMembers[0], discovered_store_ids: memberships.map(row => row.store_id), selected_store_id: invite.store_id, accept_request_count: acceptCount, persisted_after_reload: true, token_cleared: true, token_delivery: 'pre-hydration browser URL; no token-bearing HTTP GET' });
});

test('invitations: wrong account fails closed without membership or acceptance', async ({ page }) => {
  const invite = invites.invitations.wrong;
  const actor = fixture.actors.outsider as InviteActor;
  await landing(page, invite);
  expect(await continueLogin(page, actor)).toEqual([]);
  const accepted = page.waitForResponse(isAccept);
  await page.getByRole('button', { name: 'Accept invitation', exact: true }).click();
  const response = await accepted;
  expect(response.status()).toBe(422);
  expect((await response.json()).errors?.token).toContain('This invitation is for a different account.');
  await expect(page.getByRole('alert').filter({ hasText: 'Could not accept this invitation.' })).toBeVisible();
  await expect(page.getByRole('button', { name: 'Accept invitation', exact: true })).toBeEnabled();
  expect(new URL(page.url()).pathname).toBe('/invitations/accept');
  const state = readback();
  assertUnaccepted(state, invite, actor);
  assertUnaccepted(state, invite, invites.actors.rejected);
  expect(await page.evaluate(id => localStorage.getItem(`selected_store:${id}`), actor.id)).toBeNull();
  await page.goto('/staff');
  await expect(page.getByText('No active store memberships. Ask your store manager for an invitation.', { exact: true })).toBeVisible();
  await expect(page.getByRole('navigation', { name: 'Staff', exact: true })).toHaveCount(0);
  await evidence('invitations-wrong-account', { invitation_id: invite.id, wrong_account_id: actor.id, status: response.status(), database: state, staff_access_denied: true });
});

test('invitations: revoked offer fails closed for the invited existing account', async ({ page, request }) => {
  const invite = invites.invitations.revoked;
  const actor = invites.actors.rejected;
  // Revoke via the real API, not a fixture status mutation.
  await api(request, 'owner', 'DELETE', `/invitations/${invite.id}`, undefined, 204, 'b');
  const revoked = readback().invitations.find(row => row.id === invite.id)!;
  expect(revoked.revoked_at).not.toBeNull();
  expect(revoked.accepted_at).toBeNull();
  await landing(page, invite);
  expect(await continueLogin(page, actor)).toEqual([]);
  const accepted = page.waitForResponse(isAccept);
  await page.getByRole('button', { name: 'Accept invitation', exact: true }).click();
  const response = await accepted;
  expect(response.status()).toBe(422);
  expect((await response.json()).errors?.token).toContain('This invitation is invalid or has expired.');
  await expect(page.getByRole('alert').filter({ hasText: 'Could not accept this invitation.' })).toBeVisible();
  expect(new URL(page.url()).pathname).toBe('/invitations/accept');
  const state = readback();
  assertUnaccepted(state, invite, actor);
  expect(state.invitations.find(row => row.id === invite.id)?.revoked_at).toBe(revoked.revoked_at);
  expect(await page.evaluate(id => localStorage.getItem(`selected_store:${id}`), actor.id)).toBeNull();
  await page.goto('/staff');
  await expect(page.getByText('No active store memberships. Ask your store manager for an invitation.', { exact: true })).toBeVisible();
  await expect(page.getByRole('navigation', { name: 'Staff', exact: true })).toHaveCount(0);
  await evidence('invitations-revoked', { invitation_id: invite.id, status: response.status(), database: state, staff_access_denied: true });
});
