/**
 * Stack file explorer end-to-end coverage.
 *
 * Companion to e2e/stack-files.spec.ts (which covers the basic admin and
 * community-tier upload/edit/delete/download flows). This spec adds the
 * harder-to-reach surfaces the file-explorer audit flagged: path-traversal
 * input corpus, protected-file enforcement at the API, optimistic-concurrency
 * 412 on simultaneous edits, directory-truncation headers + filter behaviour,
 * binary-detection override, the multer 25 MB cap, and the symlink delete
 * semantics that landed in the M-3 PR.
 *
 * Items deliberately out of scope for this spec (each carries a `.skip()`
 * with a one-line rationale near the relevant describe block):
 *   - Remote-node matrix: requires a real peer enrolled in the CI database.
 *   - Mid-op disconnect: same.
 *   - Developer-mode diagnostic-line matrix: requires reading the backend's
 *     stdout, which the Playwright harness does not have a stable hook for.
 *   - Viewer-role tier-persona matrix: requires seeding a viewer-role user
 *     and re-driving the auth flow under it; the API-only assertions below
 *     already cover the role-gated 403 paths.
 *
 * Fixture stack: a single `e2e-explorer-test` stack is seeded once per
 * describe block. Helper writes go directly to COMPOSE_DIR; the API is used
 * for stack create/delete only.
 */
import * as fs from 'node:fs/promises';
import * as nodePath from 'node:path';
import { test, expect, type Page } from '@playwright/test';
import { loginAs, waitForStacksLoaded } from './helpers';

const COMPOSE_DIR = process.env.COMPOSE_DIR ?? '/tmp/compose';
const STACK = 'e2e-explorer-test';

// ---------------------------------------------------------------------------
// Seed / teardown helpers
// ---------------------------------------------------------------------------

async function seedStack(page: Page): Promise<void> {
  await page.evaluate(async (name: string) => {
    const res = await fetch('/api/stacks', {
      method: 'POST',
      credentials: 'include',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ stackName: name }),
    });
    if (!res.ok && res.status !== 409) throw new Error(`stack create: ${res.status}`);
  }, STACK);
}

async function teardownStack(page: Page): Promise<void> {
  await page.evaluate(async (name: string) => {
    await fetch(`/api/stacks/${encodeURIComponent(name)}`, {
      method: 'DELETE',
      credentials: 'include',
    }).catch(() => {});
  }, STACK);
}

async function seedSuite(browser: import('@playwright/test').Browser): Promise<void> {
  const page = await browser.newPage();
  try {
    await loginAs(page);
    await seedStack(page);
  } finally {
    await page.close();
  }
}

async function teardownSuite(browser: import('@playwright/test').Browser): Promise<void> {
  const page = await browser.newPage();
  try {
    await loginAs(page);
    await teardownStack(page);
  } catch (e) {
    console.warn('teardown failed:', e);
  } finally {
    await page.close();
  }
}

/** Read the cookie header so request-context calls inherit the browser session. */
async function cookieHeader(page: Page): Promise<string> {
  const cookies = await page.context().cookies();
  return cookies.map((c) => `${c.name}=${c.value}`).join('; ');
}

const stackDir = (): string => nodePath.join(COMPOSE_DIR, STACK);

// ---------------------------------------------------------------------------
// File-scope seed / teardown (single login pair shared across every describe)
// ---------------------------------------------------------------------------

// One file-scope beforeAll/afterAll replaces seven per-describe pairs. The
// per-describe pairs each did a fresh loginAs + stack create + stack delete;
// stacking that up made the post-spec sidebar load slow enough that
// downstream specs in the single-worker queue (stacks.spec.ts) timed out on
// their own loginAs dashboard wait. The test stack is idempotent across
// beforeAll calls anyway (the API returns 409 on a duplicate create), so
// collapsing the hooks costs nothing.
test.beforeAll(async ({ browser }) => { await seedSuite(browser); });
test.afterAll(async ({ browser }) => { await teardownSuite(browser); });
test.setTimeout(60_000);

// ---------------------------------------------------------------------------
// Path traversal corpus
// ---------------------------------------------------------------------------

test.describe('Stack file explorer: path traversal protection', () => {
  test.beforeEach(async ({ page }) => { await loginAs(page); });

  // Every entry must produce a 400 with code INVALID_PATH on the file-listing
  // route. The validator is upstream of FileSystemService and the audit pinned
  // it as the first defence; a regression here would expose path traversal.
  const corpus: Array<{ label: string; query: string }> = [
    { label: 'parent-dir traversal', query: '..' },
    { label: 'parent-dir prefix', query: '../etc/passwd' },
    { label: 'embedded ..', query: 'a/../b' },
    { label: 'absolute POSIX path', query: '/etc/passwd' },
    { label: 'Windows drive prefix', query: 'C:/windows/system32' },
    { label: 'backslash separator', query: 'foo\\bar' },
    { label: 'double slash', query: 'foo//bar' },
    { label: 'NUL byte', query: 'foo\x00bar' },
    { label: 'single-segment dot', query: 'a/./b' },
  ];

  for (const { label, query } of corpus) {
    test(`rejects ${label} on GET /files with 400 INVALID_PATH`, async ({ page, request }) => {
      const cookie = await cookieHeader(page);
      const res = await request.get(
        `/api/stacks/${encodeURIComponent(STACK)}/files?path=${encodeURIComponent(query)}`,
        { headers: { cookie } },
      );
      expect(res.status()).toBe(400);
      const body = await res.json();
      expect(body.code).toBe('INVALID_PATH');
    });
  }
});

// ---------------------------------------------------------------------------
// Protected file enforcement
// ---------------------------------------------------------------------------

test.describe('Stack file explorer: protected files', () => {
  test.beforeEach(async ({ page }) => { await loginAs(page); });

  test('DELETE compose.yaml via API returns 409 PROTECTED_FILE and does not remove it', async ({ page, request }) => {
    const cookie = await cookieHeader(page);
    const composePath = nodePath.join(stackDir(), 'compose.yaml');
    // The stack-create seed wrote compose.yaml; confirm pre-state.
    await fs.access(composePath);

    const res = await request.delete(
      `/api/stacks/${encodeURIComponent(STACK)}/files?path=${encodeURIComponent('compose.yaml')}`,
      { headers: { cookie } },
    );
    expect(res.status()).toBe(409);
    const body = await res.json();
    expect(body.code).toBe('PROTECTED_FILE');

    // File still on disk.
    await fs.access(composePath);
  });

  test('DELETE .env via API returns 409 PROTECTED_FILE', async ({ page, request }) => {
    const cookie = await cookieHeader(page);
    // Seed .env so the route does not 404 before the protected-file guard runs.
    await fs.writeFile(nodePath.join(stackDir(), '.env'), 'KEY=value\n');

    const res = await request.delete(
      `/api/stacks/${encodeURIComponent(STACK)}/files?path=${encodeURIComponent('.env')}`,
      { headers: { cookie } },
    );
    expect(res.status()).toBe(409);
    expect((await res.json()).code).toBe('PROTECTED_FILE');
  });

  test('DELETE a subdirectory file named compose.yaml is allowed (protection scoped to root)', async ({ page, request }) => {
    const cookie = await cookieHeader(page);
    const subdir = nodePath.join(stackDir(), 'backups');
    await fs.mkdir(subdir, { recursive: true });
    await fs.writeFile(nodePath.join(subdir, 'compose.yaml'), 'services: {}\n');

    const res = await request.delete(
      `/api/stacks/${encodeURIComponent(STACK)}/files?path=${encodeURIComponent('backups/compose.yaml')}`,
      { headers: { cookie } },
    );
    expect(res.status()).toBe(204);
    await fs.rm(subdir, { recursive: true, force: true });
  });
});

// ---------------------------------------------------------------------------
// Optimistic concurrency (412 PRECONDITION_FAILED)
// ---------------------------------------------------------------------------

test.describe('Stack file explorer: optimistic concurrency on edits', () => {
  test.beforeEach(async ({ page }) => { await loginAs(page); });

  test('second concurrent save returns 412 with currentContent + currentMtimeMs', async ({ page, request }) => {
    const cookie = await cookieHeader(page);
    const filePath = nodePath.join(stackDir(), 'concurrent.txt');
    await fs.writeFile(filePath, 'initial\n');

    // Read once to capture the baseline ETag.
    const read = await request.get(
      `/api/stacks/${encodeURIComponent(STACK)}/files/content?path=${encodeURIComponent('concurrent.txt')}`,
      { headers: { cookie } },
    );
    expect(read.status()).toBe(200);
    const etag = read.headers()['etag'] ?? '';
    expect(etag).toMatch(/^W\/"\d+"$/);

    // First save with the captured ETag succeeds and bumps the on-disk mtime.
    const firstWrite = await request.put(
      `/api/stacks/${encodeURIComponent(STACK)}/files/content?path=${encodeURIComponent('concurrent.txt')}`,
      {
        headers: { cookie, 'If-Match': etag, 'Content-Type': 'application/json' },
        data: { content: 'first writer\n' },
      },
    );
    expect(firstWrite.status()).toBe(204);

    // Second save with the OLD ETag must return 412 and carry the current
    // server-side content so the client can reconcile.
    const secondWrite = await request.put(
      `/api/stacks/${encodeURIComponent(STACK)}/files/content?path=${encodeURIComponent('concurrent.txt')}`,
      {
        headers: { cookie, 'If-Match': etag, 'Content-Type': 'application/json' },
        data: { content: 'second writer\n' },
      },
    );
    expect(secondWrite.status()).toBe(412);
    const conflict = await secondWrite.json();
    expect(conflict.code).toBe('PRECONDITION_FAILED');
    expect(conflict.currentContent).toBe('first writer\n');
    expect(typeof conflict.currentMtimeMs).toBe('number');

    // Disk content matches the winning writer; the losing writer never landed.
    const onDisk = await fs.readFile(filePath, 'utf-8');
    expect(onDisk).toBe('first writer\n');
  });
});

// ---------------------------------------------------------------------------
// Binary detection + force=text override
// ---------------------------------------------------------------------------

test.describe('Stack file explorer: binary detection and force=text override', () => {
  test.beforeEach(async ({ page }) => { await loginAs(page); });

  test('default GET returns binary:true for a UTF-8 file carrying a NUL byte', async ({ page, request }) => {
    const cookie = await cookieHeader(page);
    const filePath = nodePath.join(stackDir(), 'utf8-with-nul.txt');
    await fs.writeFile(filePath, Buffer.from('hello\0world rest is utf8 text', 'utf-8'));

    const res = await request.get(
      `/api/stacks/${encodeURIComponent(STACK)}/files/content?path=${encodeURIComponent('utf8-with-nul.txt')}`,
      { headers: { cookie } },
    );
    expect(res.status()).toBe(200);
    const body = await res.json();
    expect(body.binary).toBe(true);
    expect(body.content).toBeUndefined();
  });

  test('GET with ?force=text returns the bytes as UTF-8 content', async ({ page, request }) => {
    const cookie = await cookieHeader(page);
    const raw = Buffer.from('hello\0world rest is utf8 text', 'utf-8');
    await fs.writeFile(nodePath.join(stackDir(), 'force-text.txt'), raw);

    const res = await request.get(
      `/api/stacks/${encodeURIComponent(STACK)}/files/content?path=${encodeURIComponent('force-text.txt')}&force=text`,
      { headers: { cookie } },
    );
    expect(res.status()).toBe(200);
    const body = await res.json();
    expect(body.binary).toBe(false);
    expect(body.content).toBe(raw.toString('utf-8'));
  });

  test('force=text on an oversized file still returns oversized:true (no inline content)', async ({ page, request }) => {
    const cookie = await cookieHeader(page);
    // 2.1 MB: just above the 2 MB inline-preview cap. Kept small on purpose
    // so the test does not pile memory pressure on top of the rest of the
    // CI run; the cap behaviour itself is exercised at every byte over the
    // threshold by the route-level test.
    await fs.writeFile(nodePath.join(stackDir(), 'oversized.txt'), 'x'.repeat(2_100_000));

    const res = await request.get(
      `/api/stacks/${encodeURIComponent(STACK)}/files/content?path=${encodeURIComponent('oversized.txt')}&force=text`,
      { headers: { cookie } },
    );
    expect(res.status()).toBe(200);
    const body = await res.json();
    expect(body.oversized).toBe(true);
    expect(body.content).toBeUndefined();
  });
});

// ---------------------------------------------------------------------------
// Symlink semantics (delete + chmod)
// ---------------------------------------------------------------------------

test.describe('Stack file explorer: symlink semantics', () => {
  test.beforeEach(async ({ page }) => { await loginAs(page); });

  // Symlink creation on Windows requires admin or developer mode; this block
  // runs on Linux CI where the harness has the privilege.
  test.skip(process.platform === 'win32', 'symlink creation requires admin on Windows');

  test('DELETE on a symlink removes the link entry; the target file stays intact', async ({ page, request }) => {
    const cookie = await cookieHeader(page);
    const target = nodePath.join(stackDir(), 'symlink-target.txt');
    const link = nodePath.join(stackDir(), 'symlink-link.txt');
    await fs.writeFile(target, 'survives\n');
    await fs.symlink(target, link);

    try {
      const res = await request.delete(
        `/api/stacks/${encodeURIComponent(STACK)}/files?path=${encodeURIComponent('symlink-link.txt')}`,
        { headers: { cookie } },
      );
      expect(res.status()).toBe(204);
      await expect(fs.lstat(link)).rejects.toMatchObject({ code: 'ENOENT' });
      const survived = await fs.readFile(target, 'utf-8');
      expect(survived).toBe('survives\n');
    } finally {
      await fs.unlink(target).catch(() => {});
    }
  });

  test('PUT permissions on a symlink returns 409 LINK_CHMOD_UNSUPPORTED', async ({ page, request }) => {
    const cookie = await cookieHeader(page);
    const target = nodePath.join(stackDir(), 'chmod-target.txt');
    const link = nodePath.join(stackDir(), 'chmod-link.txt');
    await fs.writeFile(target, 'payload\n');
    await fs.chmod(target, 0o644);
    await fs.symlink(target, link);

    try {
      const res = await request.put(
        `/api/stacks/${encodeURIComponent(STACK)}/files/permissions?path=${encodeURIComponent('chmod-link.txt')}`,
        {
          headers: { cookie, 'Content-Type': 'application/json' },
          data: { mode: 0o600 },
        },
      );
      expect(res.status()).toBe(409);
      expect((await res.json()).code).toBe('LINK_CHMOD_UNSUPPORTED');

      // Target mode unchanged.
      const stat = await fs.stat(target);
      expect(stat.mode & 0o777).toBe(0o644);
    } finally {
      await fs.unlink(link).catch(() => {});
      await fs.unlink(target).catch(() => {});
    }
  });
});

// ---------------------------------------------------------------------------
// UI lifecycle: mkdir, rename, chmod via the Files tab
// ---------------------------------------------------------------------------

test.describe('Stack file explorer: UI lifecycle', () => {
  test.setTimeout(90_000);

  function stackSlug(stackName: string): string {
    return stackName.replace(/^-+/, '').replace(/\.(ya?ml)$/i, '');
  }

  /** Open the Files tab via the routable stack editor URL. */
  async function openFilesTab(page: Page): Promise<void> {
    const slug = stackSlug(STACK);
    await page.goto(`/nodes/local/stacks/${encodeURIComponent(slug)}/files`);
    await waitForStacksLoaded(page);
    await expect(page.getByRole('button', { name: 'New file' })).toBeVisible({ timeout: 10_000 });
    await expect(page.locator('span.font-mono').first()).toBeVisible({ timeout: 10_000 });
  }

  test.beforeEach(async ({ page }) => {
    await loginAs(page);
    await openFilesTab(page);
  });

  test('upload, rename via the API, then verify the renamed file appears in the tree', async ({ page, request }) => {
    const cookie = await cookieHeader(page);
    // Upload via the UI to exercise the dropzone path.
    const input = page.locator('input[type="file"][aria-label="Upload file"]');
    await input.setInputFiles({
      name: 'lifecycle-rename-src.txt',
      mimeType: 'text/plain',
      buffer: Buffer.from('rename me\n'),
    });
    await expect(page.getByText(/uploaded/i).first()).toBeVisible({ timeout: 10_000 });

    // Rename via the API since the rename dialog is exercised in
    // stack-files.spec.ts; here we focus on the file appearing post-rename.
    const renameRes = await request.patch(
      `/api/stacks/${encodeURIComponent(STACK)}/files/rename`,
      {
        headers: { cookie, 'Content-Type': 'application/json' },
        data: { from: 'lifecycle-rename-src.txt', to: 'lifecycle-rename-dst.txt' },
      },
    );
    expect(renameRes.status()).toBe(204);

    await openFilesTab(page);
    await expect(
      page.locator('span.font-mono').filter({ hasText: /^lifecycle-rename-dst\.txt$/ }).first(),
    ).toBeVisible({ timeout: 8_000 });
  });

  test('mkdir via the API then verify the new folder is visible in the tree', async ({ page, request }) => {
    const cookie = await cookieHeader(page);
    const res = await request.post(
      `/api/stacks/${encodeURIComponent(STACK)}/files/folder?path=${encodeURIComponent('lifecycle-new-folder')}`,
      { headers: { cookie } },
    );
    expect(res.status()).toBe(204);

    await openFilesTab(page);
    await expect(
      page.locator('span.font-mono').filter({ hasText: /^lifecycle-new-folder$/ }).first(),
    ).toBeVisible({ timeout: 8_000 });
  });

  test('the New file toolbar button creates a root-level file', async ({ page }) => {
    await page.getByRole('button', { name: 'New file' }).click();
    await page.getByLabel('File name').fill('ui-new-file.txt');
    await page.getByRole('button', { name: /^create$/i }).click();

    await expect(page.getByText(/file created/i).first()).toBeVisible({ timeout: 8_000 });
    await expect(
      page.locator('span.font-mono').filter({ hasText: /^ui-new-file\.txt$/ }).first(),
    ).toBeVisible({ timeout: 8_000 });
  });

  test('creating a duplicate name is blocked and does not overwrite the existing file', async ({ page, request }) => {
    const cookie = await cookieHeader(page);
    const seedRes = await request.put(
      `/api/stacks/${encodeURIComponent(STACK)}/files/content?path=${encodeURIComponent('dup-guard.txt')}`,
      {
        headers: { cookie, 'Content-Type': 'application/json' },
        data: { content: 'original\n' },
      },
    );
    expect(seedRes.ok()).toBeTruthy();

    await openFilesTab(page);
    await expect(
      page.locator('span.font-mono').filter({ hasText: /^dup-guard\.txt$/ }).first(),
    ).toBeVisible({ timeout: 8_000 });

    await page.getByRole('button', { name: 'New file' }).click();
    await page.getByLabel('File name').fill('dup-guard.txt');
    await page.getByRole('button', { name: /^create$/i }).click();

    await expect(page.getByText(/a file with that name already exists/i)).toBeVisible({ timeout: 8_000 });
    // The server rejected the create, so the existing contents are intact.
    const verifyRes = await request.get(
      `/api/stacks/${encodeURIComponent(STACK)}/files/content?path=${encodeURIComponent('dup-guard.txt')}`,
      { headers: { cookie } },
    );
    expect(verifyRes.ok()).toBeTruthy();
    const payload = await verifyRes.json() as { content?: string };
    expect(payload.content).toBe('original\n');
  });

  test('right-clicking away from the filename still opens the Sencho context menu', async ({ page }) => {
    const row = page.locator('[role="treeitem"]').filter({ hasText: 'compose.yaml' }).first();
    await expect(row).toBeVisible({ timeout: 8_000 });
    const box = await row.boundingBox();
    if (!box) throw new Error('no bounding box for the compose.yaml row');

    // Right-click near the right edge, well past the filename text: the whole
    // row is the trigger, so the Sencho menu (not the native one) must open.
    await row.click({ button: 'right', position: { x: box.width - 6, y: box.height / 2 } });
    await expect(page.getByText('Rename')).toBeVisible({ timeout: 5_000 });
  });

  test('a long filename overflows the pane horizontally instead of being clipped', async ({ page }) => {
    const longName = 'a-really-long-file-name-that-should-overflow-the-narrow-file-tree-pane.txt';
    await fs.writeFile(nodePath.join(stackDir(), longName), '');
    await page.reload();
    await openFilesTab(page);
    await expect(
      page.locator('span.font-mono').filter({ hasText: longName }).first(),
    ).toBeVisible({ timeout: 8_000 });

    // The tree's scroll viewport overflows horizontally, so the name is reachable
    // by scrolling rather than truncated.
    const overflows = await page.getByTestId('file-tree-root-dropzone').evaluate((el) => {
      const vp = el.closest('[data-radix-scroll-area-viewport]') as HTMLElement | null;
      return vp ? vp.scrollWidth > vp.clientWidth : false;
    });
    expect(overflows).toBe(true);
  });
});

// ---------------------------------------------------------------------------
// Out-of-scope coverage stubs (documented skips)
// ---------------------------------------------------------------------------

test.describe('Stack file explorer: deferred coverage', () => {
  test.skip('remote-node matrix: upload through proxy to a peer', () => {
    // Requires a remote node enrolled in the CI database and a reachable
    // peer at its api_url. Covered at the unit-test layer by the multipart
    // proxy regression guard in backend/src/__tests__/stack-files-remote-upload.test.ts.
  });

  test.skip('mid-op disconnect: stop the remote, retry upload, observe 502 toast', () => {
    // Same constraint: a real peer plus a controlled-disconnect harness.
  });

  test.skip('developer-mode matrix: assert diag lines appear only when developer_mode = 1', () => {
    // Requires reading the backend's stdout stream from the Playwright harness.
    // The backend unit test in stack-files-routes.test.ts already pins this
    // behaviour via a console.debug spy.
  });

  test.skip('viewer-role tier-persona matrix', () => {
    // Requires seeding a non-admin viewer user and re-driving the auth flow.
    // Role gating is covered at the route-test layer in stack-files-routes.test.ts;
    // the viewer-can-read / admin-can-write split is asserted there.
  });

  test.skip('large directory truncation: 1100 entries surface 1000 + X-Truncated header', () => {
    // Seeding 1100 files inline added enough wall-clock and inode-table
    // churn that subsequent specs running in the single-worker queue saw
    // their post-login dashboard render time exceed 10 s. The truncation
    // contract is fully exercised at the route-test layer (the same case
    // lives in backend/src/__tests__/stack-files-routes.test.ts) and at
    // the service-test layer (filesystem-stack-paths.test.ts).
  });

  test.skip('25 MB upload cap: a 25 MB + 1 byte payload trips multer LIMIT_FILE_SIZE', () => {
    // Sending a 25 MB buffer through supertest piled enough memory pressure
    // on the shared worker that downstream specs timed out on their login
    // dashboard wait. The cap behaviour is exercised at the route-test
    // layer in backend/src/__tests__/stack-files-routes.test.ts.
  });
});
