import { describe, expect, it, vi } from 'vitest';
import { createServiceApp, registerOptionalJwtContext, startService } from '../index.js';

type JwtRequest = { jwtPayload?: unknown };

describe('createServiceApp', () => {
  it('returns a Fastify instance', async () => {
    const app = await createServiceApp({
      name: 'test-service',
      version: '0.0.1',
      logger: false,
    });
    expect(app).toBeDefined();
    expect(typeof app.listen).toBe('function');
    expect(typeof app.get).toBe('function');
    await app.close();
  });

  it('has a /health endpoint returning correct shape', async () => {
    const app = await createServiceApp({
      name: 'my-service',
      version: '1.2.3',
      description: 'A test service',
      logger: false,
    });

    const res = await app.inject({ method: 'GET', url: '/health' });
    expect(res.statusCode).toBe(200);

    const body = JSON.parse(res.payload);
    expect(body.status).toBe('ok');
    expect(body.service).toBe('my-service');
    expect(body.version).toBe('1.2.3');
    expect(body.description).toBe('A test service');
    expect(body.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/);
    expect(body.requestId).toBe(res.headers['x-request-id']);

    await app.close();
  });

  it('propagates x-request-id header', async () => {
    const app = await createServiceApp({
      name: 'test',
      version: '0.1.0',
      logger: false,
    });

    const customId = 'req-12345';
    const res = await app.inject({
      method: 'GET',
      url: '/health',
      headers: { 'x-request-id': customId },
    });

    expect(res.headers['x-request-id']).toBe(customId);
    const body = JSON.parse(res.payload);
    expect(body.requestId).toBe(customId);

    await app.close();
  });

  it('generates x-request-id when not provided', async () => {
    const app = await createServiceApp({
      name: 'test',
      version: '0.1.0',
      logger: false,
    });

    const res = await app.inject({ method: 'GET', url: '/health' });
    expect(res.headers['x-request-id']).toBeTruthy();
    // Should be UUID format
    expect(res.headers['x-request-id']).toMatch(
      /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
    );
    const body = JSON.parse(res.payload);
    expect(body.requestId).toBe(res.headers['x-request-id']);

    await app.close();
  });

  it('handles ServiceError with correct status code', async () => {
    const { NotFoundError } = await import('@bytelyst/errors');
    const app = await createServiceApp({
      name: 'test',
      version: '0.1.0',
      logger: false,
    });

    app.get('/fail', async () => {
      throw new NotFoundError('User not found');
    });

    const res = await app.inject({ method: 'GET', url: '/fail' });
    expect(res.statusCode).toBe(404);
    const body = JSON.parse(res.payload);
    expect(body.error).toBe('User not found');
    expect(body.requestId).toBe(res.headers['x-request-id']);

    await app.close();
  });

  it('handles a ServiceError from a DUPLICATED module copy (docker-prep tarball scenario)', async () => {
    // Simulate a second physical copy of @bytelyst/errors: a structurally
    // identical, brand-carrying class with a different prototype chain. The
    // handler must still map it to its statusCode instead of falling to 500.
    const BRAND = Symbol.for('@bytelyst/errors.ServiceError');
    class DuplicatedConflictError extends Error {
      statusCode = 409;
      details?: Record<string, unknown>;
      constructor(message: string) {
        super(message);
        this.name = 'ServiceError';
        Object.defineProperty(this, BRAND, { value: true, enumerable: false });
      }
    }
    const app = await createServiceApp({
      name: 'test',
      version: '0.1.0',
      logger: false,
    });

    app.get('/conflict', async () => {
      throw new DuplicatedConflictError('Version conflict');
    });

    const res = await app.inject({ method: 'GET', url: '/conflict' });
    expect(res.statusCode).toBe(409);
    expect(JSON.parse(res.payload).error).toBe('Version conflict');

    await app.close();
  });

  it('handles ServiceError with details', async () => {
    const { BadRequestError } = await import('@bytelyst/errors');
    const app = await createServiceApp({
      name: 'test',
      version: '0.1.0',
      logger: false,
    });

    app.get('/bad', async () => {
      throw new BadRequestError('Validation failed', { field: 'email' });
    });

    const res = await app.inject({ method: 'GET', url: '/bad' });
    expect(res.statusCode).toBe(400);
    const body = JSON.parse(res.payload);
    expect(body.error).toBe('Validation failed');
    expect(body.details).toEqual({ field: 'email' });
    expect(body.requestId).toBe(res.headers['x-request-id']);

    await app.close();
  });

  it('handles unknown errors as 500', async () => {
    const app = await createServiceApp({
      name: 'test',
      version: '0.1.0',
      logger: false,
    });

    app.get('/crash', async () => {
      throw new Error('unexpected');
    });

    const res = await app.inject({ method: 'GET', url: '/crash' });
    expect(res.statusCode).toBe(500);
    const body = JSON.parse(res.payload);
    expect(body.error).toBe('Internal server error');
    expect(body.requestId).toBe(res.headers['x-request-id']);

    await app.close();
  });

  it('omits description from health when not provided', async () => {
    const app = await createServiceApp({
      name: 'minimal',
      version: '0.1.0',
      logger: false,
    });

    const res = await app.inject({ method: 'GET', url: '/health' });
    const body = JSON.parse(res.payload);
    expect(body.description).toBeUndefined();

    await app.close();
  });

  it('supports readiness endpoint and returns not_ready until enabled', async () => {
    const app = await createServiceApp({
      name: 'ready-test',
      version: '1.0.0',
      readiness: true,
      logger: false,
    });

    const before = await app.inject({ method: 'GET', url: '/ready' });
    expect(before.statusCode).toBe(503);
    expect(JSON.parse(before.payload)).toMatchObject({
      status: 'not_ready',
      service: 'ready-test',
      version: '1.0.0',
      requestId: before.headers['x-request-id'],
    });

    app.setReadyState(true);

    const after = await app.inject({ method: 'GET', url: '/ready' });
    expect(after.statusCode).toBe(200);
    expect(JSON.parse(after.payload)).toMatchObject({
      status: 'ready',
      service: 'ready-test',
      version: '1.0.0',
      requestId: after.headers['x-request-id'],
    });

    await app.close();
  });

  it('supports custom readiness path and initial readiness state', async () => {
    const app = await createServiceApp({
      name: 'custom-ready',
      version: '1.0.0',
      readiness: { path: '/status/ready', initialReady: true },
      logger: false,
    });

    const res = await app.inject({ method: 'GET', url: '/status/ready' });
    expect(res.statusCode).toBe(200);
    expect(JSON.parse(res.payload)).toMatchObject({
      status: 'ready',
      service: 'custom-ready',
    });

    await app.close();
  });

  it('can omit requestId from error responses when configured', async () => {
    const { BadRequestError } = await import('@bytelyst/errors');
    const app = await createServiceApp({
      name: 'error-config',
      version: '1.0.0',
      logger: false,
      errorResponses: { includeRequestId: false },
    });

    app.get('/bad', async () => {
      throw new BadRequestError('No request id please');
    });

    const res = await app.inject({ method: 'GET', url: '/bad' });
    expect(res.statusCode).toBe(400);
    const body = JSON.parse(res.payload);
    expect(body.error).toBe('No request id please');
    expect(body.requestId).toBeUndefined();

    await app.close();
  });
});

describe('registerOptionalJwtContext', () => {
  it('attaches jwtPayload when bearer token verification succeeds', async () => {
    const app = await createServiceApp({
      name: 'jwt-context',
      version: '1.0.0',
      logger: false,
    });

    await registerOptionalJwtContext(app, {
      verifyToken: async token => ({ sub: `user:${token}`, role: 'admin' }),
    });

    app.get('/secure', async req => ({ jwtPayload: (req as typeof req & JwtRequest).jwtPayload }));

    const res = await app.inject({
      method: 'GET',
      url: '/secure',
      headers: { authorization: 'Bearer abc123' },
    });

    expect(res.statusCode).toBe(200);
    expect(JSON.parse(res.payload)).toEqual({
      jwtPayload: { sub: 'user:abc123', role: 'admin' },
    });

    await app.close();
  });

  it('swallows verification errors by default for optional auth', async () => {
    const app = await createServiceApp({
      name: 'jwt-optional',
      version: '1.0.0',
      logger: false,
    });

    await registerOptionalJwtContext(app, {
      verifyToken: async () => {
        throw new Error('invalid token');
      },
    });

    app.get('/secure', async req => ({
      jwtPayload: (req as typeof req & JwtRequest).jwtPayload ?? null,
    }));

    const res = await app.inject({
      method: 'GET',
      url: '/secure',
      headers: { authorization: 'Bearer broken' },
    });

    expect(res.statusCode).toBe(200);
    expect(JSON.parse(res.payload)).toEqual({ jwtPayload: null });

    await app.close();
  });

  it('invokes onError callback when token verification fails', async () => {
    const app = await createServiceApp({
      name: 'jwt-onerror',
      version: '1.0.0',
      logger: false,
    });

    const onError = vi.fn();

    await registerOptionalJwtContext(app, {
      verifyToken: async () => {
        throw new Error('bad token');
      },
      onError,
    });

    app.get('/secure', async req => ({
      jwtPayload: (req as typeof req & JwtRequest).jwtPayload ?? null,
    }));

    const res = await app.inject({
      method: 'GET',
      url: '/secure',
      headers: { authorization: 'Bearer broken' },
    });

    expect(res.statusCode).toBe(200);
    expect(onError).toHaveBeenCalledOnce();

    await app.close();
  });
});

describe('startService', () => {
  it('sets readiness state after successful listen', async () => {
    const app = await createServiceApp({
      name: 'start-ready',
      version: '1.0.0',
      readiness: true,
      logger: false,
    });

    const listenMock = vi.fn(async () => 'http://127.0.0.1:9999');
    const closeMock = vi.fn(async () => undefined);
    const infoMock = vi.fn();
    const errorMock = vi.fn();

    app.listen = listenMock as typeof app.listen;
    app.close = closeMock as unknown as typeof app.close;
    app.log.info = infoMock as typeof app.log.info;
    app.log.error = errorMock as typeof app.log.error;

    expect(app.isReadyState()).toBe(false);

    await startService(app, { port: 9999, registerSignalHandlers: false, exitOnFatal: false });

    expect(listenMock).toHaveBeenCalledWith({ port: 9999, host: '0.0.0.0' });
    expect(app.isReadyState()).toBe(true);
    expect(errorMock).not.toHaveBeenCalled();

    await app.close();
  });

  it('throws startup error instead of exiting when exitOnFatal is false', async () => {
    const app = await createServiceApp({
      name: 'start-fail',
      version: '1.0.0',
      logger: false,
    });

    const startupError = new Error('listen failed');
    const listenMock = vi.fn(async () => {
      throw startupError;
    });
    const errorMock = vi.fn();

    app.listen = listenMock as typeof app.listen;
    app.log.error = errorMock as typeof app.log.error;

    await expect(
      startService(app, { port: 9999, registerSignalHandlers: false, exitOnFatal: false })
    ).rejects.toThrow('listen failed');

    expect(errorMock).toHaveBeenCalledWith(startupError);

    await app.close();
  });
});
