import { randomBytes } from 'node:crypto';
import { access, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import Fastify, { type FastifyInstance } from 'fastify';
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest';
import { defaultVault, encryptVault, saveVaultFile } from '../vault.js';
import { isLocked, lockVault } from '../vault-store.js';
import { registerVaultRoutes } from '../vault-routes.js';

const PASSWORD = 'test-password-123';

function makeTmpPath() {
  return join(tmpdir(), `vault-test-${randomBytes(4).toString('hex')}.vault`);
}

async function buildApp(vaultPath: string): Promise<FastifyInstance> {
  process.env['VAULT_PATH'] = vaultPath;
  const app = Fastify();
  await registerVaultRoutes(app);
  await app.ready();
  return app;
}

// ── Tests where the vault file does NOT yet exist ─────────────────────────────

describe('GET /api/vault/status', () => {
  let vaultPath: string;
  let app: FastifyInstance;

  beforeEach(async () => {
    vaultPath = makeTmpPath();
    app = await buildApp(vaultPath);
  });

  afterEach(async () => {
    lockVault();
    await app.close();
    await rm(vaultPath, { force: true });
    delete process.env['VAULT_PATH'];
  });

  it('returns locked=true and hasFile=false when no vault file exists', async () => {
    const res = await app.inject({ method: 'GET', url: '/api/vault/status' });
    expect(res.statusCode).toBe(200);
    expect(res.json()).toEqual({ locked: true, hasFile: false });
  });
});

// ── Tests for POST /api/vault/create ─────────────────────────────────────────

describe('POST /api/vault/create', () => {
  let vaultPath: string;
  let app: FastifyInstance;

  beforeEach(async () => {
    vaultPath = makeTmpPath();
    app = await buildApp(vaultPath);
  });

  afterEach(async () => {
    lockVault();
    await app.close();
    await rm(vaultPath, { force: true });
    delete process.env['VAULT_PATH'];
  });

  it('creates a vault and returns 201', async () => {
    const res = await app.inject({
      method: 'POST',
      url: '/api/vault/create',
      payload: { password: PASSWORD, serverUrl: 'http://localhost:4000/graphql', apiKey: 'key1' },
    });
    expect(res.statusCode).toBe(201);
    expect(res.json()).toEqual({ ok: true });
  });

  it('GET /api/vault/status after create returns locked=false and hasFile=true', async () => {
    await app.inject({
      method: 'POST',
      url: '/api/vault/create',
      payload: { password: PASSWORD, serverUrl: 'http://localhost:4000/graphql', apiKey: 'key1' },
    });
    const res = await app.inject({ method: 'GET', url: '/api/vault/status' });
    expect(res.json()).toEqual({ locked: false, hasFile: true });
  });

  it('returns 409 when vault file already exists', async () => {
    await app.inject({
      method: 'POST',
      url: '/api/vault/create',
      payload: { password: PASSWORD, serverUrl: 'http://localhost:4000/graphql', apiKey: 'key1' },
    });
    const res = await app.inject({
      method: 'POST',
      url: '/api/vault/create',
      payload: { password: PASSWORD, serverUrl: 'http://localhost:4000/graphql', apiKey: 'key1' },
    });
    expect(res.statusCode).toBe(409);
  });
});

// ── Tests where the vault file ALREADY exists ─────────────────────────────────

describe('GET /api/vault/status', () => {
  let vaultPath: string;
  let app: FastifyInstance;

  beforeEach(async () => {
    vaultPath = makeTmpPath();
    await saveVaultFile(vaultPath, defaultVault(), PASSWORD);
    app = await buildApp(vaultPath);
  });

  afterEach(async () => {
    lockVault();
    await app.close();
    await rm(vaultPath, { force: true });
    delete process.env['VAULT_PATH'];
  });

  it('returns locked=true and hasFile=true before unlock', async () => {
    const res = await app.inject({ method: 'GET', url: '/api/vault/status' });
    expect(res.statusCode).toBe(200);
    expect(res.json()).toEqual({ locked: true, hasFile: true });
  });

  it('returns locked=false after successful unlock', async () => {
    await app.inject({
      method: 'POST',
      url: '/api/vault/unlock',
      payload: { password: PASSWORD },
    });
    const res = await app.inject({ method: 'GET', url: '/api/vault/status' });
    expect(res.json()).toMatchObject({ locked: false, hasFile: true });
  });
});

describe('GET /api/vault/env-path', () => {
  let vaultPath: string;
  let app: FastifyInstance;

  beforeEach(async () => {
    vaultPath = makeTmpPath();
    app = await buildApp(vaultPath);
    process.env['VAULT_PATH'] = '/tmp/custom.vault';
  });

  afterEach(async () => {
    lockVault();
    await app.close();
    await rm(vaultPath, { force: true });
    delete process.env['VAULT_PATH'];
  });

  it('returns the VAULT_PATH env var when set', async () => {
    const res = await app.inject({ method: 'GET', url: '/api/vault/env-path' });
    expect(res.statusCode).toBe(200);
    expect(res.json()).toEqual({ path: '/tmp/custom.vault' });
  });

  it('returns ".vault" when VAULT_PATH is not set', async () => {
    delete process.env['VAULT_PATH'];
    const res = await app.inject({ method: 'GET', url: '/api/vault/env-path' });
    expect(res.statusCode).toBe(200);
    expect(res.json()).toEqual({ path: '.vault' });
  });
});

describe('GET /api/vault/download', () => {
  describe('when vault file exists', () => {
    let vaultPath: string;
    let app: FastifyInstance;

    beforeEach(async () => {
      vaultPath = makeTmpPath();
      await saveVaultFile(vaultPath, defaultVault(), PASSWORD);
      app = await buildApp(vaultPath);
    });

    afterEach(async () => {
      lockVault();
      await app.close();
      await rm(vaultPath, { force: true });
      delete process.env['VAULT_PATH'];
    });

    it('returns 200 with octet-stream content-type when vault file exists', async () => {
      const res = await app.inject({ method: 'GET', url: '/api/vault/download' });
      expect(res.statusCode).toBe(200);
      expect(res.headers['content-type']).toMatch(/application\/octet-stream/);
      expect(res.headers['content-disposition']).toBe('attachment; filename=".vault"');
      expect(res.rawPayload.length).toBeGreaterThan(0);
    });
  });

  describe('when vault file does not exist', () => {
    let vaultPath: string;
    let app: FastifyInstance;

    beforeEach(async () => {
      vaultPath = makeTmpPath();
      app = await buildApp(vaultPath);
    });

    afterEach(async () => {
      lockVault();
      await app.close();
      await rm(vaultPath, { force: true });
      delete process.env['VAULT_PATH'];
    });

    it('returns 404 when no vault file exists', async () => {
      const res = await app.inject({ method: 'GET', url: '/api/vault/download' });
      expect(res.statusCode).toBe(404);
      expect(res.json().error).toBe('no-vault-file');
    });
  });
});

describe('POST /api/vault/unlock', () => {
  let vaultPath: string;
  let app: FastifyInstance;

  beforeEach(async () => {
    vaultPath = makeTmpPath();
    await saveVaultFile(vaultPath, defaultVault(), PASSWORD);
    app = await buildApp(vaultPath);
  });

  afterEach(async () => {
    lockVault();
    await app.close();
    await rm(vaultPath, { force: true });
    delete process.env['VAULT_PATH'];
  });

  it('returns ok with correct password', async () => {
    const res = await app.inject({
      method: 'POST',
      url: '/api/vault/unlock',
      payload: { password: PASSWORD },
    });
    expect(res.statusCode).toBe(200);
    expect(res.json()).toEqual({ ok: true });
  });

  it('returns 401 with wrong password', async () => {
    const res = await app.inject({
      method: 'POST',
      url: '/api/vault/unlock',
      payload: { password: 'wrong-password' },
    });
    expect(res.statusCode).toBe(401);
  });
});

// ── Tests for POST /api/vault/upload ─────────────────────────────────────────

async function uploadVault(
  app: FastifyInstance,
  opts: { force?: boolean } = {},
): Promise<ReturnType<FastifyInstance['inject']>> {
  const blob = await encryptVault(defaultVault(), PASSWORD);
  return app.inject({
    method: 'POST',
    url: '/api/vault/upload' + (opts.force ? '?force=true' : ''),
    payload: Buffer.from(blob, 'base64'),
    headers: { 'content-type': 'application/octet-stream' },
  });
}

describe('POST /api/vault/upload', () => {
  let vaultPath: string;
  let app: FastifyInstance;

  beforeEach(async () => {
    vaultPath = makeTmpPath();
    app = await buildApp(vaultPath);
  });

  afterEach(async () => {
    lockVault();
    await app.close();
    await rm(vaultPath, { force: true });
    await rm(vaultPath + '.tmp', { force: true });
    delete process.env['VAULT_PATH'];
  });

  it('returns 200 and writes the vault file when no file exists', async () => {
    const res = await uploadVault(app);
    expect(res.statusCode).toBe(200);
    expect(res.json()).toEqual({ ok: true });
    const exists = await access(vaultPath).then(() => true).catch(() => false);
    expect(exists).toBe(true);
  });

  it('returns 409 when vault already exists and force is not set', async () => {
    await saveVaultFile(vaultPath, defaultVault(), PASSWORD);
    const res = await uploadVault(app);
    expect(res.statusCode).toBe(409);
    expect(res.json().error).toBe('vault-already-exists');
  });

  it('overwrites and returns 200 when force=true and vault exists', async () => {
    await saveVaultFile(vaultPath, defaultVault(), PASSWORD);
    const res = await uploadVault(app, { force: true });
    expect(res.statusCode).toBe(200);
  });

  it('locks the vault after successful upload even if it was unlocked', async () => {
    await saveVaultFile(vaultPath, defaultVault(), PASSWORD);
    await app.inject({
      method: 'POST',
      url: '/api/vault/unlock',
      payload: { password: PASSWORD },
    });
    expect(isLocked()).toBe(false);
    await uploadVault(app, { force: true });
    const res = await app.inject({ method: 'GET', url: '/api/vault/status' });
    expect(res.json()).toMatchObject({ locked: true });
  });
});

// ── Tests for GET /api/vault/test-connection ─────────────────────────────────

describe('GET /api/vault/test-connection', () => {
  const SERVER_URL = 'http://localhost:4000/graphql';
  const API_KEY = 'scraper-key-1';

  let vaultPath: string;
  let app: FastifyInstance;
  let fetchSpy: MockInstance<typeof globalThis.fetch>;

  function mockReply(body: string, init?: { status?: number; contentType?: string }) {
    fetchSpy.mockResolvedValue(
      new Response(body, {
        status: init?.status ?? 200,
        headers: { 'content-type': init?.contentType ?? 'application/json' },
      }),
    );
  }

  beforeEach(async () => {
    vaultPath = makeTmpPath();
    app = await buildApp(vaultPath);
    fetchSpy = vi.spyOn(globalThis, 'fetch');
    await app.inject({
      method: 'POST',
      url: '/api/vault/create',
      payload: { password: PASSWORD, serverUrl: SERVER_URL, apiKey: API_KEY },
    });
  });

  afterEach(async () => {
    fetchSpy.mockRestore();
    lockVault();
    await app.close();
    await rm(vaultPath, { force: true });
    delete process.env['VAULT_PATH'];
  });

  it('probes with the same X-API-Key header the upload client sends', async () => {
    mockReply(JSON.stringify({ data: { __typename: 'Query' } }));

    await app.inject({ method: 'GET', url: '/api/vault/test-connection' });

    expect(fetchSpy).toHaveBeenCalledTimes(1);
    const [url, init] = fetchSpy.mock.calls[0]!;
    expect(url).toBe(SERVER_URL);
    const headers = (init as RequestInit).headers as Record<string, string>;
    expect(headers['X-API-Key']).toBe(API_KEY);
    expect(headers['Authorization']).toBeUndefined();
  });

  it('reports ok for a real GraphQL result', async () => {
    mockReply(JSON.stringify({ data: { __typename: 'Query' } }));

    const res = await app.inject({ method: 'GET', url: '/api/vault/test-connection' });

    expect(res.statusCode).toBe(200);
    expect(res.json()).toMatchObject({ ok: true });
    expect(res.json().latencyMs).toBeTypeOf('number');
  });

  it('fails on a 200 that is really a SPA index.html', async () => {
    // The regression this route missed: a serverUrl pointing at a SPA (this very app)
    // answers an unknown path with index.html and a 200, which used to read as healthy.
    mockReply('<!doctype html>\n<html lang="en"><head><title>Scraper App</title></head></html>', {
      contentType: 'text/html',
    });

    const res = await app.inject({ method: 'GET', url: '/api/vault/test-connection' });

    expect(res.json().ok).toBe(false);
    expect(res.json().error).toContain('did not return JSON');
    expect(res.json().error).toContain('/graphql');
  });

  it('surfaces a GraphQL error message', async () => {
    mockReply(JSON.stringify({ errors: [{ message: 'Unauthorized' }] }));

    const res = await app.inject({ method: 'GET', url: '/api/vault/test-connection' });

    expect(res.json()).toMatchObject({ ok: false, error: 'Unauthorized' });
  });

  it('fails on valid JSON that is not a GraphQL result', async () => {
    mockReply(JSON.stringify({ hello: 'world' }));

    const res = await app.inject({ method: 'GET', url: '/api/vault/test-connection' });

    expect(res.json()).toMatchObject({ ok: false });
    expect(res.json().error).toContain('did not return a GraphQL result');
  });

  it('reports the status and body of a non-2xx response', async () => {
    mockReply('nope', { status: 502, contentType: 'text/plain' });

    const res = await app.inject({ method: 'GET', url: '/api/vault/test-connection' });

    expect(res.json()).toMatchObject({ ok: false, error: 'HTTP 502: nope' });
  });

  it('returns 401 when the vault is locked', async () => {
    lockVault();

    const res = await app.inject({ method: 'GET', url: '/api/vault/test-connection' });

    expect(res.statusCode).toBe(401);
    expect(res.json()).toEqual({ error: 'vault-locked' });
    expect(fetchSpy).not.toHaveBeenCalled();
  });
});
