import {
  BadRequestException,
  Injectable,
  Logger,
  NotFoundException,
} from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';

import { Booking } from '../bookings/entities/booking.entity';
import { RouteMilestone } from '../routes/entities/route-milestone.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import { BookingBatchService } from './booking-batch.service';
import { BookingJourneyService } from './booking-journey.service';

/**
 * Intercity (DOMESTIC) ride-along: intercity bookings never get their own
 * train — they ride a passing import/export schedule whose route milestones
 * contain the booking's origin strictly before its destination.
 *
 * Flow: the customer books a corridor with no date; at finalize time staff see
 * every waiting intercity booking whose corridor lies on the schedule's route,
 * with its wagon/weight/length need against the train's remaining capacity;
 * accepting reserves it (pay window → payment → allocation, same lifecycle as
 * a batch reservation). Cargo is loaded manually when the train reaches the
 * booking's origin yard and unloaded at its destination yard.
 */
@Injectable()
export class IntercityService {
  private readonly logger = new Logger(IntercityService.name);

  constructor(
    @InjectDataSource() private readonly dataSource: DataSource,
    private readonly bookingBatchService: BookingBatchService,
    private readonly bookingJourneyService: BookingJourneyService,
  ) {}

  /**
   * Waiting intercity bookings this schedule could carry, with the train's
   * remaining capacity along all three axes (wagons, weight, length) and each
   * booking's need, so staff can pick what fits.
   */
  /**
   * Every intercity booking and where it is in its ride-along, across all trains.
   *
   * The per-schedule candidate list answers "what can THIS train carry"; this
   * answers "what is happening to intercity cargo" — which is what a yard
   * operator needs when the work is spread over whichever trains happen to pass.
   *
   * Carries each end's facility state, because a booking whose origin or
   * destination has no facility can never be loaded or unloaded there and the
   * operator should see that before the train arrives, not when the load is
   * refused.
   */
  async listBookings() {
    return this.dataSource.query(
      `SELECT b.id                        AS "bookingId",
              b.reference                 AS "reference",
              b.status                    AS "status",
              b.freight_type              AS "freightType",
              b.cargo_total_weight_vgm    AS "weightTons",
              b.loaded_at                 AS "loadedAt",
              b.arrived_at                AS "arrivedAt",
              company.name                AS "customer",
              b.train_schedule_id         AS "trainScheduleId",
              ts.train_number             AS "trainNumber",
              ts.status                   AS "scheduleStatus",
              oy.id                       AS "originYardId",
              COALESCE(oy.label, oy.code) AS "origin",
              -- Can that end actually handle THIS booking's cargo? A container
              -- booking needs a facility with a stacker; bulk needs any facility.
              (oy.has_facility AND COALESCE(
                 CASE WHEN b.freight_type = 'CONTAINER'
                      THEN ofac.handles_container ELSE ofac.handles_bulk END, false))
                                          AS "originHasFacility",
              dy.id                       AS "destinationYardId",
              COALESCE(dy.label, dy.code) AS "destination",
              (dy.has_facility AND COALESCE(
                 CASE WHEN b.freight_type = 'CONTAINER'
                      THEN dfac.handles_container ELSE dfac.handles_bulk END, false))
                                          AS "destinationHasFacility",
              -- Where the train actually is, so the operator knows if the cargo
              -- can be worked right now.
              cp.yard_id                  AS "trainAtYardId",
              -- Most recent GRN raised for this booking at a facility.
              fh.grn_number               AS "grnNumber"
         FROM freight.bookings b
         LEFT JOIN freight.companies company ON company.id = b.company_id
         LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
         LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id
         LEFT JOIN freight.yard_facilities ofac
           ON ofac.yard_id = oy.id AND ofac.deleted_at IS NULL AND ofac.is_active = true
         LEFT JOIN freight.yard_facilities dfac
           ON dfac.yard_id = dy.id AND dfac.deleted_at IS NULL AND dfac.is_active = true
         LEFT JOIN freight.train_schedules ts
           ON ts.id = b.train_schedule_id AND ts.deleted_at IS NULL
         LEFT JOIN LATERAL (
           SELECT c.yard_id
             FROM freight.train_checkpoint_events c
            WHERE c.train_schedule_id = b.train_schedule_id
            ORDER BY c.occurred_at DESC, c.created_at DESC
            LIMIT 1
         ) cp ON true
         LEFT JOIN LATERAL (
           SELECT e.grn_number
             FROM freight.facility_handling_events e
            WHERE e.booking_id = b.id AND e.deleted_at IS NULL
            ORDER BY e.occurred_at DESC
            LIMIT 1
         ) fh ON true
        WHERE b.deleted_at IS NULL
          AND b.trade_direction = 'DOMESTIC'
        ORDER BY b.created_at DESC`,
    );
  }

  async listCandidates(scheduleId: string) {
    const schedule = await this.getSchedule(scheduleId);
    const milestones = await this.routeMilestones(schedule);
    const milestoneSeq = this.milestoneSequenceOf(schedule, milestones);
    const capacity = await this.bookingBatchService.intercityCapacity(scheduleId);

    const waiting = milestoneSeq
      ? await this.findWaitingIntercityBookings(milestoneSeq)
      : [];
    const accepted = await this.findAcceptedIntercityBookings(scheduleId);

    // Mid-corridor intercity matching needs a real stop list (>= 2 route
    // milestones). Without one the fallback is a 2-stop origin->destination
    // pseudo-route that only matches bookings on the train's exact corridor —
    // surface that so an empty candidate list isn't misread as "nobody waiting".
    const warning =
      milestoneSeq == null
        ? 'This schedule has no route or origin/destination set, so no intercity corridors can be served.'
        : schedule.routeId && milestones.length < 2
          ? "This schedule's route has no stop list (needs at least 2 route milestones), so mid-corridor intercity bookings cannot be matched — only bookings on the train's exact origin→destination will appear."
          : null;

    return {
      scheduleId,
      routeId: schedule.routeId ?? null,
      warning,
      // Segment-based: "remaining" is the most-open edge; each candidate's
      // `fits` is judged against ITS OWN leg, so a booking on a free leg fits
      // even when the train is full elsewhere.
      remaining: capacity?.budget.maxRemaining() ?? null,
      candidates: waiting.map((booking) => {
        const need = capacity?.needFor(booking) ?? null;
        // legForYards: the booking draws only from ITS OWN leg's edges, with a
        // whole-route fallback when its yards aren't on the budget's stop list.
        const leg = capacity?.budget.legForYards(
          booking.originYardId,
          booking.destinationYardId,
        );
        return {
          ...this.mapBooking(booking, need),
          need,
          fits: Boolean(need && capacity && leg && capacity.budget.fits(need, leg)),
        };
      }),
      accepted: accepted.map((booking) => {
        const need = capacity?.needFor(booking) ?? null;
        return { ...this.mapBooking(booking, need), need };
      }),
    };
  }

  /**
   * Accept selected waiting intercity bookings onto this train, in the given
   * order, each re-checked against the shrinking capacity budget. Commercial
   * bookings open a pay window (payment → allocation runs on the existing
   * settle lifecycle); government bookings allocate immediately.
   */
  async acceptBookings(scheduleId: string, bookingIds: string[]) {
    if (bookingIds.length === 0) {
      throw new BadRequestException('Select at least one intercity booking');
    }
    const schedule = await this.getSchedule(scheduleId);
    const milestoneSeq = await this.routeMilestoneSequence(schedule);
    if (!milestoneSeq) {
      throw new BadRequestException(
        'Schedule has no route milestones — cannot serve intercity corridors',
      );
    }
    const capacity = await this.bookingBatchService.intercityCapacity(scheduleId);
    if (!capacity) {
      throw new BadRequestException(
        'Schedule has no locomotive/train set — capacity unknown',
      );
    }

    const accepted: string[] = [];
    const rejected: Array<{ bookingId: string; reason: string }> = [];
    const budget = capacity.budget;

    for (const bookingId of bookingIds) {
      const booking = await this.dataSource
        .getRepository(Booking)
        .findOne({
          where: { id: bookingId },
          relations: {
            bookingContainers: { containerType: true },
            cargoType: true,
          },
        });
      if (!booking) {
        rejected.push({ bookingId, reason: 'Booking not found' });
        continue;
      }
      const notWaiting = this.whyNotWaiting(booking, milestoneSeq);
      if (notWaiting) {
        rejected.push({ bookingId, reason: notWaiting });
        continue;
      }
      const need = capacity.needFor(booking);
      // legForYards: charge only the edges this booking rides, so it can still
      // board a train that is full only on other legs.
      const leg = budget.legForYards(booking.originYardId, booking.destinationYardId);
      if (!budget.fits(need, leg)) {
        rejected.push({
          bookingId,
          reason:
            'Does not fit the remaining wagon/weight/length capacity for this train',
        });
        continue;
      }
      await this.bookingBatchService.acceptIntercity(booking, scheduleId);
      budget.subtract(need, leg);
      accepted.push(bookingId);
      this.logger.log(
        `Intercity booking ${booking.reference ?? bookingId} accepted onto schedule ${scheduleId}`,
      );
    }

    return { accepted, rejected, remaining: budget.maxRemaining() };
  }

  /**
   * Mark an accepted intercity booking's cargo as loaded. Delegates to the
   * shared per-booking journey flow (same checkpoint gating as import/export).
   */
  async loadBooking(scheduleId: string, bookingId: string) {
    await this.getAcceptedBooking(scheduleId, bookingId); // intercity-only guard
    return this.bookingJourneyService.loadBooking(scheduleId, bookingId);
  }

  /**
   * Mark an intercity booking's cargo as unloaded at its destination yard —
   * requires the latest checkpoint to be at that yard. Completes the booking.
   */
  async unloadBooking(scheduleId: string, bookingId: string) {
    await this.getAcceptedBooking(scheduleId, bookingId); // intercity-only guard
    return this.bookingJourneyService.unloadBooking(scheduleId, bookingId);
  }

  // ---- helpers ---------------------------------------------------------------

  private async getSchedule(scheduleId: string): Promise<TrainSchedule> {
    const schedule = await this.dataSource
      .getRepository(TrainSchedule)
      .findOne({ where: { id: scheduleId } });
    if (!schedule) {
      throw new NotFoundException(`Train schedule ${scheduleId} not found`);
    }
    return schedule;
  }

  /**
   * yardId → sequenceNo for the schedule's route. Falls back to a two-stop
   * origin/destination pseudo-route for legacy schedules without a routeId,
   * so an intercity booking exactly matching the train's own corridor still
   * qualifies.
   */
  private async routeMilestones(
    schedule: TrainSchedule,
  ): Promise<RouteMilestone[]> {
    if (!schedule.routeId) return [];
    return this.dataSource
      .getRepository(RouteMilestone)
      .find({ where: { routeId: schedule.routeId }, order: { sequenceNo: 'ASC' } });
  }

  private milestoneSequenceOf(
    schedule: TrainSchedule,
    milestones: RouteMilestone[],
  ): Map<string, number> | null {
    if (milestones.length >= 2) {
      return new Map(milestones.map((m) => [m.yardId, m.sequenceNo]));
    }
    if (schedule.originStationId && schedule.destinationStationId) {
      return new Map([
        [schedule.originStationId, 1],
        [schedule.destinationStationId, 2],
      ]);
    }
    return null;
  }

  private async routeMilestoneSequence(
    schedule: TrainSchedule,
  ): Promise<Map<string, number> | null> {
    return this.milestoneSequenceOf(
      schedule,
      await this.routeMilestones(schedule),
    );
  }

  /** Waiting = ready intercity bookings not yet on any train, corridor on this route. */
  private async findWaitingIntercityBookings(
    milestoneSeq: Map<string, number>,
  ): Promise<Booking[]> {
    const pool = await this.dataSource
      .getRepository(Booking)
      .createQueryBuilder('booking')
      .leftJoinAndSelect('booking.company', 'company')
      .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
      .leftJoinAndSelect('bookingContainer.containerType', 'containerType')
      .leftJoinAndSelect('booking.cargoType', 'cargoType')
      .leftJoinAndSelect('booking.originYard', 'originYard')
      .leftJoinAndSelect('booking.destinationYard', 'destinationYard')
      .where(`booking.trade_direction = 'DOMESTIC'`)
      .andWhere('booking.train_schedule_id IS NULL')
      .andWhere(
        `((booking.is_government = false AND booking.status = 'FULLY_EXECUTED')
          OR (booking.is_government = true AND booking.status = 'APPROVED'))`,
      )
      .orderBy('booking.is_government', 'DESC')
      .addOrderBy('booking.priority_score', 'DESC')
      .addOrderBy('booking.created_at', 'ASC')
      .getMany();

    return pool.filter((b) => this.corridorOnRoute(b, milestoneSeq));
  }

  /** Intercity bookings already reserved/allocated on this schedule. */
  private async findAcceptedIntercityBookings(
    scheduleId: string,
  ): Promise<Booking[]> {
    return this.dataSource
      .getRepository(Booking)
      .createQueryBuilder('booking')
      .leftJoinAndSelect('booking.company', 'company')
      .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
      .leftJoinAndSelect('bookingContainer.containerType', 'containerType')
      .leftJoinAndSelect('booking.cargoType', 'cargoType')
      .leftJoinAndSelect('booking.originYard', 'originYard')
      .leftJoinAndSelect('booking.destinationYard', 'destinationYard')
      .where(`booking.trade_direction = 'DOMESTIC'`)
      .andWhere('booking.train_schedule_id = :scheduleId', { scheduleId })
      .orderBy('booking.created_at', 'ASC')
      .getMany();
  }

  private corridorOnRoute(
    booking: Booking,
    milestoneSeq: Map<string, number>,
  ): boolean {
    const originSeq = milestoneSeq.get(booking.originYardId);
    const destinationSeq = milestoneSeq.get(booking.destinationYardId);
    return (
      originSeq != null && destinationSeq != null && originSeq < destinationSeq
    );
  }

  private whyNotWaiting(
    booking: Booking,
    milestoneSeq: Map<string, number>,
  ): string | null {
    if (booking.tradeDirection !== 'DOMESTIC') {
      return 'Not an intercity booking';
    }
    if (booking.trainScheduleId) {
      return 'Already assigned to a train';
    }
    const readyStatus = booking.isGovernment ? 'APPROVED' : 'FULLY_EXECUTED';
    if (booking.status !== readyStatus) {
      return `Not ready to board (status ${booking.status})`;
    }
    if (!this.corridorOnRoute(booking, milestoneSeq)) {
      return "Corridor is not on this schedule's route";
    }
    return null;
  }

  private async getAcceptedBooking(scheduleId: string, bookingId: string) {
    const schedule = await this.getSchedule(scheduleId);
    const booking = await this.dataSource
      .getRepository(Booking)
      .findOne({ where: { id: bookingId } });
    if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
    if (booking.trainScheduleId !== scheduleId) {
      throw new BadRequestException('Booking is not assigned to this schedule');
    }
    if (booking.tradeDirection !== 'DOMESTIC') {
      throw new BadRequestException('Not an intercity booking');
    }
    return { schedule, booking };
  }

  /**
   * `need` carries the GROSS weight (cargo + wagon tare) the capacity budget is
   * spent in. Prefer it, so the row's weight sits on the same axis as the
   * remaining-capacity figure shown beside it; cargo VGM is the fallback.
   */
  private mapBooking(booking: Booking, need?: { weightTons: number } | null) {
    return {
      id: booking.id,
      reference: booking.reference,
      status: booking.status,
      freightType: booking.freightType,
      isGovernment: booking.isGovernment,
      customer: booking.company?.name ?? 'Unknown customer',
      originYardId: booking.originYardId,
      destinationYardId: booking.destinationYardId,
      origin:
        booking.originYard?.label ?? booking.originYard?.code ?? 'Unknown origin',
      destination:
        booking.destinationYard?.label ??
        booking.destinationYard?.code ??
        'Unknown destination',
      weightTons: need?.weightTons ?? Number(booking.cargoTotalWeightVgm ?? 0),
      paymentDeadline: booking.paymentDeadline?.toISOString() ?? null,
    };
  }
}

