import {
  BadRequestException,
  Injectable,
  Logger,
  NotFoundException,
  Optional,
} from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource, EntityManager, In } from 'typeorm';
import { Freight } from '@edr/types';

import { YardFacilitiesService } from '../rule-engine/services/yard-facilities.service';
import { FacilityHandlingService } from './facility-handling.service';
import { Booking } from '../bookings/entities/booking.entity';
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
import { Yard } from '../rule-engine/entities/yard.entity';
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity';
import { Wagon } from '../wagons/entities/wagon.entity';
import { WagonMovement } from '../wagons/entities/wagon-movement.entity';
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
import { assertExportReceivedWithGrn } from '../../common/export-received-gate';

/**
 * Per-booking journey along a train's corridor — for EVERY trade direction.
 *
 * A booking rides only its own origin→destination leg, so "dispatched" and
 * "arrived" are per-booking facts confirmed by the yard operator, not train
 * facts: load at the booking's origin yard (PAID → IN_TRANSIT, loadedAt) and
 * unload at its destination yard (IN_TRANSIT → ARRIVED for import/export,
 * → COMPLETED for intercity), possibly long before the train's final arrival.
 * Both are gated on the train's latest recorded checkpoint being at that yard.
 * Unload also fires automatically: recording a checkpoint at a yard auto-
 * unloads every booking destined there (autoUnloadAtYard), so the manual
 * unload endpoint remains only a fallback.
 *
 * Unloading also settles the physical wagons: each wagon that alights with the
 * booking is released at that yard and the move is written to the
 * wagon_movements ledger.
 */
@Injectable()
export class BookingJourneyService {
  private readonly logger = new Logger(BookingJourneyService.name);

  constructor(
    @InjectDataSource() private readonly dataSource: DataSource,
    private readonly yardFacilities: YardFacilitiesService,
    private readonly facilityHandling: FacilityHandlingService,
    @Optional() private readonly milestoneService?: ClearanceMilestoneService,
  ) {}

  /** Statuses from which a booking may be loaded (gov bookings don't prepay). */
  private canLoad(booking: Booking): boolean {
    if (booking.status === 'PAID') return true;
    return booking.isGovernment && booking.status === 'APPROVED';
  }

  async loadBooking(scheduleId: string, bookingId: string, userId?: string | null) {
    const { schedule, booking } = await this.getScheduleBooking(scheduleId, bookingId);
    if (booking.loadedAt || booking.status === 'IN_TRANSIT') {
      throw new BadRequestException('Booking is already loaded');
    }
    if (!this.canLoad(booking)) {
      throw new BadRequestException(
        `Booking must be paid before loading (currently ${booking.status})`,
      );
    }
    await this.assertTrainAtYard(schedule, booking.originYardId, 'origin');
    await this.assertYardCanHandleCargo(booking, booking.originYardId, 'origin');
    // Export cargo must be in the warehouse with a GRN before it can be loaded,
    // however it arrived and whatever it is allocated to.
    await assertExportReceivedWithGrn(this.dataSource, booking);

    const now = new Date();
    await this.dataSource.transaction(async (manager) => {
      await manager.getRepository(Booking).update(bookingId, {
        status: 'IN_TRANSIT',
        loadedAt: now,
        loadedByUserId: userId ?? null,
      } as never);
      await this.setAllocationStatuses(manager, scheduleId, bookingId, 'LOADED');
      // The facility handed the cargo over — raise its GRN. No-ops for yards
      // without a facility (import/export terminals), which keep their own flow.
      await this.facilityHandling.recordHandling(manager, {
        booking,
        yardId: booking.originYardId,
        trainScheduleId: scheduleId,
        eventType: 'LOAD',
        performedBy: userId ?? null,
        occurredAt: now,
      });
    });

    // Customer tracking: cargo is on the train — loading milestones plus the
    // direction's "departed" handoff. Doc-trigger path no-ops non-customs
    // bookings (intercity) and already-completed codes.
    void this.completeMilestones(booking, [
      'CARGO_ARRIVED',
      'READY_FOR_LOADING',
      'LOADED',
      ...(booking.tradeDirection === 'IMPORT'
        ? ['DEPARTED_FROM_DJIBOUTI']
        : booking.tradeDirection === 'EXPORT'
          ? ['DEPARTED_TO_DJIBOUTI']
          : []),
    ]);

    return { bookingId, status: 'IN_TRANSIT' as const, loadedAt: now.toISOString() };
  }

  async unloadBooking(scheduleId: string, bookingId: string, userId?: string | null) {
    const { schedule, booking } = await this.getScheduleBooking(scheduleId, bookingId);
    if (booking.status !== 'IN_TRANSIT') {
      throw new BadRequestException(
        `Booking must be loaded/in transit before unloading (currently ${booking.status})`,
      );
    }
    await this.assertTrainAtYard(schedule, booking.destinationYardId, 'destination');
    await this.assertYardCanHandleCargo(booking, booking.destinationYardId, 'destination');

    // Intercity has no clearance/delivery tail — unloading completes it. Import/
    // export continue into clearance, keyed on the booking's own arrival.
    const nextStatus = booking.tradeDirection === 'DOMESTIC' ? 'COMPLETED' : 'ARRIVED';
    const now = new Date();
    await this.dataSource.transaction(async (manager) => {
      await manager.getRepository(Booking).update(bookingId, {
        status: nextStatus,
        arrivedAt: now,
        arrivedByUserId: userId ?? null,
      } as never);
      await this.setAllocationStatuses(manager, scheduleId, bookingId, 'DEPARTED');
      await this.settleWagonsOnUnload(manager, schedule, booking, now, userId ?? null);
      // The facility took the cargo off the train — raise its GRN. Where the
      // facility also stores cargo (Indode), the event links the storage record
      // that storage/demurrage accrue against.
      await this.facilityHandling.recordHandling(manager, {
        booking,
        yardId: booking.destinationYardId,
        trainScheduleId: scheduleId,
        eventType: 'UNLOAD',
        performedBy: userId ?? null,
        occurredAt: now,
      });
    });

    // Customer tracking: THIS booking arrived (train may still be rolling).
    void this.completeMilestones(booking, [
      ...(booking.tradeDirection === 'IMPORT'
        ? ['ARRIVED_ETHIOPIA']
        : booking.tradeDirection === 'EXPORT'
          ? ['ARRIVED_AT_DJIBOUTI']
          : []),
    ]);

    return { bookingId, status: nextStatus, arrivedAt: now.toISOString() };
  }

  /**
   * Per-yard operator worklist for a schedule: which bookings board / alight at
   * each stop, with their journey state, so the yard operator at Dire sees
   * exactly what to load and unload when the train is there.
   */
  async listYardWork(scheduleId: string) {
    const schedule = await this.getSchedule(scheduleId);
    const bookings = await this.dataSource
      .getRepository(Booking)
      .createQueryBuilder('booking')
      .leftJoinAndSelect('booking.company', 'company')
      .leftJoinAndSelect('booking.originYard', 'originYard')
      .leftJoinAndSelect('booking.destinationYard', 'destinationYard')
      .innerJoin(
        TrainScheduleBooking,
        'tsb',
        'tsb.booking_id = booking.id AND tsb.train_schedule_id = :scheduleId AND tsb.deleted_at IS NULL',
        { scheduleId },
      )
      .getMany();

    const latest = await this.latestCheckpoint(scheduleId);
    const yardIds = [
      ...new Set(
        bookings.flatMap((b) => [b.originYardId, b.destinationYardId]).filter(Boolean),
      ),
    ];
    const yards = yardIds.length
      ? await this.dataSource.getRepository(Yard).find({ where: { id: In(yardIds) } })
      : [];
    const yardById = new Map(yards.map((y) => [y.id, y]));
    const yardLabel = (id: string) =>
      yardById.get(id)?.label ?? yardById.get(id)?.code ?? id;

    const mapBooking = (b: Booking) => ({
      id: b.id,
      reference: b.reference,
      status: b.status,
      tradeDirection: b.tradeDirection,
      isGovernment: b.isGovernment,
      customer: b.company?.name ?? 'Unknown customer',
      originYardId: b.originYardId,
      destinationYardId: b.destinationYardId,
      origin: yardLabel(b.originYardId),
      destination: yardLabel(b.destinationYardId),
      loadedAt: b.loadedAt?.toISOString() ?? null,
      arrivedAt: b.arrivedAt?.toISOString() ?? null,
      canLoad: !b.loadedAt && this.canLoad(b),
      canUnload: b.status === 'IN_TRANSIT',
    });

    const byYard = new Map<
      string,
      { yardId: string; yard: string; toLoad: ReturnType<typeof mapBooking>[]; toUnload: ReturnType<typeof mapBooking>[] }
    >();
    const bucket = (yardId: string) => {
      let entry = byYard.get(yardId);
      if (!entry) {
        entry = { yardId, yard: yardLabel(yardId), toLoad: [], toUnload: [] };
        byYard.set(yardId, entry);
      }
      return entry;
    };
    for (const b of bookings) {
      bucket(b.originYardId).toLoad.push(mapBooking(b));
      bucket(b.destinationYardId).toUnload.push(mapBooking(b));
    }

    return {
      scheduleId,
      scheduleStatus: schedule.status,
      trainAtYardId: latest?.yardId ?? (schedule.status === 'DISPATCHED' ? null : schedule.originStationId),
      yards: [...byYard.values()],
    };
  }

  /**
   * Auto-unload on checkpoint: every IN_TRANSIT booking on this schedule whose
   * destination is the yard the train just reached alights automatically, so
   * the customer's booking flips to ARRIVED (COMPLETED for intercity) the
   * moment the train is recorded at their yard — no separate operator unload.
   * Runs through the same per-booking unload path (wagon settle + ledger +
   * milestones); one booking's failure is logged and never blocks the
   * checkpoint or the other bookings. Returns the unloaded booking ids.
   */
  async autoUnloadAtYard(
    scheduleId: string,
    yardId: string,
    userId?: string | null,
  ): Promise<string[]> {
    const bookings = await this.dataSource
      .getRepository(Booking)
      .createQueryBuilder('booking')
      // Entity-class join: a raw 'freight.table' string is parsed by TypeORM as
      // an alias.property path ("freight" alias was not found) — runtime 500.
      .innerJoin(
        TrainScheduleBooking,
        'tsb',
        'tsb.booking_id = booking.id AND tsb.train_schedule_id = :scheduleId AND tsb.deleted_at IS NULL',
        { scheduleId },
      )
      .where('booking.destination_yard_id = :yardId', { yardId })
      .andWhere(`booking.status = 'IN_TRANSIT'`)
      .getMany();

    const unloaded: string[] = [];
    for (const booking of bookings) {
      try {
        await this.unloadBooking(scheduleId, booking.id, userId);
        unloaded.push(booking.id);
      } catch (err) {
        this.logger.warn(
          `Auto-unload failed for booking ${booking.id} at yard ${yardId}: ${(err as Error).message}`,
        );
      }
    }
    return unloaded;
  }

  /**
   * Bulk fallback at the train's FINAL arrival: any booking destined for the
   * final yard that operators didn't unload individually gets its per-booking
   * arrival stamped now, so nothing stays stuck. Mid-corridor bookings are NOT
   * touched — their arrival is their own unload. Returns the affected ids.
   */
  async autoArriveAtFinalYard(
    manager: EntityManager,
    schedule: TrainSchedule,
    now: Date,
  ): Promise<string[]> {
    const rows: Array<{ id: string; trade_direction: string }> = await manager.query(
      `UPDATE freight.bookings b
          SET status = CASE WHEN b.trade_direction = 'DOMESTIC' THEN 'COMPLETED' ELSE 'ARRIVED' END,
              scheduling_status = 'DISPATCHED',
              arrived_at = COALESCE(b.arrived_at, $3),
              loaded_at = COALESCE(b.loaded_at, b.created_at)
         FROM freight.train_schedule_bookings tsb
        WHERE tsb.booking_id = b.id
          AND tsb.train_schedule_id = $1
          AND tsb.deleted_at IS NULL
          AND b.deleted_at IS NULL
          AND b.destination_yard_id = $2
          AND b.status NOT IN ('DRAFT', 'REJECTED', 'CANCELLED', 'COMPLETED', 'ARRIVED', 'DELIVERED')
        RETURNING b.id, b.trade_direction`,
      [schedule.id, schedule.destinationStationId, now],
    );
    return rows.map((r) => r.id);
  }

  // ---- 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;
  }

  private async getScheduleBooking(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');
    }
    return { schedule, booking };
  }

  private async latestCheckpoint(scheduleId: string): Promise<TrainCheckpointEvent | null> {
    return this.dataSource.getRepository(TrainCheckpointEvent).findOne({
      where: { trainScheduleId: scheduleId },
      order: { occurredAt: 'DESC', createdAt: 'DESC' },
    });
  }

  /**
   * INTERCITY ONLY. Intercity cargo rides a passing train and is handled at the
   * booking's own yards, so those yards need the equipment to do it — a train
   * stopping somewhere is not the same as somewhere being able to load it.
   *
   * Import/export are untouched: their cargo is handled at the route's terminal
   * ports, not at an arbitrary mid-corridor yard, and gating them here would
   * block existing traffic.
   *
   * Lives here rather than in the controller so the checkpoint-driven
   * autoUnloadAtYard path cannot route around it.
   */
  private async assertYardCanHandleCargo(
    booking: Booking,
    yardId: string,
    side: 'origin' | 'destination',
  ): Promise<void> {
    if (booking.tradeDirection !== 'DOMESTIC') return;
    const facility = await this.yardFacilities.facilityForYard(yardId);
    const where = side === 'origin' ? 'loaded at its origin' : 'unloaded at its destination';

    if (!facility?.hasFacility) {
      throw new BadRequestException(
        `${facility?.yardLabel ?? 'This yard'} has no load/unload facility — an intercity booking cannot be ${where} here.`,
      );
    }
    // A facility only handles what its equipment can lift: containers need a
    // reach stacker/gantry, bulk does not.
    if (!this.yardFacilities.canHandleFreight(facility, booking.freightType)) {
      throw new BadRequestException(
        `${facility.yardLabel ?? 'This yard'} does not handle ${String(booking.freightType).toLowerCase()} cargo — ` +
          `an intercity booking cannot be ${where} here.`,
      );
    }
  }

  /**
   * The train is "at" a yard when the latest recorded checkpoint is that yard,
   * or — for a booking boarding at the train's own origin — when the train has
   * not recorded any checkpoint yet (still sitting at its origin).
   */
  private async assertTrainAtYard(
    schedule: TrainSchedule,
    yardId: string,
    side: 'origin' | 'destination',
  ): Promise<void> {
    const latest = await this.latestCheckpoint(schedule.id);
    if (!latest) {
      if (side === 'origin' && schedule.originStationId === yardId) return;
      throw new BadRequestException(
        'Train has not reached this yard yet — record its checkpoint first',
      );
    }
    if (latest.yardId !== yardId) {
      throw new BadRequestException(
        `Train's last recorded position is not at the booking's ${side} yard`,
      );
    }
  }

  private async setAllocationStatuses(
    manager: EntityManager,
    scheduleId: string,
    bookingId: string,
    status: 'LOADED' | 'DEPARTED',
  ): Promise<void> {
    const allocations = await this.allocationsForBooking(manager, scheduleId, bookingId);
    if (!allocations.length) return;
    await manager
      .getRepository(WagonBookingAllocation)
      .update({ id: In(allocations.map((a) => a.id)) }, { status });
  }

  private async allocationsForBooking(
    manager: EntityManager,
    scheduleId: string,
    bookingId: string,
  ): Promise<Array<WagonBookingAllocation & { trainSetWagon?: TrainSetWagon }>> {
    return manager
      .getRepository(WagonBookingAllocation)
      .createQueryBuilder('alloc')
      .innerJoinAndSelect('alloc.trainSetWagon', 'slot')
      .innerJoin(
        TrainSchedule,
        'schedule',
        'schedule.train_set_id = slot.train_set_id AND schedule.id = :scheduleId',
        { scheduleId },
      )
      .where('alloc.booking_id = :bookingId', { bookingId })
      .getMany();
  }

  /**
   * On unload: write the wagon_movements ledger rows (board yard → unload yard,
   * kind LOADED) for the booking's pinned wagons, and release each wagon whose
   * slot alights here — it detaches, stays at this yard, and becomes Available
   * (dynamic consist). Wagons shared with a still-loaded consolidated partner
   * stay pinned until the last booking on the slot unloads.
   */
  private async settleWagonsOnUnload(
    manager: EntityManager,
    schedule: TrainSchedule,
    booking: Booking,
    now: Date,
    userId: string | null,
  ): Promise<void> {
    const allocations = await this.allocationsForBooking(manager, schedule.id, booking.id);
    for (const alloc of allocations) {
      const slot = alloc.trainSetWagon;
      if (!slot?.physicalWagonId) continue;

      const boardYardId = slot.boardYardId ?? schedule.originStationId;
      await manager.getRepository(WagonMovement).save(
        manager.getRepository(WagonMovement).create({
          wagonId: slot.physicalWagonId,
          fromYardId: boardYardId,
          toYardId: booking.destinationYardId,
          trainScheduleId: schedule.id,
          bookingId: booking.id,
          kind: Freight.WagonMovementKind.Loaded,
          movedByUserId: userId,
          occurredAt: now,
        }),
      );

      // Detach only when this yard is where the slot's leg ends and no other
      // booking on the wagon is still in transit.
      const slotAlightYardId = slot.alightYardId ?? schedule.destinationStationId;
      if (slotAlightYardId !== booking.destinationYardId) continue;
      const siblings = await manager
        .getRepository(WagonBookingAllocation)
        .createQueryBuilder('alloc')
        .innerJoin('alloc.booking', 'b')
        .where('alloc.train_set_wagon_id = :slotId', { slotId: slot.id })
        .andWhere('alloc.booking_id != :bookingId', { bookingId: booking.id })
        .andWhere(`b.status = 'IN_TRANSIT'`)
        .getCount();
      if (siblings > 0) continue;

      await manager.getRepository(TrainSetWagon).update(slot.id, { status: 'DEPARTED' });
      const wagon = await manager
        .getRepository(Wagon)
        .findOne({ where: { id: slot.physicalWagonId } });
      // Only settle a wagon still bound to this schedule (it may have been
      // re-pinned elsewhere already).
      if (wagon && wagon.currentTrainScheduleId === schedule.id) {
        await manager.getRepository(Wagon).update(wagon.id, {
          currentYardId: booking.destinationYardId,
          currentTrainScheduleId: null,
          trainSetWagonId: null,
          status: Freight.WagonStatus.Available,
        });
      }
    }
  }

  private async completeMilestones(booking: Booking, codes: string[]): Promise<void> {
    if (!this.milestoneService || !codes.length) return;
    for (const code of codes) {
      try {
        await this.milestoneService.completeByDocTrigger({ bookingId: booking.id }, code);
      } catch (err) {
        this.logger.warn(
          `Milestone ${code} completion failed for booking ${booking.id}: ${(err as Error).message}`,
        );
      }
    }
  }
}
