import { randomUUID } from "crypto";

import { Injectable, Inject } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Logger } from "nestjs-pino";

import { CacheService } from "@/infra/cache/cache.service.js";
import { JoseJwtService } from "../token/jose-jwt.service.js";

/**
 * Trusted device token payload stored in JWT
 * Extends JWTPayload to satisfy jose library constraints
 */
interface TrustedDeviceTokenPayload {
  /** JWT subject - user ID */
  sub: string;
  /** Unique device identifier */
  deviceId: string;
  /** Purpose claim to distinguish from other token types */
  purpose: "trusted_device";
  /** Index signature for JWTPayload compatibility */
  [key: string]: unknown;
}

/**
 * Device record stored in Redis
 */
interface TrustedDeviceRecord {
  /** User ID this device belongs to */
  userId: string;
  /** Unique device identifier */
  deviceId: string;
  /** When the device was trusted */
  createdAt: string;
  /** User agent string for audit/display */
  userAgent?: string;
  /** Whether this device trust is still valid */
  valid: boolean;
}

/**
 * Result of validating a trusted device token
 */
export interface TrustedDeviceValidationResult {
  /** Whether the device is trusted and valid */
  valid: boolean;
  /** User ID if valid */
  userId?: string;
  /** Device ID if valid */
  deviceId?: string;
  /** Reason for invalid result */
  reason?: "missing" | "expired" | "invalid_signature" | "revoked" | "user_mismatch";
}

/**
 * Trusted Device Service
 *
 * Manages trusted device tokens for "Remember this device" functionality.
 * When a user successfully completes OTP verification with "Remember this device"
 * checked, a trusted device token is issued as a signed JWT stored in a cookie.
 *
 * On subsequent logins, if a valid trusted device cookie exists for the same user,
 * OTP verification is skipped.
 *
 * Security:
 * - Tokens are signed JWTs with purpose claim
 * - Device records stored in Redis with TTL
 * - Devices can be revoked via logout or manual revocation
 * - User binding: token only valid for the user who created it
 */
@Injectable()
export class TrustedDeviceService {
  private readonly DEVICE_PREFIX = "trusted-device:";
  private readonly USER_DEVICES_PREFIX = "user-devices:";
  private readonly ttlSeconds: number;

  constructor(
    private readonly cache: CacheService,
    private readonly jwtService: JoseJwtService,
    private readonly config: ConfigService,
    @Inject(Logger) private readonly logger: Logger
  ) {
    // Default: 7 days (168 hours)
    const ttlHours = this.config.get<number>("TRUSTED_DEVICE_TTL_HOURS", 168);
    this.ttlSeconds = ttlHours * 60 * 60;
  }

  /**
   * Create a trusted device token and store the device record
   *
   * @param userId - The user ID to associate with the device
   * @param userAgent - Optional user agent for audit purposes
   * @returns The signed JWT token to set in the cookie
   */
  async createTrustedDevice(userId: string, userAgent?: string): Promise<string> {
    const deviceId = randomUUID();
    const createdAt = new Date().toISOString();

    // Store device record in Redis
    const deviceRecord: TrustedDeviceRecord = {
      userId,
      deviceId,
      createdAt,
      valid: true,
      ...(userAgent !== undefined && { userAgent }),
    };

    await this.cache.set(this.buildDeviceKey(deviceId), deviceRecord, this.ttlSeconds);

    // Add to user's device set for bulk operations
    await this.addToUserDeviceSet(userId, deviceId);

    // Create signed JWT token
    const token = await this.jwtService.sign(
      {
        sub: userId,
        deviceId,
        purpose: "trusted_device",
      } satisfies TrustedDeviceTokenPayload,
      this.ttlSeconds
    );

    this.logger.debug({ userId, deviceId }, "Trusted device created");

    return token;
  }

  /**
   * Validate a trusted device token
   *
   * @param token - The JWT token from the cookie
   * @param expectedUserId - The user ID that should match the token (from credentials)
   * @returns Validation result with user info if valid
   */
  async validateTrustedDevice(
    token: string | undefined,
    expectedUserId: string
  ): Promise<TrustedDeviceValidationResult> {
    if (!token) {
      return { valid: false, reason: "missing" };
    }

    // Verify JWT signature and decode payload
    let payload: TrustedDeviceTokenPayload;
    try {
      payload = await this.jwtService.verify<TrustedDeviceTokenPayload>(token);
    } catch (error) {
      const errorMessage = error instanceof Error ? error.message : "Unknown error";
      // Check if it's an expiry error
      if (errorMessage.includes("exp") || errorMessage.includes("expired")) {
        this.logger.debug({ error: errorMessage }, "Trusted device token expired");
        return { valid: false, reason: "expired" };
      }
      this.logger.debug({ error: errorMessage }, "Trusted device token invalid");
      return { valid: false, reason: "invalid_signature" };
    }

    // Validate purpose claim
    if (payload.purpose !== "trusted_device") {
      this.logger.warn({ purpose: payload.purpose }, "Invalid token purpose for trusted device");
      return { valid: false, reason: "invalid_signature" };
    }

    // Check user binding - token must be for the same user attempting login
    if (payload.sub !== expectedUserId) {
      this.logger.debug(
        { tokenUserId: payload.sub, expectedUserId },
        "Trusted device token user mismatch"
      );
      return { valid: false, reason: "user_mismatch" };
    }

    // Check if device is still valid in Redis (not revoked)
    const deviceRecord = await this.cache.get<TrustedDeviceRecord>(
      this.buildDeviceKey(payload.deviceId)
    );

    if (!deviceRecord) {
      this.logger.debug({ deviceId: payload.deviceId }, "Trusted device not found in cache");
      return { valid: false, reason: "revoked" };
    }

    if (!deviceRecord.valid) {
      this.logger.debug({ deviceId: payload.deviceId }, "Trusted device has been revoked");
      return { valid: false, reason: "revoked" };
    }

    // Verify user ID matches (defense in depth)
    if (deviceRecord.userId !== expectedUserId) {
      this.logger.warn(
        { recordUserId: deviceRecord.userId, expectedUserId },
        "Trusted device record user mismatch"
      );
      return { valid: false, reason: "user_mismatch" };
    }

    this.logger.debug(
      { userId: payload.sub, deviceId: payload.deviceId },
      "Trusted device validated successfully"
    );

    return {
      valid: true,
      userId: payload.sub,
      deviceId: payload.deviceId,
    };
  }

  /**
   * Revoke a specific trusted device
   *
   * @param deviceId - The device ID to revoke
   */
  async revokeDevice(deviceId: string): Promise<void> {
    const deviceRecord = await this.cache.get<TrustedDeviceRecord>(this.buildDeviceKey(deviceId));

    if (deviceRecord) {
      // Mark as invalid rather than delete (for audit trail)
      deviceRecord.valid = false;
      await this.cache.set(this.buildDeviceKey(deviceId), deviceRecord, this.ttlSeconds);

      // Remove from user's device set
      await this.removeFromUserDeviceSet(deviceRecord.userId, deviceId);

      this.logger.debug({ deviceId, userId: deviceRecord.userId }, "Trusted device revoked");
    }
  }

  /**
   * Revoke all trusted devices for a user
   * Called on logout or password change
   *
   * @param userId - The user ID whose devices should be revoked
   */
  async revokeAllUserDevices(userId: string): Promise<void> {
    const deviceIds = await this.getUserDeviceIds(userId);

    for (const deviceId of deviceIds) {
      // eslint-disable-next-line no-await-in-loop -- Sequential deletion ensures clean revocation
      await this.cache.del(this.buildDeviceKey(deviceId));
    }

    // Clear the user's device set
    await this.cache.del(this.buildUserDevicesKey(userId));

    this.logger.debug(
      { userId, deviceCount: deviceIds.length },
      "All trusted devices revoked for user"
    );
  }

  /**
   * Get the TTL in milliseconds (for cookie maxAge)
   */
  getTtlMs(): number {
    return this.ttlSeconds * 1000;
  }

  // ============================================================================
  // Private Helper Methods
  // ============================================================================

  /**
   * Build Redis key for device record
   */
  private buildDeviceKey(deviceId: string): string {
    return `${this.DEVICE_PREFIX}${deviceId}`;
  }

  /**
   * Build Redis key for user's device set
   */
  private buildUserDevicesKey(userId: string): string {
    return `${this.USER_DEVICES_PREFIX}${userId}`;
  }

  /**
   * Add device to user's device set (atomic operation using Redis SADD)
   */
  private async addToUserDeviceSet(userId: string, deviceId: string): Promise<void> {
    const key = this.buildUserDevicesKey(userId);
    await this.cache.setAddWithTtl(key, deviceId, this.ttlSeconds);
  }

  /**
   * Remove device from user's device set (atomic operation using Redis SREM)
   */
  private async removeFromUserDeviceSet(userId: string, deviceId: string): Promise<void> {
    const key = this.buildUserDevicesKey(userId);
    await this.cache.setRemove(key, deviceId);
  }

  /**
   * Get all device IDs for a user (using Redis SMEMBERS)
   */
  private async getUserDeviceIds(userId: string): Promise<string[]> {
    const key = this.buildUserDevicesKey(userId);
    return this.cache.setMembers(key);
  }
}
