import { Injectable, HttpStatus, BadRequestException, InternalServerErrorException } from '@nestjs/common';
import { Kysely, sql } from 'kysely';
import { DB } from '../../database/database.type';
import { Logger } from 'pino-nestjs';
import { DeviceTypePayload } from './device.dto';
import { EXPO_PUSH_TOKEN_REGEX } from '../push/push.service';

@Injectable()
export class DeviceService {
  constructor(
    private readonly db: Kysely<DB>,
    private readonly logger: Logger,
  ) {}

  private readonly MAX_DEVICE_ID_LENGTH = 128;

  private isValidDeviceId(value: string): boolean {
    const trimmed = String(value || '').trim();
    return trimmed.length > 0 && trimmed.length <= this.MAX_DEVICE_ID_LENGTH;
  }

  private normalizeNullableToken(token?: string | null): string | null | undefined {
    if (token === undefined) return undefined;
    if (token === null) return null;
    const trimmed = String(token).trim();
    if (!trimmed) return null;
    if (!EXPO_PUSH_TOKEN_REGEX.test(trimmed)) {
      throw new BadRequestException('Invalid Expo push token format');
    }
    return trimmed;
  }

  private normalizeLocation(location?: any): any {
    if (!location) return undefined;
    const latitude = Number(location.latitude);
    const longitude = Number(location.longitude);
    const accuracy = location.accuracy_m == null ? null : Number(location.accuracy_m);
    if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
      throw new BadRequestException('Location latitude and longitude must be valid numbers');
    }
    if (latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180) {
      throw new BadRequestException('Location latitude/longitude out of range');
    }
    if (accuracy != null && !Number.isFinite(accuracy)) {
      throw new BadRequestException('Location accuracy_m must be a valid number');
    }
    return {
      latitude,
      longitude,
      accuracy_m: accuracy,
    };
  }

  async getDeviceById(device_id: string) {
    const device = await this.db
      .selectFrom('devices')
      .select(['device_id', 'platform', 'app_version'])
      .where('device_id', '=', device_id)
      .where('deleted_at', 'is', null)
      .executeTakeFirst();

    return device;
  }

  private async getDeviceByPlatformAndDeviceId(platform: string, device_id: string) {
    const device = await this.db
      .selectFrom('devices')
      .select(['device_id', 'platform', 'app_version'])
      .where('platform', '=', platform)
      .where('device_id', '=', device_id)
      .where('deleted_at', 'is', null)
      .executeTakeFirst();

    return device;
  }

  async upsertDevice(input: DeviceTypePayload) {
    this.logger.log('upsertDevice: Initiating upsertDevice', input);

    const rawDeviceId = String(input.device_id || '').trim();
    if (!rawDeviceId || !this.isValidDeviceId(rawDeviceId)) {
      this.logger.warn('upsertDevice: Validation failed: device_id is missing or too long', { rawDeviceId });
      throw new BadRequestException('device_id is required and must be 128 characters or fewer');
    }

    const platform = (input.platform || '').trim().toLowerCase();
    if (platform !== 'ios' && platform !== 'android') {
      this.logger.warn('upsertDevice: Validation failed: invalid platform', { platform });
      throw new BadRequestException('platform must be ios or android');
    }

    const existing = await this.getDeviceByPlatformAndDeviceId(platform, rawDeviceId);
    const tokenProvided = 'expo_push_token' in input;
    const locationProvided = 'location' in input && !!input.location;
    const userProvided = 'user_id' in input;
    const ipGeoProvided = 'ip_geo' in input;
    const ipProvided = 'ip' in input && !!input.ip;

    let normalizedToken: string | null | undefined;
    let normalizedLocation: any;
    try {
      normalizedToken = this.normalizeNullableToken(input.expo_push_token);
      normalizedLocation = this.normalizeLocation(input.location);
    } catch (error: any) {
      this.logger.warn('upsertDevice: Validation failed: Invalid device payload.', error);
      throw error;
    }

    const appVersion = String(input.app_version || existing?.app_version || '').trim();

    if (!appVersion) {
      this.logger.warn('upsertDevice: Validation failed: missing app_version', { rawDeviceId });
      throw new BadRequestException('app_version is required');
    }

    const buildNumber = input.build_number == null ? null : String(input.build_number).trim() || null;
    const ipGeo = input.ip_geo;

    const userIdValue =
      input.user_id === undefined ? null : input.user_id === null ? null : String(input.user_id);

    try {
      if (normalizedToken) {
        const clearedResult = await this.db
          .updateTable('devices')
          .set({ expo_push_token: null })
          .where('expo_push_token', '=', normalizedToken)
          .where('device_id', '!=', rawDeviceId)
          .returning('device_id')
          .execute();

        if (clearedResult.length > 0) {
          const deviceIds = clearedResult.map((r) => r.device_id).join(', ');
          this.logger.warn('upsertDevice', `Cleared duplicate token for devices: ${deviceIds}`);
        }
      }

      const userId = userIdValue;
      const pushToken = tokenProvided ? normalizedToken : null;
      const latitude = locationProvided ? normalizedLocation.latitude : null;
      const longitude = locationProvided ? normalizedLocation.longitude : null;
      const accuracy = locationProvided ? (normalizedLocation.accuracy_m ?? null) : null;
      const hasLocation = locationProvided;
      const updateUser = userProvided;
      const updateToken = tokenProvided;

      const result = await this.db
        .insertInto('devices')
        .values({
          device_id: rawDeviceId,
          user_id: userId,
          expo_push_token: pushToken,
          platform: platform,
          app_version: appVersion,
          build_number: buildNumber,
          latitude: latitude,
          longitude: longitude,
          location_accuracy_m: accuracy,
          location_updated_at: sql`CASE WHEN ${hasLocation} THEN NOW() ELSE NULL END`,
          ip_country: ipGeo?.country ?? null,
          ip_country_code: ipGeo?.countryCode ?? null,
          ip_city: ipGeo?.city ?? null,
          ip_latitude: ipGeo?.latitude ?? null,
          ip_longitude: ipGeo?.longitude ?? null,
          last_ip: input.ip ?? null,
          last_active_at: sql`NOW()`,
          created_at: sql`NOW()`,
          updated_at: sql`NOW()`,
        })
        .onConflict((oc) =>
          oc
            .columns(['platform', 'device_id'])
            .where('devices.deleted_at', 'is', null)
            .doUpdateSet({
              user_id: sql`CASE WHEN ${updateUser} THEN EXCLUDED.user_id ELSE devices.user_id END`,
              expo_push_token: sql`CASE WHEN ${updateToken} THEN EXCLUDED.expo_push_token ELSE devices.expo_push_token END`,
              platform: sql`EXCLUDED.platform`,
              app_version: sql`COALESCE(EXCLUDED.app_version, devices.app_version)`,
              build_number: sql`COALESCE(EXCLUDED.build_number, devices.build_number)`,
              latitude: sql`CASE WHEN ${hasLocation} THEN EXCLUDED.latitude ELSE devices.latitude END`,
              longitude: sql`CASE WHEN ${hasLocation} THEN EXCLUDED.longitude ELSE devices.longitude END`,
              location_accuracy_m: sql`CASE WHEN ${hasLocation} THEN EXCLUDED.location_accuracy_m ELSE devices.location_accuracy_m END`,
              location_updated_at: sql`CASE WHEN ${hasLocation} THEN NOW() ELSE devices.location_updated_at END`,
              ip_country: sql`CASE WHEN ${ipGeoProvided} THEN EXCLUDED.ip_country ELSE devices.ip_country END`,
              ip_country_code: sql`CASE WHEN ${ipGeoProvided} THEN EXCLUDED.ip_country_code ELSE devices.ip_country_code END`,
              ip_city: sql`CASE WHEN ${ipGeoProvided} THEN EXCLUDED.ip_city ELSE devices.ip_city END`,
              ip_latitude: sql`CASE WHEN ${ipGeoProvided} THEN EXCLUDED.ip_latitude ELSE devices.ip_latitude END`,
              ip_longitude: sql`CASE WHEN ${ipGeoProvided} THEN EXCLUDED.ip_longitude ELSE devices.ip_longitude END`,
              last_ip: sql`CASE WHEN ${ipProvided} THEN EXCLUDED.last_ip ELSE devices.last_ip END`,
              last_active_at: sql`NOW()`,
              updated_at: sql`NOW()`,
            }),
        )
        .returningAll()
        .executeTakeFirstOrThrow();

      this.logger.log('upsertDevice: Successfully upserted device', { device_id: result.device_id });

      return {
        status: true,
        statusCode: existing ? HttpStatus.OK : HttpStatus.CREATED,
        data: result,
      };
    } catch (error) {
      this.logger.error('Failed to upsert device', error);
      throw new InternalServerErrorException('Internal server error during device registration');
    }
  }

  async detachDeviceUser(device_id: string) {
    const rawDeviceId = String(device_id || '').trim();
    if (!rawDeviceId || !this.isValidDeviceId(rawDeviceId)) {
      throw new BadRequestException('device_id is required and must be 128 characters or fewer');
    }

    try {
      const result = await this.db
        .updateTable('devices')
        .set({
          user_id: null,
          last_active_at: new Date(),
          updated_at: new Date(),
        })
        .where('device_id', '=', rawDeviceId)
        .where('deleted_at', 'is', null)
        .returning('device_id')
        .executeTakeFirst();

      if (!result) {
        return {
          status: true,
          statusCode: HttpStatus.OK,
          data: { message: 'Device already detached' },
        };
      }

      return {
        status: true,
        statusCode: HttpStatus.OK,
        data: { message: 'Device detached' },
      };
    } catch (error) {
      this.logger.error('Failed to detach device user', error);
      return {
        status: false,
        statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
        message: 'Internal server error during device logout',
      };
    }
  }

  async getUserPushTokens(user_id: string): Promise<string[]> {
    const result = await this.db
      .selectFrom('devices')
      .select('expo_push_token')
      .distinct()
      .where('user_id', '=', user_id)
      .where('deleted_at', 'is', null)
      .where('expo_push_token', 'is not', null)
      .execute();

    const tokens = result
      .map((row) => String(row.expo_push_token || '').trim())
      .filter((token) => EXPO_PUSH_TOKEN_REGEX.test(token));

    if (tokens.length > 0) return tokens;

    // Fallback for legacy user table column
    const legacy = await this.db
      .selectFrom('users')
      .select('push_token')
      .where('id', '=', user_id)
      .where('deleted_at', 'is', null)
      .executeTakeFirst();

    if (!legacy || !legacy.push_token) return [];
    const legacyToken = legacy.push_token.trim();
    if (!EXPO_PUSH_TOKEN_REGEX.test(legacyToken)) return [];
    return [legacyToken];
  }

  async getDevicePushTokens(device_id: string): Promise<string[]> {
    const result = await this.db
      .selectFrom('devices')
      .select('expo_push_token')
      .where('device_id', '=', device_id)
      .where('deleted_at', 'is', null)
      .where('expo_push_token', 'is not', null)
      .executeTakeFirst();

    if (!result || !result.expo_push_token) return [];
    const token = result.expo_push_token.trim();
    return EXPO_PUSH_TOKEN_REGEX.test(token) ? [token] : [];
  }

  async getAnonymousPushTokens(limit = 500): Promise<string[]> {
    const safeLimit = Math.max(1, Math.min(limit, 5000));
    const result = await this.db
      .selectFrom('devices')
      .select('expo_push_token')
      .distinct()
      .where('user_id', 'is', null)
      .where('deleted_at', 'is', null)
      .where('expo_push_token', 'is not', null)
      .limit(safeLimit)
      .execute();

    return result
      .map((row) => String(row.expo_push_token || '').trim())
      .filter((token) => EXPO_PUSH_TOKEN_REGEX.test(token));
  }

  async clearPushTokenByValue(pushToken: string): Promise<void> {
    const normalized = pushToken.trim();
    if (!normalized) return;

    await this.db
      .updateTable('devices')
      .set({
        expo_push_token: null,
        updated_at: new Date(),
      })
      .where('expo_push_token', '=', normalized)
      .where('deleted_at', 'is', null)
      .execute();
  }
}
