import { randomBytes } from 'crypto';
import {
  BadRequestException,
  ForbiddenException,
  Injectable,
  NotFoundException,
  OnModuleDestroy,
  UnauthorizedException,
} from '@nestjs/common';
import { PkiService } from '../certificates/pki.service';
import { PrismaService } from '../prisma/prisma.service';

const CHALLENGE_TTL_MS = Number(process.env.MQTT_CHALLENGE_TTL_SECONDS || 120) * 1000;
const SESSION_CERT_DAYS = Number(process.env.MQTT_SESSION_CERT_HOURS || 24) / 24;
const SWEEP_INTERVAL_MS = 60_000;

interface PendingChallenge {
  serial: string;
  expiresAt: number;
}

// Lets a BLE-only locator prove possession of its permanent private key
// (issued via PkiService.issueDeviceCert, never leaves the device) through a
// phone relay, without ever exporting that permanent key. The phone gets a
// short-lived session cert instead: mint-on-demand, never persisted, since
// Mosquitto validates any CA-signed cert at connect time regardless of
// whether this service remembers issuing it.
@Injectable()
export class DeviceMqttAuthService implements OnModuleDestroy {
  private readonly pending = new Map<string, PendingChallenge>();
  private readonly sweep = setInterval(() => this.sweepExpired(), SWEEP_INTERVAL_MS);

  constructor(
    private readonly prisma: PrismaService,
    private readonly pki: PkiService,
  ) {}

  async issueChallenge(serial: string) {
    const device = await this.prisma.device.findUnique({ where: { serialNumber: serial } });
    if (!device) {
      throw new NotFoundException(`No device with serial "${serial}"`);
    }
    const nonce = randomBytes(32).toString('hex');
    const expiresAt = Date.now() + CHALLENGE_TTL_MS;
    this.pending.set(nonce, { serial, expiresAt });
    return { nonce, payload: this.payloadFor(serial, nonce), expiresAt: new Date(expiresAt) };
  }

  async redeemChallenge(serial: string, nonce: string, signature: string) {
    const entry = this.pending.get(nonce);
    // Single-use: consumed here regardless of outcome, so a nonce can never
    // be replayed even after a failed attempt.
    this.pending.delete(nonce);
    if (!entry || entry.expiresAt < Date.now() || entry.serial !== serial) {
      throw new BadRequestException('Challenge is invalid, expired, or already used — request a new one');
    }

    const device = await this.prisma.device.findUnique({ where: { serialNumber: serial } });
    if (!device) {
      throw new NotFoundException(`No device with serial "${serial}"`);
    }
    if (!device.isActive) {
      throw new ForbiddenException(`Device "${serial}" is disabled`);
    }
    const cert = await this.prisma.deviceCertificate.findUnique({ where: { deviceId: device.id } });
    if (!cert) {
      throw new NotFoundException(`Device "${serial}" has no permanent certificate issued yet`);
    }

    const payload = this.payloadFor(serial, nonce);
    const verified = await this.pki.verifySignature(cert.certificatePem, payload, signature);
    if (!verified) {
      throw new UnauthorizedException('Signature does not match this device\'s certificate');
    }

    const [issued, caCertPem] = await Promise.all([
      this.pki.issueDeviceCert(serial, SESSION_CERT_DAYS),
      this.pki.caCertPem(),
    ]);
    return {
      certificatePem: issued.certificatePem,
      privateKeyPem: issued.privateKeyPem,
      caCertPem,
      expiresAt: issued.expiresAt,
    };
  }

  private payloadFor(serial: string, nonce: string): string {
    return `ulhub-mqtt-auth-v1:${serial}:${nonce}`;
  }

  private sweepExpired(): void {
    const now = Date.now();
    for (const [nonce, entry] of this.pending) {
      if (entry.expiresAt < now) {
        this.pending.delete(nonce);
      }
    }
  }

  onModuleDestroy(): void {
    clearInterval(this.sweep);
  }
}
