import {
  BadRequestException,
  ForbiddenException,
  Injectable,
  Logger,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { TripStatus } from '../common/constants/enums';
import { isOperationsRole } from '../common/constants/roles';
import {
  gpsLiveMinDistanceMeters,
  gpsLiveMinIntervalMs,
  isLiveTrackingWsEnabled,
} from '../config/runtime-config';
import { haversineDistanceMeters } from '../common/utils/geo.util';
import type { JwtUserPayload } from '../auth/jwt.types';
import { GpsService } from '../gps/gps.service';
import { TripsService } from '../trips/trips.service';
import { UsersService } from '../users/users.service';
import { resolveStoredRole } from '../users/user-api.mapper';
import { docId } from '../common/utils/mongo.util';
import type {
  LiveLocationPayload,
  TrackingLocationUpdateDto,
} from './dto/tracking-location.dto';

const EMIT_TRACKABLE_STATUSES: TripStatus[] = [
  TripStatus.STARTED,
  TripStatus.TRAVELLING,
  TripStatus.RETURN_TRIP,
];

type LastPoint = { latitude: number; longitude: number; timestampMs: number };

@Injectable()
export class LiveTrackingService {
  private readonly logger = new Logger(LiveTrackingService.name);
  private readonly lastAccepted = new Map<string, LastPoint>();

  constructor(
    private readonly config: ConfigService,
    private readonly trips: TripsService,
    private readonly users: UsersService,
    private readonly gps: GpsService,
  ) {}

  isWsEnabled(): boolean {
    return isLiveTrackingWsEnabled(this.config);
  }

  async authorizeJoin(actor: JwtUserPayload, tripId: string) {
    const trip = await this.trips.assertCanRead(actor, tripId);
    return trip;
  }

  async authorizeEmit(actor: JwtUserPayload, tripId: string) {
    const trip = await this.trips.findDocumentByTripId(tripId);
    const user = await this.users.findByUid(actor.uid);
    if (!user || docId(user) !== trip.userId) {
      throw new ForbiddenException('Only the trip owner can emit location updates');
    }
    if (!EMIT_TRACKABLE_STATUSES.includes(trip.status)) {
      throw new BadRequestException('Trip is not in an active tracking state');
    }
    return { trip, user };
  }

  shouldAcceptPoint(
    tripId: string,
    userId: string,
    latitude: number,
    longitude: number,
    timestamp: Date,
    force = false,
  ): { accept: boolean; reason?: string } {
    if (force) return { accept: true };

    const key = `${tripId}:${userId}`;
    const last = this.lastAccepted.get(key);
    if (!last) return { accept: true };

    const elapsed = timestamp.getTime() - last.timestampMs;
    const minInterval = gpsLiveMinIntervalMs(this.config);
    const minDistance = gpsLiveMinDistanceMeters(this.config);
    const moved = haversineDistanceMeters(
      { latitude: last.latitude, longitude: last.longitude },
      { latitude, longitude },
    );

    if (elapsed < minInterval && moved < minDistance) {
      return { accept: false, reason: 'rate_limited' };
    }
    return { accept: true };
  }

  rememberAcceptedPoint(
    tripId: string,
    userId: string,
    latitude: number,
    longitude: number,
    timestamp: Date,
  ) {
    this.lastAccepted.set(`${tripId}:${userId}`, {
      latitude,
      longitude,
      timestampMs: timestamp.getTime(),
    });
  }

  clearTripCache(tripId: string, userId: string) {
    this.lastAccepted.delete(`${tripId}:${userId}`);
  }

  clearTripCacheForTripId(tripId: string) {
    for (const key of this.lastAccepted.keys()) {
      if (key.startsWith(`${tripId}:`)) {
        this.lastAccepted.delete(key);
      }
    }
  }

  normalizePayload(
    dto: TrackingLocationUpdateDto,
    userId: string,
    tripId: string,
  ): LiveLocationPayload {
    const requestId = dto.requestId ?? tripId;
    const heading = dto.heading ?? dto.bearing;
    return {
      requestId,
      tripId,
      userId,
      latitude: dto.latitude,
      longitude: dto.longitude,
      timestamp: new Date(dto.timestamp).toISOString(),
      speed: dto.speed,
      heading,
      accuracy: dto.accuracy,
      address: dto.address,
      pointId: dto.pointId,
    };
  }

  async handleLocationUpdate(
    actor: JwtUserPayload,
    dto: TrackingLocationUpdateDto,
    options?: { force?: boolean },
  ): Promise<{
    ok: boolean;
    persisted: boolean;
    broadcast?: LiveLocationPayload;
    reason?: string;
  }> {
    if (!this.isWsEnabled()) {
      return { ok: false, persisted: false, reason: 'ws_disabled' };
    }

    const tripId = dto.tripId;
    const { trip, user } = await this.authorizeEmit(actor, tripId);
    const userId = docId(user);
    const timestamp = new Date(dto.timestamp);

    const rate = this.shouldAcceptPoint(
      tripId,
      userId,
      dto.latitude,
      dto.longitude,
      timestamp,
      options?.force,
    );
    if (!rate.accept) {
      return { ok: true, persisted: false, reason: rate.reason };
    }

    const clientPointId =
      dto.pointId ??
      `ws-${tripId}-${timestamp.getTime()}-${dto.latitude.toFixed(5)}-${dto.longitude.toFixed(5)}`;

    const result = await this.gps.ingestLivePoint(tripId, actor, {
      latitude: dto.latitude,
      longitude: dto.longitude,
      timestamp: dto.timestamp,
      accuracy: dto.accuracy,
      speed: dto.speed,
      bearing: dto.heading ?? dto.bearing,
      clientPointId,
    });

    if (!result.persisted) {
      return { ok: true, persisted: false, reason: 'duplicate' };
    }

    this.rememberAcceptedPoint(
      tripId,
      userId,
      result.latitude,
      result.longitude,
      result.timestamp,
    );

    const broadcast = this.normalizePayload(
      {
        ...dto,
        requestId: dto.requestId ?? trip.tripId,
        pointId: clientPointId,
      },
      userId,
      tripId,
    );

    return { ok: true, persisted: true, broadcast };
  }

  canSubscribeLiveMap(actor: JwtUserPayload): boolean {
    return isOperationsRole(resolveStoredRole(actor.role));
  }
}
