import {
  BadRequestException,
  ForbiddenException,
  Injectable,
} from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import { PunchType, TripStatus } from '../common/constants/enums';
import { APP_EVENTS } from '../common/constants/events';
import type { JwtUserPayload } from '../auth/jwt.types';
import { TripsService } from '../trips/trips.service';
import { GeofencingService } from '../geofencing/geofencing.service';
import { Punch, PunchDocument } from './schemas/punch.schema';
import { docId } from '../common/utils/mongo.util';
import {
  ensureTripLegs,
  findActiveLegIndex,
  hasPunch,
  punchFromDto,
  syncTripTopLevelFromLegs,
  type TripLegRecord,
} from '../trips/trip-leg.util';
import {
  ArrivalPunchDto,
  DeparturePunchDto,
  MeetingEndPunchDto,
  MeetingStartPunchDto,
} from './dto/punch.dto';

@Injectable()
export class PunchesService {
  constructor(
    @InjectModel(Punch.name) private readonly punchModel: Model<PunchDocument>,
    private readonly trips: TripsService,
    private readonly geofence: GeofencingService,
    private readonly events: EventEmitter2,
  ) {}

  async recordDeparture(
    tripId: string,
    actor: JwtUserPayload,
    dto: DeparturePunchDto,
  ) {
    if (dto.isMockLocation) {
      throw new ForbiddenException('Mock location detected');
    }
    const trip = await this.trips.findDocumentByTripId(tripId);
    const tripDbId = docId(trip);
    const persistedLegs = Array.isArray(trip.tripLegs)
      ? (trip.tripLegs as TripLegRecord[]).map((leg) => ({ ...leg }))
      : [];

    if (persistedLegs.length > 1) {
      const activeIndex = findActiveLegIndex(persistedLegs);
      const active = persistedLegs[activeIndex];
      if (hasPunch(active, 'departurePunch')) {
        throw new BadRequestException('Already departed from this stop');
      }

      const origin = this.resolveLegDepartureOrigin(
        trip,
        persistedLegs,
        activeIndex,
      );
      if (!origin) {
        throw new BadRequestException(
          'Could not verify the starting location for this leg. ' +
            'Ensure from/to locations were selected on the map.',
        );
      }
      const departAllowed = this.geofence.canDepart(
        { latitude: dto.latitude, longitude: dto.longitude },
        origin,
      );
      if (!departAllowed) {
        throw new BadRequestException(
          `Departure must be within ${this.geofence.departureRadiusMeters()}m of the starting location`,
        );
      }

      persistedLegs[activeIndex] = {
        ...active,
        departurePunch: punchFromDto(dto, 'travel_departure'),
      };
      syncTripTopLevelFromLegs(trip, persistedLegs);
      trip.set('tripLegs', persistedLegs);
      trip.markModified('tripLegs');
      trip.markModified('status');
      await trip.save();

      const punchPayload = {
        type: PunchType.DEPARTURE,
        timestamp: new Date(dto.timestamp),
        latitude: dto.latitude,
        longitude: dto.longitude,
        address: dto.address,
      };
      this.events.emit(APP_EVENTS.PUNCH_RECORDED, {
        tripId,
        type: PunchType.DEPARTURE,
        punch: punchPayload,
      });
      return {
        punch: { ...punchPayload, id: `${tripId}_leg_${activeIndex + 1}_departure` },
        trip: { ...trip.toObject(), id: tripDbId, status: trip.status },
      };
    }

    if (trip.status !== TripStatus.CREATED) {
      throw new BadRequestException('Trip must be in CREATED status');
    }

    const origin = this.resolveTripOrigin(trip);
    if (!origin) {
      throw new BadRequestException(
        'Trip is missing starting coordinates (originLat/originLng). ' +
          'Edit the request and select the from location on the map, then try again.',
      );
    }
    const departAllowed = this.geofence.canDepart(
      { latitude: dto.latitude, longitude: dto.longitude },
      origin,
    );
    if (!departAllowed) {
      throw new BadRequestException(
        `Departure must be within ${this.geofence.departureRadiusMeters()}m of the starting location`,
      );
    }

    const punch = await this.createPunch(tripDbId, PunchType.DEPARTURE, dto);
    if (!trip.fromLocation?.trim() && dto.address?.trim()) {
      trip.fromLocation = dto.address.trim();
    }
    if (trip.originLat == null || trip.originLng == null) {
      trip.originLat = origin.latitude;
      trip.originLng = origin.longitude;
    }
    await this.trips.updateStatus(tripId, TripStatus.STARTED, actor);
    trip.status = TripStatus.TRAVELLING;
    await this.persistTripLegsFromPunches(trip);
    await trip.save();

    this.events.emit(APP_EVENTS.PUNCH_RECORDED, {
      tripId,
      type: PunchType.DEPARTURE,
      punch,
    });
    return {
      punch: { ...punch.toObject(), id: docId(punch) },
      trip: { ...trip.toObject(), id: tripDbId, status: TripStatus.TRAVELLING },
    };
  }

  async recordArrival(
    tripId: string,
    actor: JwtUserPayload,
    dto: ArrivalPunchDto,
  ) {
    const trip = await this.trips.findDocumentByTripId(tripId);
    const tripDbId = docId(trip);
    const persistedLegs = this.getPersistedLegs(trip);

    if (persistedLegs.length > 1) {
      const activeIndex = findActiveLegIndex(persistedLegs);
      const active = persistedLegs[activeIndex];
      if (!hasPunch(active, 'departurePunch')) {
        throw new BadRequestException('Must depart before marking arrival');
      }
      if (hasPunch(active, 'arrivalPunch')) {
        throw new BadRequestException('Already arrived at this stop');
      }

      const destination = this.resolveLegArrivalDestination(
        trip,
        persistedLegs,
        activeIndex,
      );
      if (destination) {
        const allowed = this.geofence.canArrive(
          { latitude: dto.latitude, longitude: dto.longitude },
          destination,
        );
        if (!allowed) {
          throw new BadRequestException(
            `Arrival must be within ${this.geofence.arrivalRadiusMeters()}m of destination`,
          );
        }
      }

      persistedLegs[activeIndex] = {
        ...active,
        arrivalPunch: punchFromDto(dto, 'travel_arrival'),
      };
      if (!trip.toLocation?.trim() && dto.address?.trim()) {
        trip.toLocation = dto.address.trim();
      }
      const activeClient = String(active.clientName ?? '').trim();
      if (!trip.clientName?.trim() && activeClient) {
        trip.clientName = activeClient;
      }
      trip.destinationLat = destination?.latitude ?? dto.latitude;
      trip.destinationLng = destination?.longitude ?? dto.longitude;
      await this.saveLegPunchUpdate(trip, persistedLegs);

      const punchPayload = {
        type: PunchType.ARRIVAL,
        timestamp: new Date(dto.timestamp),
        latitude: dto.latitude,
        longitude: dto.longitude,
        address: dto.address,
      };
      this.events.emit(APP_EVENTS.PUNCH_RECORDED, {
        tripId,
        type: PunchType.ARRIVAL,
        punch: punchPayload,
      });
      return {
        punch: { ...punchPayload, id: `${tripId}_leg_${activeIndex + 1}_arrival` },
        trip: { ...trip.toObject(), id: tripDbId, status: trip.status },
      };
    }

    if (
      trip.status !== TripStatus.STARTED &&
      trip.status !== TripStatus.TRAVELLING
    ) {
      throw new BadRequestException('Invalid trip status for arrival');
    }

    const destination = this.resolveTripDestination(trip);
    if (destination) {
      const allowed = this.geofence.canArrive(
        { latitude: dto.latitude, longitude: dto.longitude },
        destination,
      );
      if (!allowed) {
        throw new BadRequestException(
          `Arrival must be within ${this.geofence.arrivalRadiusMeters()}m of destination`,
        );
      }
    } else {
      throw new BadRequestException(
        'Trip is missing destination coordinates (destinationLat/destinationLng). ' +
          'Edit the request and select the destination on the map, then try again.',
      );
    }

    const punch = await this.createPunch(tripDbId, PunchType.ARRIVAL, dto);
    if (!trip.toLocation?.trim() && dto.address?.trim()) {
      trip.toLocation = dto.address.trim();
    }
    if (!trip.clientName?.trim()) {
      trip.clientName = trip.toLocation ?? dto.address?.trim();
    }
    if (trip.destinationLat == null || trip.destinationLng == null) {
      trip.destinationLat = destination.latitude;
      trip.destinationLng = destination.longitude;
    }
    await trip.save();

    await this.persistTripLegsFromPunches(trip);
    await trip.save();

    const updated = await this.trips.updateStatus(
      tripId,
      TripStatus.ARRIVED,
      actor,
    );
    this.events.emit(APP_EVENTS.PUNCH_RECORDED, {
      tripId,
      type: PunchType.ARRIVAL,
      punch,
    });
    return {
      punch: { ...punch.toObject(), id: docId(punch) },
      trip: updated,
    };
  }

  async recordMeetingStart(
    tripId: string,
    actor: JwtUserPayload,
    dto: MeetingStartPunchDto,
  ) {
    const tripDoc = await this.trips.findDocumentByTripId(tripId);
    const tripDbId = docId(tripDoc);
    const persistedLegs = this.getPersistedLegs(tripDoc);

    if (persistedLegs.length > 1) {
      const activeIndex = findActiveLegIndex(persistedLegs);
      const active = persistedLegs[activeIndex];
      if (!hasPunch(active, 'arrivalPunch')) {
        throw new BadRequestException('Must arrive before starting meeting');
      }
      if (hasPunch(active, 'meetingStartPunch')) {
        throw new BadRequestException('Meeting already started for this stop');
      }

      persistedLegs[activeIndex] = {
        ...active,
        meetingStartPunch: punchFromDto(dto, 'meeting_start'),
      };
      await this.saveLegPunchUpdate(tripDoc, persistedLegs);

      const punchPayload = {
        type: PunchType.MEETING_START,
        timestamp: new Date(dto.timestamp),
        latitude: dto.latitude,
        longitude: dto.longitude,
        address: dto.address,
      };
      this.events.emit(APP_EVENTS.PUNCH_RECORDED, {
        tripId,
        type: PunchType.MEETING_START,
        punch: punchPayload,
      });
      return {
        punch: {
          ...punchPayload,
          id: `${tripId}_leg_${activeIndex + 1}_meeting_start`,
        },
        trip: { ...tripDoc.toObject(), id: tripDbId, status: tripDoc.status },
      };
    }

    if (tripDoc.status !== TripStatus.ARRIVED) {
      throw new BadRequestException('Must arrive before starting meeting');
    }
    const arrival = await this.punchModel
      .findOne({ tripId: tripDbId, type: PunchType.ARRIVAL })
      .exec();
    if (!arrival) {
      throw new BadRequestException('Arrival punch required');
    }

    const punch = await this.createPunch(tripDbId, PunchType.MEETING_START, dto);
    await this.persistTripLegsFromPunches(tripDoc);
    await tripDoc.save();
    const updated = await this.trips.findByTripId(tripId);
    this.events.emit(APP_EVENTS.PUNCH_RECORDED, {
      tripId,
      type: PunchType.MEETING_START,
      punch,
    });
    return {
      punch: { ...punch.toObject(), id: docId(punch) },
      trip: updated,
    };
  }

  async recordMeetingEnd(
    tripId: string,
    actor: JwtUserPayload,
    dto: MeetingEndPunchDto,
  ) {
    const tripDoc = await this.trips.findDocumentByTripId(tripId);
    const tripDbId = docId(tripDoc);
    const persistedLegs = this.getPersistedLegs(tripDoc);

    if (persistedLegs.length > 1) {
      const activeIndex = findActiveLegIndex(persistedLegs);
      const active = persistedLegs[activeIndex];
      if (!hasPunch(active, 'meetingStartPunch')) {
        throw new BadRequestException('Meeting must be started first');
      }
      if (hasPunch(active, 'meetingEndPunch')) {
        throw new BadRequestException('Meeting already ended for this stop');
      }

      const meetingStart = active.meetingStartPunch as
        | { time?: string | Date }
        | undefined;
      const meetingStartTime = meetingStart?.time
        ? new Date(meetingStart.time)
        : null;
      const meetingEndTime = new Date(dto.timestamp);
      const meetingDuration =
        meetingStartTime && !Number.isNaN(meetingStartTime.getTime())
          ? Math.floor(
              (meetingEndTime.getTime() - meetingStartTime.getTime()) / 1000,
            )
          : 0;

      persistedLegs[activeIndex] = {
        ...active,
        meetingEndPunch: punchFromDto(dto, 'meeting_end'),
      };
      tripDoc.meetingDuration = meetingDuration;
      await this.saveLegPunchUpdate(tripDoc, persistedLegs);

      const punchPayload = {
        type: PunchType.MEETING_END,
        timestamp: meetingEndTime,
        latitude: dto.latitude,
        longitude: dto.longitude,
        address: dto.address,
        meetingSummary: dto.meetingSummary,
        leadType: dto.leadType,
        customerNotes: dto.customerNotes,
      };
      this.events.emit(APP_EVENTS.PUNCH_RECORDED, {
        tripId,
        type: PunchType.MEETING_END,
        punch: punchPayload,
      });
      return {
        punch: {
          ...punchPayload,
          id: `${tripId}_leg_${activeIndex + 1}_meeting_end`,
        },
        trip: { ...tripDoc.toObject(), id: tripDbId, status: tripDoc.status },
      };
    }

    if (tripDoc.status !== TripStatus.MEETING_STARTED) {
      throw new BadRequestException('Meeting must be started first');
    }

    const punch = await this.punchModel.create({
      tripId: tripDbId,
      type: PunchType.MEETING_END,
      timestamp: new Date(dto.timestamp),
      latitude: dto.latitude,
      longitude: dto.longitude,
      address: dto.address,
      meetingSummary: dto.meetingSummary,
      leadType: dto.leadType,
      customerNotes: dto.customerNotes,
    });

    const meetingStart = await this.punchModel
      .findOne({ tripId: tripDbId, type: PunchType.MEETING_START })
      .exec();
    const meetingDuration = meetingStart
      ? Math.floor(
          (punch.timestamp.getTime() - meetingStart.timestamp.getTime()) / 1000,
        )
      : 0;

    tripDoc.status = TripStatus.MEETING_COMPLETED;
    tripDoc.meetingDuration = meetingDuration;
    await this.persistTripLegsFromPunches(tripDoc);
    await tripDoc.save();

    const updated = await this.trips.findByTripId(tripId);
    this.events.emit(APP_EVENTS.PUNCH_RECORDED, {
      tripId,
      type: PunchType.MEETING_END,
      punch,
    });
    return {
      punch: { ...punch.toObject(), id: docId(punch) },
      trip: updated,
    };
  }

  private getPersistedLegs(trip: { tripLegs?: TripLegRecord[] }): TripLegRecord[] {
    return Array.isArray(trip.tripLegs)
      ? (trip.tripLegs as TripLegRecord[]).map((leg) => ({ ...leg }))
      : [];
  }

  private async saveLegPunchUpdate(
    trip: {
      tripLegs?: TripLegRecord[];
      set: (key: string, value: unknown) => void;
      markModified: (key: string) => void;
      save: () => Promise<unknown>;
      status: TripStatus;
    },
    legs: TripLegRecord[],
  ) {
    syncTripTopLevelFromLegs(trip as never, legs);
    trip.set('tripLegs', legs);
    trip.markModified('tripLegs');
    trip.markModified('status');
    await trip.save();
  }

  private async persistTripLegsFromPunches(trip: {
    tripId: string;
    tripLegs?: TripLegRecord[];
    set: (key: string, value: unknown) => void;
    markModified: (key: string) => void;
    save: () => Promise<unknown>;
  }) {
    const tripDbId = docId(trip as never);
    const punches = await this.punchModel
      .find({ tripId: tripDbId })
      .sort({ timestamp: 1 })
      .lean()
      .exec();
    const legs = ensureTripLegs(
      trip as never,
      punches.map((p) => ({ ...p, id: String(p._id) })),
    );
    syncTripTopLevelFromLegs(trip as never, legs);
    trip.set('tripLegs', legs);
    trip.markModified('tripLegs');
  }

  private resolveTripOrigin(trip: {
    originLat?: number | null;
    originLng?: number | null;
  }) {
    if (trip.originLat == null || trip.originLng == null) return null;
    return { latitude: trip.originLat, longitude: trip.originLng };
  }

  private resolveTripDestination(trip: {
    destinationLat?: number | null;
    destinationLng?: number | null;
  }) {
    if (trip.destinationLat == null || trip.destinationLng == null) return null;
    return { latitude: trip.destinationLat, longitude: trip.destinationLng };
  }

  private resolveLegDepartureOrigin(
    trip: { originLat?: number | null; originLng?: number | null },
    legs: TripLegRecord[],
    activeIndex: number,
  ) {
    if (activeIndex <= 0) {
      return this.resolveTripOrigin(trip);
    }
    const previous = legs[activeIndex - 1];
    const arrival = previous?.arrivalPunch as
      | { latitude?: number; longitude?: number }
      | undefined;
    if (arrival?.latitude != null && arrival?.longitude != null) {
      return { latitude: arrival.latitude, longitude: arrival.longitude };
    }
    return this.resolveTripOrigin(trip);
  }

  private resolveLegArrivalDestination(
    trip: { destinationLat?: number | null; destinationLng?: number | null },
    legs: TripLegRecord[],
    activeIndex: number,
  ) {
    const leg = legs[activeIndex];
    const legLat = leg?.destinationLat ?? leg?.toLat;
    const legLng = leg?.destinationLng ?? leg?.toLng;
    if (legLat != null && legLng != null) {
      return { latitude: Number(legLat), longitude: Number(legLng) };
    }
    if (activeIndex <= 0) {
      return this.resolveTripDestination(trip);
    }
    return null;
  }

  private async createPunch(
    tripDbId: string,
    type: PunchType,
    dto: {
      timestamp: string;
      latitude: number;
      longitude: number;
      address?: string;
      batteryPercent?: number;
      gpsAccuracy?: number;
      speed?: number;
      isMockLocation?: boolean;
    },
  ) {
    return this.punchModel.create({
      tripId: tripDbId,
      type,
      timestamp: new Date(dto.timestamp),
      latitude: dto.latitude,
      longitude: dto.longitude,
      address: dto.address,
      batteryPercent: dto.batteryPercent,
      gpsAccuracy: dto.gpsAccuracy,
      speed: dto.speed,
      isMockLocation: dto.isMockLocation ?? false,
    });
  }
}
