import {
  type ArgumentsHost,
  BadRequestException,
  ForbiddenException,
  HttpException,
  HttpStatus,
  InternalServerErrorException,
  NotFoundException,
  UnauthorizedException,
} from '@nestjs/common';
import { trace } from '@opentelemetry/api';
import type { Response } from 'express';
import type { Logger } from 'nestjs-pino';
import { StructuredErrorFilter, errorResponse } from './structured-error.filter';

function makeRes(): Response & { status: jest.Mock; json: jest.Mock } {
  const res = {
    status: jest.fn(),
    json: jest.fn(),
  };
  res.status.mockReturnValue(res);
  res.json.mockReturnValue(res);
  return res as unknown as Response & { status: jest.Mock; json: jest.Mock };
}

function makeHost(res: Response): ArgumentsHost {
  return {
    switchToHttp: () => ({
      getRequest: () => ({}),
      getResponse: () => res,
      getNext: () => undefined,
    }),
  } as unknown as ArgumentsHost;
}

function makeLogger() {
  return {
    log: jest.fn(),
    warn: jest.fn(),
    error: jest.fn(),
    debug: jest.fn(),
  } as unknown as Logger & { warn: jest.Mock; error: jest.Mock };
}

describe('StructuredErrorFilter', () => {
  describe('classification by HttpException type', () => {
    it.each([
      [new BadRequestException('bad input'), HttpStatus.BAD_REQUEST, 'bad_request'],
      [new UnauthorizedException(), HttpStatus.UNAUTHORIZED, 'unauthenticated'],
      [new ForbiddenException(), HttpStatus.FORBIDDEN, 'forbidden'],
      [new NotFoundException(), HttpStatus.NOT_FOUND, 'not_found'],
    ])('maps %p to status %i with code %s', (exception, expectedStatus, expectedCode) => {
      const res = makeRes();
      const filter = new StructuredErrorFilter(makeLogger());
      filter.catch(exception, makeHost(res));
      expect(res.status).toHaveBeenCalledWith(expectedStatus);
      const body = res.json.mock.calls[0]?.[0] as { error: { code: string } };
      expect(body.error.code).toBe(expectedCode);
    });
  });

  describe('5xx envelope', () => {
    it('does NOT leak the exception message on a raw 500', () => {
      const res = makeRes();
      const filter = new StructuredErrorFilter(makeLogger());
      filter.catch(new Error('Prisma broke: DATABASE_URL=postgres://secret'), makeHost(res));
      expect(res.status).toHaveBeenCalledWith(500);
      const body = res.json.mock.calls[0]?.[0] as { error: { code: string; message: string } };
      expect(body.error.code).toBe('internal');
      expect(body.error.message).toBe('Internal server error');
      expect(body.error.message).not.toContain('secret');
      expect(body.error.message).not.toContain('Prisma');
    });

    it('logs the full exception at error level (with stack via err field)', () => {
      const logger = makeLogger();
      const filter = new StructuredErrorFilter(logger);
      const exception = new Error('original error');
      filter.catch(exception, makeHost(makeRes()));
      expect(logger.error).toHaveBeenCalledWith(
        expect.objectContaining({ event: 'http.error', status: 500, err: exception }),
        'StructuredError',
      );
    });

    it('respects an InternalServerErrorException message but still uses the safe code', () => {
      // Even if a developer wraps a sensitive error in
      // InternalServerErrorException, the message we send out
      // comes from the HttpException's getResponse() which is
      // explicit user-facing text. The filter trusts it (caller's
      // responsibility — not the filter's).
      const res = makeRes();
      const filter = new StructuredErrorFilter(makeLogger());
      filter.catch(
        new InternalServerErrorException('Database temporarily unavailable'),
        makeHost(res),
      );
      const body = res.json.mock.calls[0]?.[0] as { error: { code: string; message: string } };
      expect(body.error.message).toBe('Database temporarily unavailable');
      // Falls through to status-derived code since
      // InternalServerErrorException doesn't define a custom one.
      expect(body.error.code).toBe('http_error');
    });
  });

  describe('custom code via HttpException response object', () => {
    it('picks up `code` from the response object', () => {
      const res = makeRes();
      const filter = new StructuredErrorFilter(makeLogger());
      filter.catch(
        new HttpException({ code: 'csrf', message: 'CSRF token missing' }, HttpStatus.FORBIDDEN),
        makeHost(res),
      );
      const body = res.json.mock.calls[0]?.[0] as { error: { code: string; message: string } };
      expect(body.error.code).toBe('csrf');
      expect(body.error.message).toBe('CSRF token missing');
    });

    it('joins array messages with semicolons (Nest ValidationPipe shape)', () => {
      const res = makeRes();
      const filter = new StructuredErrorFilter(makeLogger());
      filter.catch(
        new HttpException(
          { message: ['name is required', 'email is invalid'] },
          HttpStatus.BAD_REQUEST,
        ),
        makeHost(res),
      );
      const body = res.json.mock.calls[0]?.[0] as { error: { message: string } };
      expect(body.error.message).toBe('name is required; email is invalid');
    });
  });

  describe('trace id', () => {
    it('captures the active OTel trace id in the envelope', () => {
      const fakeSpan = {
        spanContext: () => ({ traceId: 'abc123', spanId: 'def', traceFlags: 0 }),
      };
      const spy = jest.spyOn(trace, 'getActiveSpan').mockReturnValue(fakeSpan as never);
      try {
        const res = makeRes();
        const filter = new StructuredErrorFilter(makeLogger());
        filter.catch(new BadRequestException('x'), makeHost(res));
        const body = res.json.mock.calls[0]?.[0] as { error: { traceId: string | null } };
        expect(body.error.traceId).toBe('abc123');
      } finally {
        spy.mockRestore();
      }
    });

    it('writes traceId: null when no span is active', () => {
      const spy = jest.spyOn(trace, 'getActiveSpan').mockReturnValue(undefined);
      try {
        const res = makeRes();
        const filter = new StructuredErrorFilter(makeLogger());
        filter.catch(new BadRequestException('x'), makeHost(res));
        const body = res.json.mock.calls[0]?.[0] as { error: { traceId: string | null } };
        expect(body.error.traceId).toBeNull();
      } finally {
        spy.mockRestore();
      }
    });
  });
});

describe('errorResponse() helper (for raw-middleware callers)', () => {
  it('produces the same envelope shape as the filter', () => {
    const body = errorResponse('csrf', 'CSRF token missing');
    expect(body).toEqual({
      error: {
        code: 'csrf',
        message: 'CSRF token missing',
        traceId: null,
      },
    });
  });

  it('picks up the active OTel trace id', () => {
    const spy = jest.spyOn(trace, 'getActiveSpan').mockReturnValue({
      spanContext: () => ({ traceId: 'tid', spanId: 's', traceFlags: 0 }),
    } as never);
    try {
      expect(errorResponse('csrf', 'm').error.traceId).toBe('tid');
    } finally {
      spy.mockRestore();
    }
  });
});
