import {
  BadRequestException,
  ConflictException,
  Injectable,
  NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Not, Repository } from 'typeorm';
import { CreateVehicleDto } from './dto/create-vehicle.dto';
import { UpdateVehicleDto } from './dto/update-vehicle.dto';
import { Vehicle, VehicleAvailability, VehicleStatus } from './entities/vehicle.entity';
import { TruckType } from '../truck-types/entities/truck-type.entity';
import { TruckTypesService } from '../truck-types/truck-types.service';
import { FirstMile, FirstMileStatus } from '../first-mile/entities/first-mile.entity';
import { FirstMileContainerAllocation } from '../first-mile/entities/first-mile-container-allocation.entity';
import { LastMile, LastMileStatus } from '../last-mile/entities/last-mile.entity';
import { LastMileContainerAllocation } from '../last-mile/entities/last-mile-container-allocation.entity';
import { BookingContainerAllocation } from '../bookings/entities/booking-container-allocation.entity';
import { FleetHistoryService } from '../fleet-history/fleet-history.service';
import { FleetEventType } from '../fleet-history/entities/fleet-event.entity';

@Injectable()
export class VehiclesService {
  constructor(
    @InjectRepository(Vehicle)
    private readonly vehicleRepo: Repository<Vehicle>,
    private readonly history: FleetHistoryService,
    private readonly truckTypes: TruckTypesService,
  ) {}

  /**
   * A trailer plate only exists on a configuration that pulls a trailer — a
   * rigid truck (Casoni) has none. Checked against the RESULTING record, not
   * just the patch, so switching an articulated truck to a rigid type cannot
   * leave its old trailer plate stranded on the row.
   */
  private assertTrailerPlateAllowed(
    truckType: TruckType,
    trailerPlateNo?: string | null,
  ): void {
    if (!truckType.hasTrailer && trailerPlateNo) {
      throw new BadRequestException(
        `${truckType.name} has no trailer — remove the trailer plate number`,
      );
    }
  }

  /**
   * A driver holds one truck at a time — reassignment requires detaching them
   * from their current truck first.
   * ponytail: app-level guard only (race window); add a partial unique index on
   * assigned_driver_id if concurrent fleet edits ever become real.
   */
  private async assertDriverUnassigned(driverId: string, exceptVehicleId?: string): Promise<void> {
    const holder = await this.vehicleRepo.findOne({
      where: exceptVehicleId
        ? { assignedDriverId: driverId, id: Not(exceptVehicleId) }
        : { assignedDriverId: driverId },
    });
    if (holder) {
      throw new ConflictException(
        `This driver is already assigned to truck ${holder.plateNumber ?? holder.code ?? holder.id} — detach the driver from that truck first`,
      );
    }
  }

  async create(dto: CreateVehicleDto): Promise<Vehicle> {
    const existing = await this.vehicleRepo.findOne({
      where: { plateNumber: dto.plateNumber },
    });

    if (existing) {
      throw new ConflictException(
        `Vehicle with plate number ${dto.plateNumber} already exists`,
      );
    }

    if (dto.assignedDriverId) {
      await this.assertDriverUnassigned(dto.assignedDriverId);
    }

    const truckType = await this.truckTypes.findById(dto.truckTypeId);
    this.assertTrailerPlateAllowed(truckType, dto.trailerPlateNo);

    const registrationNumber = `REG-${truckType.code}-${Date.now()}`;
    const vehicle = this.vehicleRepo.create({
      ...dto,
      registrationNumber,
      // Denormalised for truck-detention billing, which groups on this column.
      vehicleType: truckType.code,
      // Capacity belongs to the type; an explicit value still wins for one-offs.
      capacity: dto.capacity ?? truckType.capacityTons ?? undefined,
    });

    const saved = await this.vehicleRepo.save(vehicle);

    await this.history.record({
      eventType: FleetEventType.VEHICLE_REGISTERED,
      vehicleId: saved.id,
      label: saved.plateNumber ?? saved.code ?? null,
      toValue: saved.availability ?? null,
    });
    if (saved.assignedDriverId) {
      await this.history.record({
        eventType: FleetEventType.DRIVER_ASSIGNED,
        vehicleId: saved.id,
        driverId: saved.assignedDriverId,
        label: saved.assignedDriverName ?? null,
        metadata: {
          vehiclePlate: saved.plateNumber ?? saved.code ?? null,
          driverName: saved.assignedDriverName ?? null,
        },
      });
    }

    return saved;
  }

  async findAll(query: {
    search?: string;
    status?: VehicleStatus | string;
    availability?: VehicleAvailability | string;
    page?: number;
    limit?: number;
    sortBy?: string;
    sortOrder?: 'ASC' | 'DESC';
  } = {}): Promise<Vehicle[]> {
    let qb = this.vehicleRepo.createQueryBuilder('v');

    if (query.search) {
      qb = qb.where(
        '(v.plateNumber ILIKE :search OR v.manufacturer ILIKE :search OR v.model ILIKE :search OR v.code ILIKE :search OR v.trailerPlateNo ILIKE :search)',
        { search: `%${query.search}%` },
      );
    }

    if (query.status) {
      qb = qb.andWhere('v.status = :status', { status: query.status });
    }

    if (query.availability) {
      qb = qb.andWhere('v.availability = :availability', { availability: query.availability });
    }

    const sortBy = ['plateNumber', 'status', 'year', 'createdAt'].includes(
      query.sortBy ?? '',
    )
      ? query.sortBy
      : 'createdAt';
    const sortOrder = (query.sortOrder ?? 'DESC').toUpperCase();

    return qb
      .orderBy(`v.${sortBy}`, sortOrder as 'ASC' | 'DESC')
      .getMany();
  }

  async findById(id: string): Promise<Vehicle> {
    const vehicle = await this.vehicleRepo.findOne({ where: { id } });
    if (!vehicle) {
      throw new NotFoundException(`Vehicle ${id} not found`);
    }
    return vehicle;
  }

  async update(id: string, dto: UpdateVehicleDto): Promise<Vehicle> {
    const vehicle = await this.findById(id);

    if (dto.plateNumber && dto.plateNumber !== vehicle.plateNumber) {
      const existing = await this.vehicleRepo.findOne({
        where: { plateNumber: dto.plateNumber },
      });
      if (existing) {
        throw new ConflictException(
          `Vehicle with plate number ${dto.plateNumber} already exists`,
        );
      }
    }

    if (dto.assignedDriverId && dto.assignedDriverId !== vehicle.assignedDriverId) {
      await this.assertDriverUnassigned(dto.assignedDriverId, id);
    }

    // Re-resolve the truck type whenever the type OR the trailer plate moves —
    // either edit can produce a rigid truck holding a trailer plate.
    const nextTruckTypeId = dto.truckTypeId ?? vehicle.truckTypeId;
    let nextTruckType: TruckType | null = null;
    if (nextTruckTypeId && (dto.truckTypeId !== undefined || dto.trailerPlateNo !== undefined)) {
      nextTruckType = await this.truckTypes.findById(nextTruckTypeId);
      const nextTrailerPlate =
        dto.trailerPlateNo !== undefined ? dto.trailerPlateNo : vehicle.trailerPlateNo;
      this.assertTrailerPlateAllowed(nextTruckType, nextTrailerPlate);
    }

    const prev = {
      assignedDriverId: vehicle.assignedDriverId,
      assignedDriverName: vehicle.assignedDriverName,
      status: vehicle.status,
      availability: vehicle.availability,
    };

    Object.assign(vehicle, dto);
    // After the patch is applied, so the denormalised billing code always
    // reflects the type the vehicle actually ends up on.
    if (nextTruckType) {
      vehicle.vehicleType = nextTruckType.code;
    }
    const saved = await this.vehicleRepo.save(vehicle);

    // Driver (re)assignment — emit an unassign for the old driver and/or an
    // assign for the new one so both drivers' timelines and the vehicle's line up.
    if (
      dto.assignedDriverId !== undefined &&
      dto.assignedDriverId !== prev.assignedDriverId
    ) {
      const vehiclePlate = saved.plateNumber ?? saved.code ?? null;
      if (prev.assignedDriverId) {
        await this.history.record({
          eventType: FleetEventType.DRIVER_UNASSIGNED,
          vehicleId: id,
          driverId: prev.assignedDriverId,
          label: prev.assignedDriverName ?? null,
          metadata: { vehiclePlate, driverName: prev.assignedDriverName ?? null },
        });
      }
      if (saved.assignedDriverId) {
        await this.history.record({
          eventType: FleetEventType.DRIVER_ASSIGNED,
          vehicleId: id,
          driverId: saved.assignedDriverId,
          label: saved.assignedDriverName ?? null,
          metadata: { vehiclePlate, driverName: saved.assignedDriverName ?? null },
        });
      }
    }
    if (dto.status !== undefined && dto.status !== prev.status) {
      await this.history.record({
        eventType: FleetEventType.VEHICLE_STATUS_CHANGED,
        vehicleId: id,
        fromValue: prev.status ?? null,
        toValue: saved.status ?? null,
      });
    }
    if (dto.availability !== undefined && dto.availability !== prev.availability) {
      await this.history.record({
        eventType: FleetEventType.VEHICLE_AVAILABILITY_CHANGED,
        vehicleId: id,
        fromValue: prev.availability ?? null,
        toValue: saved.availability ?? null,
      });
    }

    return saved;
  }

  async setAvailability(id: string, availability: VehicleAvailability): Promise<void> {
    // Read the current value so the audit event records an accurate from→to and
    // we skip logging no-op writes (setAvailability is called in release loops).
    const vehicle = await this.vehicleRepo.findOne({ where: { id } });
    const previous = vehicle?.availability;
    await this.vehicleRepo.update(id, { availability });
    if (previous !== availability) {
      await this.history.record({
        eventType: FleetEventType.VEHICLE_AVAILABILITY_CHANGED,
        vehicleId: id,
        fromValue: previous ?? null,
        toValue: availability,
      });
    }
  }

  /**
   * Set vehicles back to FREE, but only when no active (non-completed)
   * first/last-mile record or container allocation still references them.
   * First-mile trips ending in RECEIVED_TO_PORT and last-mile trips ending
   * in DELIVERED no longer hold the vehicle.
   */
  async releaseIfUnused(vehicleIds: string[]): Promise<void> {
    const manager = this.vehicleRepo.manager;
    for (const vehicleId of [...new Set(vehicleIds)]) {
      const [fmRecords, lmRecords, fmAllocations, lmAllocations, bookingAllocations] = await Promise.all([
        manager.count(FirstMile, {
          where: { vehicleId, status: Not<FirstMileStatus>('RECEIVED_TO_PORT') },
        }),
        manager.count(LastMile, {
          where: { vehicleId, status: Not<LastMileStatus>('DELIVERED') },
        }),
        manager
          .createQueryBuilder(FirstMileContainerAllocation, 'alloc')
          .innerJoin(FirstMile, 'fm', 'fm.id = alloc.firstMileId')
          .where('alloc.vehicleId = :vehicleId', { vehicleId })
          .andWhere('fm.status != :done', { done: 'RECEIVED_TO_PORT' })
          .andWhere('fm.deletedAt IS NULL')
          .getCount(),
        manager
          .createQueryBuilder(LastMileContainerAllocation, 'alloc')
          .innerJoin(LastMile, 'lm', 'lm.id = alloc.lastMileId')
          .where('alloc.vehicleId = :vehicleId', { vehicleId })
          .andWhere('lm.status != :done', { done: 'DELIVERED' })
          .andWhere('lm.deletedAt IS NULL')
          .getCount(),
        manager.count(BookingContainerAllocation, { where: { vehicleId } }),
      ]);
      if (fmRecords + lmRecords + fmAllocations + lmAllocations + bookingAllocations === 0) {
        await this.setAvailability(vehicleId, VehicleAvailability.FREE);
      }
    }
  }

  async remove(id: string): Promise<void> {
    await this.findById(id);
    await this.vehicleRepo.softDelete(id);
  }
}
