import { ThrottlerStorageRedisService } from '@nest-lab/throttler-storage-redis';
import { Controller, type INestApplication, Post, UseGuards } from '@nestjs/common';
import { Test, type TestingModule } from '@nestjs/testing';
import { Throttle, ThrottlerModule } from '@nestjs/throttler';
import Redis from 'ioredis';
import request from 'supertest';
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { FeatureListingThrottlerGuard } from '../../shared/infrastructure/guards/feature-listing-throttler.guard';

/**
 * TEC-2930 — feature-listing throttle integration.
 *
 * Spins up a minimal Nest app whose only route mirrors the production
 * `POST /listings/:id/feature` decoration:
 *
 *   @UseGuards(FeatureListingThrottlerGuard)
 *   @Throttle({ default: { limit: 10, ttl: 60_000 } })
 *
 * Backed by the same Redis-backed throttler storage as production. We do
 * not boot the full ListingsModule — that would require Postgres, JWT, the
 * payments stack, etc. The contract under test is purely "does the
 * endpoint emit 429 after the configured limit, and are per-user and
 * per-IP buckets kept separate?".
 */

const REDIS_HOST = process.env['REDIS_HOST'] ?? 'localhost';
const REDIS_PORT = Number(process.env['REDIS_PORT'] ?? 16379);
const REDIS_PASSWORD = process.env['REDIS_PASSWORD'] ?? 'goodgo-redis-dev';

@Controller('listings')
class StubListingsController {
  @Post(':id/feature')
  @Throttle({ default: { limit: 10, ttl: 60_000 } })
  @UseGuards(FeatureListingThrottlerGuard)
  async featureListing(): Promise<{ ok: true }> {
    return { ok: true };
  }
}

async function flushTestKeys(redis: Redis): Promise<void> {
  const keys = await redis.keys('throttle-test:throttler:*');
  if (keys.length > 0) {
    await redis.del(...keys);
  }
}

describe('Feature listing throttler (TEC-2930)', () => {
  let app: INestApplication;
  let redis: Redis;
  let storage: ThrottlerStorageRedisService;
  let redisAvailable = false;

  beforeAll(async () => {
    redis = new Redis({
      host: REDIS_HOST,
      port: REDIS_PORT,
      password: REDIS_PASSWORD,
      lazyConnect: true,
      maxRetriesPerRequest: 1,
    });

    try {
      await redis.connect();
      await redis.ping();
      redisAvailable = true;
    } catch {
      redisAvailable = false;
    }

    if (!redisAvailable) {
      // Skip the suite gracefully — we'll guard each test below.
      return;
    }

    storage = new ThrottlerStorageRedisService({
      host: REDIS_HOST,
      port: REDIS_PORT,
      password: REDIS_PASSWORD,
      keyPrefix: 'throttle-test:throttler:',
      maxRetriesPerRequest: 1,
    });

    const moduleRef: TestingModule = await Test.createTestingModule({
      imports: [
        ThrottlerModule.forRoot({
          throttlers: [{ name: 'default', ttl: 60_000, limit: 10 }],
          storage,
        }),
      ],
      controllers: [StubListingsController],
    }).compile();

    app = moduleRef.createNestApplication();
    await app.init();
  });

  afterAll(async () => {
    if (app) {
      await app.close();
    }
    if (storage?.redis && 'quit' in storage.redis) {
      await (storage.redis as Redis).quit();
    }
    if (redis.status !== 'end') {
      await redis.quit();
    }
  });

  beforeEach(async () => {
    if (redisAvailable) {
      await flushTestKeys(redis);
    }
  });

  it('allows the first 10 requests then returns 429', async () => {
    if (!redisAvailable) {
      console.warn('Skipping: Redis not reachable at', `${REDIS_HOST}:${REDIS_PORT}`);
      return;
    }

    const target = '/listings/abc-123/feature';

    for (let i = 0; i < 10; i += 1) {
      const res = await request(app.getHttpServer())
        .post(target)
        .set('X-Forwarded-For', '203.0.113.10')
        .send({});
      expect(res.status, `request ${i + 1} should pass`).toBe(201);
    }

    const blocked = await request(app.getHttpServer())
      .post(target)
      .set('X-Forwarded-For', '203.0.113.10')
      .send({});
    expect(blocked.status).toBe(429);
    // @nestjs/throttler exposes Retry-After when the limit is exceeded.
    expect(blocked.headers['retry-after']).toBeDefined();
  });

  it('keeps per-IP buckets independent', async () => {
    if (!redisAvailable) return;

    const target = '/listings/abc-123/feature';

    for (let i = 0; i < 10; i += 1) {
      await request(app.getHttpServer())
        .post(target)
        .set('X-Forwarded-For', '203.0.113.20')
        .send({})
        .expect(201);
    }

    // The first IP is now exhausted...
    await request(app.getHttpServer())
      .post(target)
      .set('X-Forwarded-For', '203.0.113.20')
      .send({})
      .expect(429);

    // ...but a different IP still has its full quota.
    await request(app.getHttpServer())
      .post(target)
      .set('X-Forwarded-For', '198.51.100.42')
      .send({})
      .expect(201);
  });

  it('separates buckets by tracker key (user vs ip)', async () => {
    if (!redisAvailable) return;

    // Verify the guard's key derivation directly. Hitting the HTTP layer
    // would require wiring JwtAuthGuard, which is out of scope for this
    // unit-of-behaviour. We test the tracker contract instead.
    const guard = new FeatureListingThrottlerGuard(
      { throttlers: [{ name: 'default', ttl: 60_000, limit: 10 }] },
      storage,
      // @ts-expect-error reflector is not used by getTracker
      { getAllAndOverride: () => undefined },
    );

    const trackerForUser = await (guard as unknown as {
      getTracker: (req: unknown) => Promise<string>;
    }).getTracker({
      headers: {},
      ip: '127.0.0.1',
      user: { sub: 'user-123', role: 'USER' },
    });
    expect(trackerForUser).toBe('user:user-123');

    const trackerForAnon = await (guard as unknown as {
      getTracker: (req: unknown) => Promise<string>;
    }).getTracker({
      headers: { 'x-forwarded-for': '203.0.113.55, 10.0.0.1' },
      ip: '127.0.0.1',
      user: null,
    });
    expect(trackerForAnon).toBe('ip:203.0.113.55');
  });
});
