import { test, expect, type Page } from '@playwright/test';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
import { dirname, resolve, join } from 'node:path';
import { tmpdir } from 'node:os';

// ── Local-auth WORKSPACE-INVITE-LINK E2E ──────────────────────────────────────
//
// Proves the space invite-link feature on feat/space-invite-links, end-to-end
// through the real server with LOCAL AUTH on (ui/playwright.auth.config.ts →
// ui/e2e-auth/config.e2e-auth.yaml). The WHOLE POINT of an invite link is that it
// bypasses the org-scoped picker: a user who shares NO org with the owner can
// still join via the token. So two personas suffice:
//
//   owner  — owns a case space, can manage it (mint/revoke invites). role 'user'.
//   joiner — a separate active user in NO shared org with owner. Must NOT see the
//            space until they accept the invite, then becomes a member.
//
// IDENTITY ISOLATION: this file uses its own *@invite.local emails so it never
// collides with shared-space.auth.spec.ts / sharing-scope.auth.spec.ts, which
// seed personas into the SAME shared DB. Reusing those (esp. an admin or an
// org-sharing pair) would mask the "no shared org" precondition the invite relies
// on.
//
// SEEDING (real, not fallback): both users + the owner's case space are created in
// beforeAll via the BUILT Repository (compiled dist), into the SAME deterministic
// DB the webServer opened. No org membership is granted to EITHER user, so the
// joiner is provably outside the owner's org-scoped candidate pool. Seeding in
// beforeAll (not a test body) keeps the ids deterministic for every test.
//
// DRIVING STYLE: invite CREATION / REVOCATION runs via authenticated page.request
// from the OWNER's browser context (the API is the contract); the JOIN flow is
// exercised through the REAL UI at /ui/invite/:token (JoinSpace.tsx).

const __dirname = dirname(fileURLToPath(import.meta.url));
const require = createRequire(import.meta.url);
// __dirname is <repo>/ui/e2e-auth → repo root is two levels up.
const repoRoot = resolve(__dirname, '..', '..');

// Same deterministic DB path the webServer uses (ui/playwright.auth.config.ts
// builds it identically: <tmpdir>/maestro-e2e-auth/e2e-auth.db). Computing it
// independently keeps the spec decoupled from Playwright's config internals while
// guaranteeing both open the SAME SQLite file.
const DB_PATH = join(tmpdir(), 'maestro-e2e-auth', 'e2e-auth.db');

const OWNER = { email: 'owner@invite.local', password: 'OwnerPass123!', name: 'InviteOwner' };
const JOINER = { email: 'joiner@invite.local', password: 'JoinerPass123!', name: 'InviteJoiner' };

// Resolved in beforeAll, reused by every test.
let ownerId = '';
let joinerId = '';
let caseSpaceId = '';

const BENIGN_PATHS = new Set([
  '/api/users/me/orgs',
  '/api/mcp/connections',
  '/api/mcp/servers',
  '/api/mcp/user-servers',
  '/api/ssh/connections',
]);
const BENIGN_ASSET = /\.(ico|png|svg|map|webmanifest|json)$/i;
const BENIGN_PATH_RE =
  /\/api\/local\/tasks\/\d+\/console\/status$|\/api\/local\/browser\/sessions\/task-session\/\d+$/;
// The invite preview GET is intentionally 404 for invalid/revoked tokens (tests 3
// & 4 navigate to such a token through the UI → JoinSpace fetches the preview and
// the server returns 404 by design). That 404 is the feature working, not a bug,
// so it must not trip the navigation-scoped fatal tracker.
const INVITE_PREVIEW_RE = /\/api\/local\/spaces\/invite\/[^/]+$/;

function trackFatalErrors(page: Page): string[] {
  const fatalErrors: string[] = [];
  page.on('pageerror', (err) => fatalErrors.push(`pageerror: ${err.message}`));
  page.on('response', (res) => {
    if (res.status() >= 400) {
      const { pathname } = new URL(res.url());
      const expectedInvitePreview404 = res.status() === 404 && INVITE_PREVIEW_RE.test(pathname);
      if (
        !BENIGN_ASSET.test(pathname) &&
        !BENIGN_PATHS.has(pathname) &&
        !BENIGN_PATH_RE.test(pathname) &&
        !expectedInvitePreview404
      ) {
        fatalErrors.push(`HTTP ${res.status()} ${res.url()}`);
      }
    }
  });
  return fatalErrors;
}

/** Log a real user in via the browser form. Success → redirect chain settles off
 *  /auth/login onto the SPA at /ui/. */
async function login(page: Page, email: string, password: string) {
  await page.goto('/auth/login');
  await page.fill('input[name="email"]', email);
  await page.fill('input[name="password"]', password);
  await Promise.all([
    page.waitForURL(/\/ui(\/|$)/, { timeout: 15_000 }),
    page.click('button[type="submit"]'),
  ]);
  expect(page.url(), 'login should not bounce back to /auth/login').not.toContain('/auth/login');
}

async function logout(page: Page) {
  await page.goto('/auth/logout');
}

/** Mint an invite for the seeded case space via the owner's authed page.request.
 *  Returns the captured token. */
async function createInvite(page: Page, role: 'viewer' | 'editor'): Promise<string> {
  const res = await page.request.post(`/api/local/spaces/${caseSpaceId}/invite`, {
    data: { role },
  });
  expect(res.status(), `owner mints invite (role ${role})`).toBe(201);
  const body = (await res.json()) as {
    invite?: { token?: string; role?: string; valid?: boolean; url?: string };
  };
  expect(body.invite?.role, 'minted invite has the requested role').toBe(role);
  expect(body.invite?.valid, 'minted invite is valid').toBe(true);
  const token = body.invite?.token;
  expect(token, 'invite token captured').toBeTruthy();
  return token as string;
}

test.beforeAll(async () => {
  // The webServer is already up (Playwright waits on its url), so schema +
  // migrations + bootstrap admin all exist. Seed the two personas (both regular
  // 'user's, NEITHER in any org → no shared org) and the owner-owned case space
  // into the SAME deterministic DB.
  const { Repository } = require(resolve(repoRoot, 'dist/db/repository.js')) as {
    Repository: new (dbPath: string) => {
      getUserByEmail: (email: string) => { id: string } | null;
      createLocalUser: (p: {
        email: string; password: string; name?: string; role: string; status: string;
      }) => { id: string };
      createSpace: (p: {
        kind: string; title: string; ownerId: string; visibility?: string;
      }) => Promise<{ id: string }>;
      close?: () => void;
    };
  };

  const repo = new Repository(DB_PATH);
  try {
    const ensure = (u: typeof OWNER, role: string) => {
      const existing = repo.getUserByEmail(u.email);
      if (existing) return existing.id;
      return repo.createLocalUser({
        email: u.email, password: u.password, name: u.name, role, status: 'active',
      }).id;
    };
    // Both are REGULAR users; neither is added to any org → the invite link is the
    // only path by which the joiner can reach the owner's private space.
    ownerId = ensure(OWNER, 'user');
    joinerId = ensure(JOINER, 'user');

    // Owner-owned private case space. No members pre-added → the joiner starts
    // strictly outside.
    const space = await repo.createSpace({
      kind: 'case', title: '案件-招待リンク', ownerId, visibility: 'private',
    });
    caseSpaceId = space.id;
  } finally {
    repo.close?.();
  }
});

// 0. Sanity: the seed produced usable ids and the joiner is genuinely an outsider
//    (cannot see the space, is not a member) — the precondition for every test.
test('seed sanity: joiner is an outsider (no space visibility, not a member)', async ({ page }) => {
  expect(caseSpaceId, 'case space seeded').toBeTruthy();
  expect(joinerId, 'joiner seeded').toBeTruthy();

  await login(page, JOINER.email, JOINER.password);

  // The owner's private space is NOT in the joiner's spaces list.
  const list = await page.request.get('/api/local/spaces');
  expect(list.status(), 'joiner lists spaces').toBe(200);
  const spaces = (await list.json()) as Array<{ id: string }>;
  expect(spaces.some((s) => s.id === caseSpaceId), 'joiner cannot see the space pre-join').toBe(false);

  // And the space itself is denied (404 — not enumerable).
  const space = await page.request.get(`/api/local/spaces/${caseSpaceId}`);
  expect(space.status(), 'joiner denied the space pre-join').toBe(404);
});

// 1+2. Owner mints a viewer invite (via page.request); the joiner (NOT in owner's
//      org) opens /ui/invite/<token> in the REAL UI, sees the JoinSpace screen
//      with the space title + accept button, joins, lands on the spaces page, and
//      is now a member. This is the core "invite bypasses org-scope" proof.
test('joiner opens the invite link and joins the space (org-scope bypassed)', async ({ page }) => {
  const fatalErrors = trackFatalErrors(page);

  // Owner mints the invite.
  await login(page, OWNER.email, OWNER.password);
  const token = await createInvite(page, 'viewer');

  // Switch to the joiner and walk the real invite flow.
  await logout(page);
  await login(page, JOINER.email, JOINER.password);

  await page.goto(`/ui/invite/${token}`);
  const joinScreen = page.getByTestId('join-space');
  await expect(joinScreen).toBeVisible({ timeout: 15_000 });
  // Preview shows the space title + an accept button (the 'ok' branch).
  await expect(joinScreen).toContainText('案件-招待リンク', { timeout: 15_000 });
  const accept = page.getByTestId('join-space-accept');
  await expect(accept).toBeVisible();

  // Accept → JoinSpace navigates to /ui/?page=spaces&space=<id>.
  await Promise.all([
    page.waitForURL(/\/ui\/\?page=spaces&space=/, { timeout: 15_000 }),
    accept.click(),
  ]);
  expect(page.url()).toContain(`space=${caseSpaceId}`);

  // The joiner is now a member: the members list (in their own authed context)
  // includes their userId.
  const members = await page.request.get(`/api/local/spaces/${caseSpaceId}/members`);
  expect(members.status(), 'joiner reads members after joining').toBe(200);
  const roster = (await members.json()) as Array<{ userId: string; role: string }>;
  expect(roster.some((m) => m.userId === joinerId), 'joiner appears in the roster').toBe(true);

  // And the space is now visible in their spaces list.
  const list = await page.request.get('/api/local/spaces');
  const spaces = (await list.json()) as Array<{ id: string }>;
  expect(spaces.some((s) => s.id === caseSpaceId), 'joiner now sees the space').toBe(true);

  expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
});

// 3. An invalid / unknown token shows the invalid state — NOT the accept button.
test('invalid token shows the invalid state, no accept button', async ({ page }) => {
  const fatalErrors = trackFatalErrors(page);

  await login(page, JOINER.email, JOINER.password);
  await page.goto('/ui/invite/does-not-exist');

  // The JoinSpace shell still renders, but in the invalid branch: invalid copy is
  // shown and there is no accept button.
  const joinScreen = page.getByTestId('join-space');
  await expect(joinScreen).toBeVisible({ timeout: 15_000 });
  await expect(joinScreen).toContainText('リンクが無効です', { timeout: 15_000 });
  await expect(page.getByTestId('join-space-accept')).toHaveCount(0);

  // The expected preview 404 is excused by the tracker; nothing else should error.
  expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
});

// 4. After the owner REVOKES the invite (page.request DELETE), the SAME token no
//    longer previews/accepts: navigating to /ui/invite/<token> shows the invalid
//    state. Uses a fresh invite so revocation here can't affect other tests.
test('revoked token can no longer be used to join', async ({ page }) => {
  const fatalErrors = trackFatalErrors(page);

  // Owner mints a fresh invite, then immediately revokes it.
  await login(page, OWNER.email, OWNER.password);
  const token = await createInvite(page, 'viewer');

  // Preview is valid while live (owner context, page.request — out of band).
  const before = await page.request.get(`/api/local/spaces/invite/${token}`);
  expect(before.status(), 'invite previews while live').toBe(200);

  const del = await page.request.delete(`/api/local/spaces/${caseSpaceId}/invite`);
  expect(del.status(), 'owner revokes the invite').toBe(204);

  // Switch to the joiner: the revoked token now renders the invalid state.
  await logout(page);
  await login(page, JOINER.email, JOINER.password);
  await page.goto(`/ui/invite/${token}`);

  const joinScreen = page.getByTestId('join-space');
  await expect(joinScreen).toBeVisible({ timeout: 15_000 });
  await expect(joinScreen).toContainText('リンクが無効です', { timeout: 15_000 });
  await expect(page.getByTestId('join-space-accept')).toHaveCount(0);

  // The preview API itself is 404 for the revoked token (proof at the contract).
  const after = await page.request.get(`/api/local/spaces/invite/${token}`);
  expect(after.status(), 'revoked token previews as 404').toBe(404);

  expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
});
