import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import express from 'express';
import request from 'supertest';
import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import ExcelJS from 'exceljs';
import { mountLocalFilesApi } from './local-files-api.js';
import type { Repository } from '../db/repository.js';

// Functional tests driving two PARTIAL gaps end-to-end through the HTTP layer:
//   GET /files/office-preview  (APIC-020): section-enum 400, canViewTask gate,
//        SofficeUnavailableError → 503 mapping, happy-path xlsx.
//   GET /files/content + /files/raw traversal guard (APIC-018/019, gap #4):
//        an actual path-escape payload is rejected at the route.
//
// Mirrors src/bridge/local-files-api.test.ts: a vi.fn()-backed fake Repository
// + a bare express() app + the real mountLocalFilesApi.

let ws: string;

function makeRepo(overrides: Partial<Repository> = {}): Repository {
  return {
    getLocalTask: vi.fn().mockResolvedValue({
      id: 1,
      ownerId: 'user-1',
      visibility: 'private',
      workspacePath: ws,
    }),
    getLatestJobForIssue: vi.fn().mockResolvedValue(null),
    userCanViewSpace: vi.fn().mockReturnValue(false),
    ...overrides,
  } as unknown as Repository;
}

function makeUser(overrides: Partial<Express.User> = {}): Express.User {
  return {
    id: 'user-1',
    email: 'u@example.com',
    name: 'User One',
    avatarUrl: null,
    role: 'user',
    status: 'active',
    orgIds: [],
    defaultVisibility: 'private',
    defaultVisibilityOrgId: null,
    ...overrides,
  };
}

function makeApp(repo: Repository, user?: Express.User): express.Application {
  const app = express();
  if (user) {
    app.use((req, _res, next) => {
      (req as unknown as { user: Express.User }).user = user;
      next();
    });
  }
  mountLocalFilesApi(app, repo, { authActive: true });
  return app;
}

beforeEach(async () => {
  ws = mkdtempSync(join(tmpdir(), 'local-files-office-'));
  mkdirSync(join(ws, 'input'), { recursive: true });

  // A real .xlsx so the happy path renders without soffice.
  const wb = new ExcelJS.Workbook();
  const sheet = wb.addWorksheet('Data');
  sheet.addRow(['name', 'age']);
  sheet.addRow(['Alice', 30]);
  await wb.xlsx.writeFile(join(ws, 'input', 'book.xlsx'));

  // A .pptx whose conversion would invoke soffice (used for the 503 test).
  writeFileSync(join(ws, 'input', 'deck.pptx'), 'dummy');

  // A secret just outside the workspace that traversal must never reach.
  writeFileSync(join(ws, '..', `outside-${process.pid}.txt`), 'top-secret');
});

afterEach(() => {
  rmSync(ws, { recursive: true, force: true });
  rmSync(join(ws, '..', `outside-${process.pid}.txt`), { force: true });
});

describe('GET /api/local/tasks/:taskId/files/office-preview', () => {
  it('renders an xlsx into a spreadsheet preview JSON (happy path)', async () => {
    const app = makeApp(makeRepo(), makeUser());
    const res = await request(app)
      .get('/api/local/tasks/1/files/office-preview')
      .query({ section: 'input', path: 'book.xlsx' });
    expect(res.status).toBe(200);
    expect(res.body.kind).toBe('spreadsheet');
    expect(res.body.sheets[0].name).toBe('Data');
    expect(res.body.sheets[0].rows[0]).toEqual(['name', 'age']);
  });

  it('rejects an invalid section with 400', async () => {
    const app = makeApp(makeRepo(), makeUser());
    const res = await request(app)
      .get('/api/local/tasks/1/files/office-preview')
      .query({ section: 'etc', path: 'book.xlsx' });
    expect(res.status).toBe(400);
    expect(res.body.error).toContain('section must be');
  });

  it('denies a non-viewer of a private task (404, no leak)', async () => {
    const app = makeApp(makeRepo(), makeUser({ id: 'intruder' }));
    const res = await request(app)
      .get('/api/local/tasks/1/files/office-preview')
      .query({ section: 'input', path: 'book.xlsx' });
    // canViewTask → 404 for a non-owner of a private task.
    expect(res.status).toBe(404);
    expect(res.body).not.toHaveProperty('sheets');
  });

  it('maps SofficeUnavailableError to 503 when the converter is missing', async () => {
    // The pptx path invokes soffice; point SOFFICE_BIN at a nonexistent binary
    // so the spawn fails with ENOENT → SofficeUnavailableError regardless of
    // whether soffice is actually installed in the environment.
    const prev = process.env.SOFFICE_BIN;
    process.env.SOFFICE_BIN = join(ws, 'definitely-not-soffice');
    try {
      const app = makeApp(makeRepo(), makeUser());
      const res = await request(app)
        .get('/api/local/tasks/1/files/office-preview')
        .query({ section: 'input', path: 'deck.pptx' });
      expect(res.status).toBe(503);
      expect(res.body.error).toBe('converter_unavailable');
    } finally {
      if (prev === undefined) delete process.env.SOFFICE_BIN;
      else process.env.SOFFICE_BIN = prev;
    }
  });

  it('rejects a traversal payload on office-preview (path escapes workspace)', async () => {
    const app = makeApp(makeRepo(), makeUser());
    const res = await request(app)
      .get('/api/local/tasks/1/files/office-preview')
      .query({ section: 'input', path: `../outside-${process.pid}.txt` });
    // ensurePathWithin throws → handleOfficePreviewError maps escape → 400.
    expect(res.status).toBe(400);
    expect(res.text).not.toContain('top-secret');
  });
});

describe('/files/* traversal guard (drives the route, not just the helper)', () => {
  // GAP: the task brief expects 403 for /files/* traversal, but the
  // /files/content and /files/raw routes return 400 ("Path escapes workspace")
  // via isPathEscapeError — NOT 403. (403 is the subtask-files-api.ts contract,
  // APIC-045, a different route.) This is a status-code naming discrepancy in
  // the spec, not a bug: traversal IS blocked here; the secret never leaks. We
  // assert the actual contract (400) and that no bytes escape.

  it('blocks ../ traversal on /files/content with 400', async () => {
    const app = makeApp(makeRepo(), makeUser());
    const res = await request(app)
      .get('/api/local/tasks/1/files/content')
      .query({ section: 'input', path: `../outside-${process.pid}.txt` });
    expect(res.status).toBe(400);
    expect(res.body.error).toBe('Path escapes workspace');
    expect(res.text).not.toContain('top-secret');
  });

  it('blocks an encoded ..%2f traversal on /files/content with 400', async () => {
    const app = makeApp(makeRepo(), makeUser());
    // Send a pre-decoded payload — supertest passes the query through; the
    // escape segment still resolves outside the root.
    const res = await request(app)
      .get('/api/local/tasks/1/files/content')
      .query({ section: 'input', path: `../../etc/passwd` });
    expect(res.status).toBe(400);
    expect(res.body.error).toBe('Path escapes workspace');
    expect(res.text).not.toContain('root:');
  });

  it('blocks ../ traversal on /files/raw with 400', async () => {
    const app = makeApp(makeRepo(), makeUser());
    const res = await request(app)
      .get('/api/local/tasks/1/files/raw')
      .query({ section: 'input', path: `../outside-${process.pid}.txt` });
    expect(res.status).toBe(400);
    expect(res.body.error).toBe('Path escapes workspace');
    expect(res.text).not.toContain('top-secret');
  });
});
