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

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),
    ...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,
  opts: { authActive?: boolean } = {},
): 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: opts.authActive ?? true });
  return app;
}

beforeEach(() => {
  ws = mkdtempSync(join(tmpdir(), 'local-files-api-'));
  mkdirSync(join(ws, 'input'), { recursive: true });
  mkdirSync(join(ws, 'output', 'sub'), { recursive: true });
  writeFileSync(join(ws, 'input', 'data.csv'), 'a,b\n1,2\n');
  writeFileSync(join(ws, 'output', 'report.md'), '# report');
  writeFileSync(join(ws, 'output', 'report.html'), '<!doctype html><script>window.__ran = true</script><h1>report</h1>');
  writeFileSync(join(ws, 'output', 'sub', 'nested.txt'), 'nested');
  // A file just outside the workspace that traversal must never reach.
  writeFileSync(join(ws, '..', `outside-${process.pid}.txt`), 'secret');
});

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

describe('GET /api/local/tasks/:taskId/files (listing)', () => {
  it('lists the input section by default', async () => {
    const res = await request(makeApp(makeRepo(), makeUser())).get('/api/local/tasks/1/files');
    expect(res.status).toBe(200);
    expect(res.body.basePath).toBe('input');
    expect(res.body.entries.map((e: { name: string }) => e.name)).toEqual(['data.csv']);
  });

  it('lists a subdirectory via the path query', async () => {
    const res = await request(makeApp(makeRepo(), makeUser()))
      .get('/api/local/tasks/1/files?section=output&path=sub');
    expect(res.status).toBe(200);
    expect(res.body.entries.map((e: { name: string }) => e.name)).toEqual(['nested.txt']);
  });

  it('rejects an unknown section with 400', async () => {
    const res = await request(makeApp(makeRepo(), makeUser()))
      .get('/api/local/tasks/1/files?section=secrets');
    expect(res.status).toBe(400);
  });

  it('rejects an invalid task id with 400', async () => {
    const res = await request(makeApp(makeRepo(), makeUser())).get('/api/local/tasks/-1/files');
    expect(res.status).toBe(400);
  });

  it('hides a private task from a non-owner with 404', async () => {
    const res = await request(makeApp(makeRepo(), makeUser({ id: 'stranger' })))
      .get('/api/local/tasks/1/files');
    expect(res.status).toBe(404);
  });

  it('rejects directory traversal on listing with 400', async () => {
    const res = await request(makeApp(makeRepo(), makeUser()))
      .get('/api/local/tasks/1/files?section=input&path=..%2F..');
    expect(res.status).toBe(400);
    expect(res.body.error).toBe('Path escapes workspace');
    expect(JSON.stringify(res.body)).not.toContain('outside-');
  });
});

describe('GET /api/local/tasks/:taskId/files/content', () => {
  it('serves file content as text/plain', async () => {
    const res = await request(makeApp(makeRepo(), makeUser()))
      .get('/api/local/tasks/1/files/content?section=output&path=report.md');
    expect(res.status).toBe(200);
    expect(res.headers['content-type']).toContain('text/plain');
    expect(res.text).toBe('# report');
  });

  it('requires a path', async () => {
    const res = await request(makeApp(makeRepo(), makeUser()))
      .get('/api/local/tasks/1/files/content?section=output');
    expect(res.status).toBe(400);
    expect(res.body.error).toBe('path is required');
  });

  it('rejects a directory path with 400', async () => {
    const res = await request(makeApp(makeRepo(), makeUser()))
      .get('/api/local/tasks/1/files/content?section=output&path=sub');
    expect(res.status).toBe(400);
    expect(res.body.error).toBe('path must point to a file');
  });

  it('rejects traversal reads with 400 and never serves outside files', async () => {
    const res = await request(makeApp(makeRepo(), makeUser()))
      .get(`/api/local/tasks/1/files/content?section=input&path=..%2F..%2Foutside-${process.pid}.txt`);
    expect(res.status).toBe(400);
    expect(res.body.error).toBe('Path escapes workspace');
    expect(res.text).not.toContain('secret');
  });

  it('strips leading slashes so absolute paths stay inside the workspace', async () => {
    const res = await request(makeApp(makeRepo(), makeUser()))
      .get('/api/local/tasks/1/files/content?section=input&path=%2Fetc%2Fpasswd');
    expect([404, 500]).toContain(res.status);
    expect(res.text).not.toContain('root:');
  });
});

describe('GET /api/local/tasks/:taskId/files/raw', () => {
  it('serves bytes with a type derived from the extension', async () => {
    const res = await request(makeApp(makeRepo(), makeUser()))
      .get('/api/local/tasks/1/files/raw?section=output&path=report.md');
    expect(res.status).toBe(200);
    expect(res.headers['content-type']).toContain('markdown');
  });

  it('sandboxes raw HTML by default', async () => {
    const res = await request(makeApp(makeRepo(), makeUser()))
      .get('/api/local/tasks/1/files/raw?section=output&path=report.html');
    expect(res.status).toBe(200);
    expect(res.headers['content-security-policy']).toBe('sandbox');
  });

  it('allows the task OWNER to open trusted raw HTML without the sandbox header', async () => {
    const res = await request(makeApp(makeRepo(), makeUser()))
      .get('/api/local/tasks/1/files/raw?section=output&path=report.html&trusted=1');
    expect(res.status).toBe(200);
    expect(res.headers['content-security-policy']).toBeUndefined();
    expect(res.headers['content-type']).toContain('html');
  });

  it("keeps trusted raw HTML sandboxed even for an admin on another user's task (user→admin lure)", async () => {
    const res = await request(makeApp(makeRepo(), makeUser({ id: 'admin-9', role: 'admin' })))
      .get('/api/local/tasks/1/files/raw?section=output&path=report.html&trusted=1');
    expect(res.status).toBe(200);
    expect(res.headers['content-security-policy']).toBe('sandbox');
  });

  it('keeps trusted raw HTML sandboxed for a NON-owner viewer of a shared task', async () => {
    const repo = makeRepo({
      getLocalTask: vi.fn().mockResolvedValue({
        id: 1,
        ownerId: 'user-1',
        visibility: 'public',
        workspacePath: ws,
      }),
    } as Partial<Repository>);
    const res = await request(makeApp(repo, makeUser({ id: 'user-2' })))
      .get('/api/local/tasks/1/files/raw?section=output&path=report.html&trusted=1');
    expect(res.status).toBe(200);
    expect(res.headers['content-security-policy']).toBe('sandbox');
  });

  it('keeps trusted raw HTML sandboxed for an ownerless task even when authenticated', async () => {
    const repo = makeRepo({
      getLocalTask: vi.fn().mockResolvedValue({
        id: 1,
        ownerId: null,
        visibility: 'public',
        workspacePath: ws,
      }),
    } as Partial<Repository>);
    const res = await request(makeApp(repo, makeUser()))
      .get('/api/local/tasks/1/files/raw?section=output&path=report.html&trusted=1');
    expect(res.status).toBe(200);
    expect(res.headers['content-security-policy']).toBe('sandbox');
  });

  it('keeps trusted raw HTML sandboxed when auth is on but no user is present', async () => {
    const res = await request(makeApp(makeRepo()))
      .get('/api/local/tasks/1/files/raw?section=output&path=report.html&trusted=1');
    expect(res.status).toBe(200);
    expect(res.headers['content-security-policy']).toBe('sandbox');
  });

  it('allows trusted raw HTML in no-auth mode (sole operator owns every task)', async () => {
    const res = await request(makeApp(makeRepo(), undefined, { authActive: false }))
      .get('/api/local/tasks/1/files/raw?section=output&path=report.html&trusted=1');
    expect(res.status).toBe(200);
    expect(res.headers['content-security-policy']).toBeUndefined();
    expect(res.headers['content-type']).toContain('html');
  });

  it('still sandboxes non-HTML in no-auth mode even with trusted=1', async () => {
    const res = await request(makeApp(makeRepo(), undefined, { authActive: false }))
      .get('/api/local/tasks/1/files/raw?section=output&path=report.md&trusted=1');
    expect(res.status).toBe(200);
    expect(res.headers['content-security-policy']).toBe('sandbox');
  });

  it('rejects traversal reads with 400 and never serves outside files', async () => {
    const res = await request(makeApp(makeRepo(), makeUser()))
      .get(`/api/local/tasks/1/files/raw?section=input&path=..%2F..%2Foutside-${process.pid}.txt`);
    expect(res.status).toBe(400);
    expect(res.body.error).toBe('Path escapes workspace');
    expect(res.text).not.toContain('secret');
  });
});

describe('PUT /api/local/tasks/:taskId/files/content', () => {
  const put = (app: express.Application, body: unknown) =>
    request(app).put('/api/local/tasks/1/files/content').send(body as object);

  it('writes an output file for the owner', async () => {
    const res = await put(makeApp(makeRepo(), makeUser()), {
      section: 'output',
      path: 'report.md',
      content: 'updated',
    });
    expect(res.status).toBe(200);
    expect(readFileSync(join(ws, 'output', 'report.md'), 'utf-8')).toBe('updated');
  });

  it('hides the task from a non-owner with 404', async () => {
    const res = await put(makeApp(makeRepo(), makeUser({ id: 'stranger' })), {
      section: 'output',
      path: 'report.md',
      content: 'x',
    });
    expect(res.status).toBe(404);
    expect(readFileSync(join(ws, 'output', 'report.md'), 'utf-8')).toBe('# report');
  });

  it('lets an admin edit another user\'s task', async () => {
    const res = await put(makeApp(makeRepo(), makeUser({ id: 'admin-1', role: 'admin' })), {
      section: 'output',
      path: 'report.md',
      content: 'admin edit',
    });
    expect(res.status).toBe(200);
  });

  it('refuses edits while a job is running', async () => {
    const repo = makeRepo({
      getLatestJobForIssue: vi.fn().mockResolvedValue({ status: 'running' }),
    } as Partial<Repository>);
    const res = await put(makeApp(repo, makeUser()), {
      section: 'output',
      path: 'report.md',
      content: 'x',
    });
    expect(res.status).toBe(409);
  });

  it('only allows the output section', async () => {
    const res = await put(makeApp(makeRepo(), makeUser()), {
      section: 'input',
      path: 'data.csv',
      content: 'x',
    });
    expect(res.status).toBe(400);
    expect(res.body.error).toBe('Only output files can be edited');
  });

  it('requires string content', async () => {
    const res = await put(makeApp(makeRepo(), makeUser()), {
      section: 'output',
      path: 'report.md',
      content: 42,
    });
    expect(res.status).toBe(400);
  });

  it('rejects traversal writes with 400 and writes nothing', async () => {
    const res = await put(makeApp(makeRepo(), makeUser()), {
      section: 'output',
      path: '../evil.txt',
      content: 'pwned',
    });
    expect(res.status).toBe(400);
    expect(res.body.error).toBe('Path escapes workspace');
    expect(existsSync(join(ws, 'evil.txt'))).toBe(false);
  });
});
