import {
  Injectable,
  ForbiddenException,
  NotFoundException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { InjectModel } from '@nestjs/mongoose';
import { Model, Types } from 'mongoose';
import { EmployeePresence, TripStatus } from '../common/constants/enums';
import { RedisService } from '../redis/redis.service';
import { TripsService } from '../trips/trips.service';
import { GeofencingService } from '../geofencing/geofencing.service';
import {
  gpsDuplicateThresholdMeters,
  gpsStopDetectionMinutes,
} from '../config/runtime-config';
import {
  calculateRouteDistance,
  deduplicatePoints,
  smoothGpsPoint,
  type LatLng,
} from '../common/utils/geo.util';
import { APP_EVENTS } from '../common/constants/events';
import type { JwtUserPayload } from '../auth/jwt.types';
import { UsersService } from '../users/users.service';
import { GpsPoint, GpsPointDocument } from './schemas/gps-point.schema';
import { GpsStop, GpsStopDocument } from './schemas/gps-stop.schema';
import { docId } from '../common/utils/mongo.util';
import { GpsBatchDto } from './dto/gps.dto';

@Injectable()
export class GpsService {
  constructor(
    @InjectModel(GpsPoint.name)
    private readonly gpsModel: Model<GpsPointDocument>,
    @InjectModel(GpsStop.name)
    private readonly stopModel: Model<GpsStopDocument>,
    private readonly redis: RedisService,
    private readonly trips: TripsService,
    private readonly users: UsersService,
    private readonly geofence: GeofencingService,
    private readonly config: ConfigService,
    private readonly events: EventEmitter2,
  ) {}

  async ingestBatch(tripId: string, actor: JwtUserPayload, dto: GpsBatchDto) {
    const trip = await this.trips.findDocumentByTripId(tripId);
    const user = await this.users.findByUid(actor.uid);
    if (!user) throw new NotFoundException('User not found');

    const threshold = gpsDuplicateThresholdMeters(this.config);
    const rawPoints = dto.points.map((p) => ({
      latitude: p.latitude,
      longitude: p.longitude,
      timestamp: new Date(p.timestamp),
      accuracy: p.accuracy,
      speed: p.speed,
      bearing: p.bearing,
      batteryLevel: p.batteryLevel,
      clientPointId: p.clientPointId,
      legId: p.legId,
      sessionId: p.sessionId,
      source: p.source,
    }));

    type GpsInput = (typeof rawPoints)[number];
    const deduped = deduplicatePoints(rawPoints, threshold) as GpsInput[];
    const tripDbId = docId(trip);
    const userId = docId(user);

    const bulkOps = deduped.map((p, idx) => {
      const isLast = idx === deduped.length - 1;
      const recent = deduped.slice(Math.max(0, idx - 4), idx + 1).map((x) => ({
        latitude: x.latitude,
        longitude: x.longitude,
      }));
      const smoothed = isLast ? smoothGpsPoint(recent) : null;
      const coords = smoothed ?? p;
      const clientPointId =
        p.clientPointId ?? `auto-${p.timestamp.getTime()}`;

      return {
        updateOne: {
          filter: { tripId: tripDbId, clientPointId },
          update: {
            $set: {
              tripId: tripDbId,
              userId,
              clientPointId,
              latitude: coords.latitude,
              longitude: coords.longitude,
              accuracy: p.accuracy,
              speed: p.speed,
              bearing: p.bearing,
              batteryLevel: p.batteryLevel,
              timestamp: p.timestamp,
              isSmoothed: !!smoothed,
              legId: p.legId,
              sessionId: p.sessionId,
              source: p.source,
            },
          },
          upsert: true,
        },
      };
    });

    if (bulkOps.length) {
      await this.gpsModel.bulkWrite(bulkOps, { ordered: false });
    }

    const last = deduped[deduped.length - 1];
    if (last) {
      await this.redis.setLiveLocation(userId, {
        tripId,
        latitude: last.latitude,
        longitude: last.longitude,
        speed: last.speed,
        bearing: last.bearing,
        batteryLevel: last.batteryLevel,
        timestamp: last.timestamp.toISOString(),
        accuracy: last.accuracy,
      });

      user.presence = EmployeePresence.ON_TRIP;
      user.lastSeenAt = new Date();
      await user.save();

      if (
        trip.destinationLat != null &&
        trip.destinationLng != null &&
        (trip.status === TripStatus.TRAVELLING ||
          trip.status === TripStatus.STARTED)
      ) {
        const autoArrive = this.geofence.shouldAutoArrive(
          { latitude: last.latitude, longitude: last.longitude },
          { latitude: trip.destinationLat, longitude: trip.destinationLng },
          last.speed,
        );
        if (autoArrive) {
          trip.status = TripStatus.ARRIVED;
          trip.arrivedAt = new Date();
          await trip.save();
        }
      }
    }

    await this.recalculateTripMetrics(tripId);
    await this.detectStops(tripDbId);

    this.events.emit(APP_EVENTS.GPS_BATCH_PERSISTED, {
      tripId,
      userId,
      count: bulkOps.length,
      lastPoint: last,
    });

    return { inserted: bulkOps.length, tripId };
  }

  async ingestLivePoint(
    tripId: string,
    actor: JwtUserPayload,
    point: {
      latitude: number;
      longitude: number;
      timestamp: string;
      accuracy?: number;
      speed?: number;
      bearing?: number;
      clientPointId: string;
    },
  ): Promise<{
    persisted: boolean;
    latitude: number;
    longitude: number;
    timestamp: Date;
    clientPointId: string;
  }> {
    const trip = await this.trips.findDocumentByTripId(tripId);
    const user = await this.users.findByUid(actor.uid);
    if (!user) throw new NotFoundException('User not found');
    if (docId(user) !== trip.userId) {
      throw new ForbiddenException('Only the trip owner can record GPS points');
    }

    const tripDbId = docId(trip);
    const userId = docId(user);
    const ts = new Date(point.timestamp);

    const recent = await this.gpsModel
      .find({ tripId: tripDbId })
      .sort({ timestamp: -1 })
      .limit(4)
      .select('latitude longitude')
      .lean()
      .exec();
    const recentCoords = [
      ...recent.reverse().map((p) => ({
        latitude: p.latitude,
        longitude: p.longitude,
      })),
      { latitude: point.latitude, longitude: point.longitude },
    ];
    const smoothed = smoothGpsPoint(recentCoords);
    const coords = smoothed ?? {
      latitude: point.latitude,
      longitude: point.longitude,
    };

    try {
      await this.gpsModel.updateOne(
        { tripId: tripDbId, clientPointId: point.clientPointId },
        {
          $set: {
            tripId: tripDbId,
            userId,
            clientPointId: point.clientPointId,
            latitude: coords.latitude,
            longitude: coords.longitude,
            accuracy: point.accuracy,
            speed: point.speed,
            bearing: point.bearing,
            timestamp: ts,
            isSmoothed: !!smoothed,
          },
        },
        { upsert: true },
      );
    } catch (err: unknown) {
      const code = (err as { code?: number })?.code;
      if (code === 11000) {
        return {
          persisted: false,
          latitude: coords.latitude,
          longitude: coords.longitude,
          timestamp: ts,
          clientPointId: point.clientPointId,
        };
      }
      throw err;
    }

    await this.redis.setLiveLocation(userId, {
      tripId,
      latitude: coords.latitude,
      longitude: coords.longitude,
      speed: point.speed,
      bearing: point.bearing,
      timestamp: ts.toISOString(),
      accuracy: point.accuracy,
    });

    user.presence = EmployeePresence.ON_TRIP;
    user.lastSeenAt = new Date();
    await user.save();

    if (
      trip.destinationLat != null &&
      trip.destinationLng != null &&
      (trip.status === TripStatus.TRAVELLING ||
        trip.status === TripStatus.STARTED)
    ) {
      const autoArrive = this.geofence.shouldAutoArrive(
        { latitude: coords.latitude, longitude: coords.longitude },
        { latitude: trip.destinationLat, longitude: trip.destinationLng },
        point.speed,
      );
      if (autoArrive) {
        trip.status = TripStatus.ARRIVED;
        trip.arrivedAt = new Date();
        await trip.save();
      }
    }

    await this.recalculateTripMetrics(tripId);
    await this.detectStops(tripDbId);

    this.events.emit(APP_EVENTS.GPS_BATCH_PERSISTED, {
      tripId,
      userId,
      count: 1,
      lastPoint: {
        latitude: coords.latitude,
        longitude: coords.longitude,
        timestamp: ts,
        speed: point.speed,
        bearing: point.bearing,
      },
    });

    return {
      persisted: true,
      latitude: coords.latitude,
      longitude: coords.longitude,
      timestamp: ts,
      clientPointId: point.clientPointId,
    };
  }

  async listRoute(
    tripId: string,
    params: { from?: string; to?: string; cursor?: string; limit?: number },
  ) {
    const trip = await this.trips.findDocumentByTripId(tripId);
    const limit = params.limit ?? 500;
    const query: Record<string, unknown> = { tripId: docId(trip) };
    if (params.from || params.to) {
      query.timestamp = {
        ...(params.from ? { $gte: new Date(params.from) } : {}),
        ...(params.to ? { $lte: new Date(params.to) } : {}),
      };
    }
    if (params.cursor && Types.ObjectId.isValid(params.cursor)) {
      query._id = { $gt: new Types.ObjectId(params.cursor) };
    }

    const points = await this.gpsModel
      .find(query)
      .sort({ timestamp: 1 })
      .limit(limit + 1)
      .lean()
      .exec();

    const hasMore = points.length > limit;
    const items = hasMore ? points.slice(0, limit) : points;
    return {
      items: items.map((p) => ({ ...p, id: p._id.toString() })),
      nextCursor: hasMore ? items[items.length - 1]._id.toString() : null,
    };
  }

  async recalculateTripMetrics(tripId: string) {
    const trip = await this.trips.findDocumentByTripId(tripId);
    const tripDbId = docId(trip);
    const points = await this.gpsModel
      .find({ tripId: tripDbId })
      .sort({ timestamp: 1 })
      .select('latitude longitude timestamp speed')
      .lean()
      .exec();

    if (!points.length) return;

    const coords: LatLng[] = points.map((p) => ({
      latitude: p.latitude,
      longitude: p.longitude,
    }));
    const totalDistance = calculateRouteDistance(coords);
    const start = points[0].timestamp;
    const end = points[points.length - 1].timestamp;
    const travelDuration = Math.floor(
      (end.getTime() - start.getTime()) / 1000,
    );

    let idleTime = 0;
    const stopMinutes = gpsStopDetectionMinutes(this.config);
    for (let i = 1; i < points.length; i++) {
      const speed = points[i].speed ?? 0;
      const gap =
        (points[i].timestamp.getTime() - points[i - 1].timestamp.getTime()) /
        1000;
      if (speed < 1 && gap >= stopMinutes * 60) {
        idleTime += gap;
      }
    }

    const planned = trip.plannedDistance ?? totalDistance;
    const tripEfficiency =
      totalDistance > 0
        ? Math.min(100, (planned / totalDistance) * 100)
        : 100;
    const meetingRatio = trip.meetingDuration / Math.max(travelDuration, 1);
    const productivityScore = Math.min(
      100,
      meetingRatio * 50 + tripEfficiency * 0.5,
    );

    await this.trips.updateMetrics(tripId, {
      totalDistance,
      travelDuration,
      idleTime: Math.floor(idleTime),
      tripEfficiency,
      productivityScore,
    });
  }

  private async detectStops(tripDbId: string) {
    const points = await this.gpsModel
      .find({ tripId: tripDbId })
      .sort({ timestamp: 1 })
      .exec();
    const stopMinutes = gpsStopDetectionMinutes(this.config);
    let stopStart: GpsPointDocument | null = null;

    for (let i = 1; i < points.length; i++) {
      const speed = points[i].speed ?? 0;
      const gapMin =
        (points[i].timestamp.getTime() - points[i - 1].timestamp.getTime()) /
        60000;

      if (speed < 1 && gapMin >= stopMinutes) {
        if (!stopStart) stopStart = points[i - 1];
      } else if (stopStart) {
        const duration = Math.floor(
          (points[i - 1].timestamp.getTime() -
            stopStart.timestamp.getTime()) /
            1000,
        );
        await this.stopModel.create({
          tripId: tripDbId,
          latitude: stopStart.latitude,
          longitude: stopStart.longitude,
          startedAt: stopStart.timestamp,
          endedAt: points[i - 1].timestamp,
          duration,
        });
        this.events.emit(APP_EVENTS.GPS_STOP_DETECTED, {
          tripId: tripDbId,
          duration,
        });
        stopStart = null;
      }
    }
  }
}
