import { Injectable, NotFoundException } from "@nestjs/common";
import { PrismaService } from "../../common/prisma.service";
import {
  SearchTripsDto,
  FareQuoteDto,
  FareBreakdownRequestDto,
  FareBreakdownPassengerDto,
} from "./search.dto";
import { CurrencyService } from "../currency/currency.service";
import { FareEngineService } from "../fare-engine/fare-engine.service";
import { SegmentsService } from "../segments/segments.service";
import { resolveCurrencyFromNationality } from "../fare-engine/fare-engine.dto";
import { resolveCheckinCutoff } from "../../common/utils/checkin-cutoff.utils";
import { Currency, Prisma } from "@prisma/client";
import { Passenger } from "@edr/types";

const POINTS_TO_MINOR = 10;

// Shape returned by the heavy schedule include used throughout this service
type ScheduleWithIncludes = {
  id: string;
  routeId: string | null;
  departureAt: Date;
  arrivalAt: Date;
  status: string;
  train: any;
  originStation: any;
  destinationStation: any;
  route: {
    checkinMinutesBefore: number;
    stops: Array<{ stationId: string; checkinMinutesBefore: number | null }>;
  } | null;
  stopTimes: Array<{
    stationId: string;
    sequence: number;
    plannedArrivalAt: Date | null;
    plannedDepartureAt: Date | null;
    station: any;
  }>;
  coachAssignments: Array<{
    coach: {
      id: string;
      seats: any[];
      coachType: {
        id: string;
        name: string;
        code: string;
        seatClasses: any[];
      } | null;
    };
  }>;
};

const SCHEDULE_INCLUDE = {
  train: true,
  originStation: true,
  destinationStation: true,
  route: {
    select: {
      checkinMinutesBefore: true,
      stops: { select: { stationId: true, checkinMinutesBefore: true } },
    },
  },
  stopTimes: { include: { station: true }, orderBy: { sequence: "asc" } },
  coachAssignments: {
    include: {
      coach: {
        include: { seats: true, coachType: { include: { seatClasses: true } } },
      },
    },
  },
} as const;

@Injectable()
export class SearchService {
  constructor(
    private prisma: PrismaService,
    private currencyService: CurrencyService,
    private fareEngine: FareEngineService,
    private segmentsService: SegmentsService,
  ) {}

  async searchTrips(dto: SearchTripsDto) {
    const [direct, transit] = await Promise.all([
      this.searchSchedules(
        dto.originStationId,
        dto.destinationStationId,
        dto.date,
        dto.adultCount,
        dto.childCount,
        dto.nationality,
      ),
      this.searchTransitOptions(
        dto.originStationId,
        dto.destinationStationId,
        dto.date,
        dto.adultCount,
        dto.childCount,
        dto.nationality,
      ),
    ]);

    const outbound = [...direct, ...transit];

    if (outbound.length === 0 && dto.journeyType !== "ROUND_TRIP") {
      const [alternativesOutbound, outboundReason] = await Promise.all([
        this.searchAlternatives(
          dto.originStationId,
          dto.destinationStationId,
          dto.date,
          dto.adultCount,
          dto.childCount,
          dto.nationality,
        ),
        this.classifyEmptySearch(dto.originStationId, dto.destinationStationId, dto.date),
      ]);
      return {
        journeyType: "ONE_WAY",
        outbound: [],
        alternativeOutbound: alternativesOutbound,
        requestedDate: dto.date,
        outboundReason,
      };
    }

    if (dto.journeyType === "ROUND_TRIP") {
      const [returnDirect, returnTransit] = await Promise.all([
        this.searchSchedules(
          dto.destinationStationId,
          dto.originStationId,
          dto.returnDate ?? dto.date,
          dto.adultCount,
          dto.childCount,
          dto.nationality,
        ),
        this.searchTransitOptions(
          dto.destinationStationId,
          dto.originStationId,
          dto.returnDate ?? dto.date,
          dto.adultCount,
          dto.childCount,
          dto.nationality,
        ),
      ]);

      const allReturn = [...returnDirect, ...returnTransit];
      const latestOutboundArrival =
        outbound.length > 0
          ? Math.max(
              ...outbound.map((s: any) =>
                new Date(s.arrivalAt ?? s.leg2?.arrivalAt).getTime(),
              ),
            )
          : Date.now();

      const inbound = allReturn.filter(
        (s: any) =>
          new Date(s.departureAt ?? s.leg1?.departureAt).getTime() >
          latestOutboundArrival,
      );

      const returnDate = dto.returnDate ?? dto.date;

      if (outbound.length === 0 || inbound.length === 0) {
        const [alternativeOutbound, alternativeInbound, outboundReason, inboundReason] = await Promise.all([
          outbound.length === 0
            ? this.searchAlternatives(
                dto.originStationId,
                dto.destinationStationId,
                dto.date,
                dto.adultCount,
                dto.childCount,
                dto.nationality,
              )
            : Promise.resolve([]),
          inbound.length === 0
            ? this.searchAlternatives(
                dto.destinationStationId,
                dto.originStationId,
                returnDate,
                dto.adultCount,
                dto.childCount,
                dto.nationality,
              )
            : Promise.resolve([]),
          outbound.length === 0
            ? this.classifyEmptySearch(dto.originStationId, dto.destinationStationId, dto.date)
            : Promise.resolve(undefined),
          inbound.length === 0
            ? this.classifyEmptySearch(dto.destinationStationId, dto.originStationId, returnDate)
            : Promise.resolve(undefined),
        ]);
        return {
          journeyType: "ROUND_TRIP",
          outbound,
          inbound,
          alternativeOutbound,
          alternativeInbound,
          requestedDate: dto.date,
          requestedReturnDate: returnDate,
          outboundReason,
          inboundReason,
        };
      }

      return {
        journeyType: "ROUND_TRIP",
        outbound,
        inbound,
        requestedDate: dto.date,
        requestedReturnDate: returnDate,
      };
    }

    return { journeyType: "ONE_WAY", outbound };
  }

  private async searchAlternatives(
    originStationId: string,
    destinationStationId: string,
    dateStr: string,
    adultCount: number,
    childCount?: number,
    nationality?: string,
  ) {
    const [y, m, d] = dateStr.split("-").map(Number);
    const requestedDate = new Date(
      `${String(y)}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}T00:00:00+03:00`,
    );
    const requestedNextDay = new Date(
      `${String(y)}-${String(m).padStart(2, "0")}-${String(d + 1).padStart(2, "0")}T00:00:00+03:00`,
    );
    const now = new Date();
    const totalPassengers = adultCount + (childCount ?? 0);
    const NEEDED = 3;

    // Include BOARDING alongside SCHEDULED: BOARDING is just an operational display status the
    // schedule-level cron sets on a fixed 30-min-before-departure timer (see tasks.service.ts) —
    // it does NOT mean booking is closed. The actual booking cutoff is per-stop and configurable
    // (RouteStop/Route.checkinMinutesBefore), enforced below by buildScheduleResult's own live
    // check against each stop's estimated arrival/departure. Excluding BOARDING here would
    // silently impose a hidden, non-configurable 30-minute cutoff on top of that.
    const baseWhere: Prisma.TrainScheduleWhereInput = {
      status: { in: ["SCHEDULED", "BOARDING", "EN_ROUTE"] },
      isPackageOnly: false,
      stopTimes: { some: { stationId: originStationId } },
      coachAssignments: { some: {} },
    };

    // Fetch candidates before and after in parallel; take more than needed to
    // account for routes that don't serve the destination or have no availability.
    const FETCH_LIMIT = NEEDED * 5;

    const [beforeCandidates, afterCandidates] = await Promise.all([
      this.prisma.trainSchedule.findMany({
        where: {
          ...baseWhere,
          departureAt: {
            gte: now < requestedDate ? now : new Date(0),
            lt: requestedDate,
          },
        },
        include: SCHEDULE_INCLUDE,
        orderBy: { departureAt: "desc" },
        take: FETCH_LIMIT,
      }),
      this.prisma.trainSchedule.findMany({
        where: {
          ...baseWhere,
          departureAt: { gte: requestedNextDay > now ? requestedNextDay : now },
        },
        include: SCHEDULE_INCLUDE,
        orderBy: { departureAt: "asc" },
        take: FETCH_LIMIT,
      }),
    ]);

    const pickN = async (
      candidates: typeof beforeCandidates,
      limit: number,
    ) => {
      const out: NonNullable<
        Awaited<ReturnType<typeof this.buildScheduleResult>>
      >[] = [];
      for (const schedule of candidates) {
        if (out.length >= limit) break;
        const r = await this.buildScheduleResult(
          schedule as any,
          originStationId,
          destinationStationId,
          totalPassengers,
          nationality,
        );
        if (r?.hasAvailability) out.push(r);
      }
      return out;
    };

    const [before, after] = await Promise.all([
      pickN(beforeCandidates, NEEDED),
      pickN(afterCandidates, NEEDED),
    ]);

    // before was fetched desc (closest first); reverse so result is chronological
    return [...before.reverse(), ...after];
  }

  private async searchSchedules(
    originStationId: string,
    destinationStationId: string,
    dateStr: string,
    adultCount: number,
    childCount?: number,
    nationality?: string,
  ) {
    const [y, m, d] = dateStr.split("-").map(Number);
    const date = new Date(
      `${String(y)}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}T00:00:00+03:00`,
    );
    const nextDay = new Date(
      `${String(y)}-${String(m).padStart(2, "0")}-${String(d + 1).padStart(2, "0")}T00:00:00+03:00`,
    );
    const totalPassengers = adultCount + (childCount ?? 0);

    // Match on the schedule's own departure DATE only — do NOT use `now` as a lower bound here.
    // A schedule whose origin has already departed (EN_ROUTE) can still have a later stop (e.g.
    // Lebu, Adama) whose own cutoff hasn't passed; using the overall departureAt as a floor would
    // wrongly exclude the whole schedule for those still-bookable downstream segments. The
    // per-segment cutoff check in buildScheduleResult is the sole authority for whether THIS
    // specific origin stop is still bookable, using each stop's own estimated arrival/departure.
    const schedules = await this.prisma.trainSchedule.findMany({
      where: {
        // EN_ROUTE/BOARDING included alongside SCHEDULED — these are operational display
        // statuses, not booking-closed signals (see comment on searchAlternatives' baseWhere).
        status: { in: ["SCHEDULED", "BOARDING", "EN_ROUTE"] },
        isPackageOnly: false,
        departureAt: { gte: date, lt: nextDay },
        stopTimes: { some: { stationId: originStationId } },
        coachAssignments: { some: {} },
      },
      include: SCHEDULE_INCLUDE,
    });

    const results = await Promise.all(
      schedules.map((schedule) =>
        this.buildScheduleResult(
          schedule as any,
          originStationId,
          destinationStationId,
          totalPassengers,
          nationality,
        ),
      ),
    );
    return results.filter(
      (r): r is NonNullable<typeof r> => !!r && r.hasAvailability,
    );
  }

  /**
   * Only called when searchSchedules/searchTransitOptions found zero bookable results for a
   * leg — classifies WHY, cheaply, by re-querying without the filters that already excluded
   * everything. Priority order (most specific/actionable first): a station pair EDR never
   * connects at all beats "nothing on this exact date", which beats "something exists but
   * every option is cancelled/package-only/past cutoff/full" — see SearchEmptyReasonCode.
   */
  private async classifyEmptySearch(
    originStationId: string,
    destinationStationId: string,
    dateStr: string,
  ): Promise<Passenger.ISearchEmptyReason> {
    const [origin, destination] = await Promise.all([
      this.prisma.station.findUnique({ where: { id: originStationId }, select: { name: true } }),
      this.prisma.station.findUnique({ where: { id: destinationStationId }, select: { name: true } }),
    ]);
    const originStationName = origin?.name ?? "the origin station";
    const destinationStationName = destination?.name ?? "the destination station";
    const withCode = (code: Passenger.SearchEmptyReasonCode) => ({
      code,
      originStationName,
      destinationStationName,
    });

    // 1. Does any active route connect these two stations, in this direction, at all —
    // ignoring date entirely?
    const candidateRoutes = await this.prisma.route.findMany({
      where: { active: true, stops: { some: { stationId: originStationId } } },
      select: { stops: { select: { stationId: true, sequence: true } } },
    });
    const routeExists = candidateRoutes.some((r) => {
      const o = r.stops.find((s) => s.stationId === originStationId);
      const d = r.stops.find((s) => s.stationId === destinationStationId);
      return !!o && !!d && o.sequence < d.sequence;
    });
    if (!routeExists) return withCode(Passenger.SearchEmptyReasonCode.NoRoute);

    // 2. A route exists — is there any schedule at all on the requested date for this pair
    // (regardless of status/package/coach/cutoff — those are checked next)?
    const [y, m, d] = dateStr.split("-").map(Number);
    const date = new Date(
      `${String(y)}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}T00:00:00+03:00`,
    );
    const nextDay = new Date(
      `${String(y)}-${String(m).padStart(2, "0")}-${String(d + 1).padStart(2, "0")}T00:00:00+03:00`,
    );
    const dayCandidates = await this.prisma.trainSchedule.findMany({
      where: { departureAt: { gte: date, lt: nextDay }, stopTimes: { some: { stationId: originStationId } } },
      select: {
        status: true,
        isPackageOnly: true,
        departureAt: true,
        route: {
          select: { checkinMinutesBefore: true, stops: { select: { stationId: true, checkinMinutesBefore: true } } },
        },
        stopTimes: { select: { stationId: true, sequence: true, plannedArrivalAt: true, plannedDepartureAt: true } },
        coachAssignments: { select: { id: true } },
      },
    });
    const sameDayForPair = dayCandidates.filter((s) => {
      const o = s.stopTimes.find((st) => st.stationId === originStationId);
      const dst = s.stopTimes.find((st) => st.stationId === destinationStationId);
      return !!o && !!dst && o.sequence < dst.sequence;
    });
    if (sameDayForPair.length === 0) return withCode(Passenger.SearchEmptyReasonCode.NoScheduleOnDate);

    // 3. Schedules exist that date — narrow to ones that would otherwise be bookable
    // (right status, not package-only, has at least one coach assigned).
    const bookable = sameDayForPair.filter(
      (s) =>
        (["SCHEDULED", "BOARDING", "EN_ROUTE"] as string[]).includes(s.status) &&
        !s.isPackageOnly &&
        s.coachAssignments.length > 0,
    );
    if (bookable.length === 0) {
      if (sameDayForPair.every((s) => s.status === "CANCELLED"))
        return withCode(Passenger.SearchEmptyReasonCode.Cancelled);
      if (sameDayForPair.every((s) => s.isPackageOnly))
        return withCode(Passenger.SearchEmptyReasonCode.PackageOnly);
      return withCode(Passenger.SearchEmptyReasonCode.NoScheduleOnDate);
    }

    // 4. Bookable schedules exist — did every one of them already pass its check-in cutoff
    // for this origin stop?
    const allCutoffPassed = bookable.every((s) => {
      const originStop = s.stopTimes.find((st) => st.stationId === originStationId) ?? null;
      return Date.now() >= resolveCheckinCutoff(s, originStop, originStationId).cutoffAt.getTime();
    });
    if (allCutoffPassed) return withCode(Passenger.SearchEmptyReasonCode.CheckinClosed);

    // 5. A bookable, still-open schedule exists for this pair/date — the only remaining reason
    // searchSchedules dropped it is zero/insufficient seat availability.
    return withCode(Passenger.SearchEmptyReasonCode.FullyBooked);
  }

  // ── Transit search ─────────────────────────────────────────────────────────
  private readonly MIN_CONNECTION_MINUTES = 30;
  private readonly MAX_CONNECTION_MINUTES = 360;

  private async searchTransitOptions(
    originStationId: string,
    destinationStationId: string,
    dateStr: string,
    adultCount: number,
    childCount?: number,
    nationality?: string,
  ) {
    const [y, m, d] = dateStr.split("-").map(Number);
    const dayStart = new Date(
      `${String(y)}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}T00:00:00+03:00`,
    );
    const dayEnd = new Date(
      `${String(y)}-${String(m).padStart(2, "0")}-${String(d + 1).padStart(2, "0")}T00:00:00+03:00`,
    );
    const leg2WindowEnd = new Date(
      dayEnd.getTime() + this.MAX_CONNECTION_MINUTES * 60_000,
    );
    const totalPassengers = adultCount + (childCount ?? 0);

    // Load leg1 and all potential leg2 candidates in one parallel round-trip
    // instead of firing a separate DB query per transit stop.
    const [leg1Schedules, allCandidates] = await Promise.all([
      this.prisma.trainSchedule.findMany({
        where: {
          // BOARDING included alongside SCHEDULED — see comment on searchAlternatives' baseWhere.
          status: { in: ["SCHEDULED", "BOARDING", "EN_ROUTE"] },
          isPackageOnly: false,
          departureAt: { gte: dayStart, lt: dayEnd },
          stopTimes: { some: { stationId: originStationId } },
          coachAssignments: { some: {} },
        },
        include: SCHEDULE_INCLUDE,
      }),
      this.prisma.trainSchedule.findMany({
        where: {
          status: { in: ["SCHEDULED", "BOARDING", "EN_ROUTE"] },
          isPackageOnly: false,
          departureAt: { gte: dayStart, lt: leg2WindowEnd },
          coachAssignments: { some: {} },
        },
        include: SCHEDULE_INCLUDE,
      }),
    ]);

    const results: any[] = [];

    for (const leg1 of leg1Schedules as ScheduleWithIncludes[]) {
      const originStop = leg1.stopTimes.find(
        (s) => s.stationId === originStationId,
      );
      if (!originStop) continue;

      const candidateTransitStops = leg1.stopTimes.filter(
        (s) => s.sequence > originStop.sequence,
      );

      for (const transitStop of candidateTransitStops) {
        const leg1HasDest = leg1.stopTimes.some(
          (s) => s.stationId === destinationStationId,
        );
        if (leg1HasDest) continue;

        const transitStationId = transitStop.stationId;
        const leg1ArrivalAt =
          transitStop.plannedArrivalAt ??
          transitStop.plannedDepartureAt ??
          leg1.arrivalAt;

        const connWindowStart = new Date(
          new Date(leg1ArrivalAt).getTime() +
            this.MIN_CONNECTION_MINUTES * 60_000,
        );
        const connWindowEnd = new Date(
          new Date(leg1ArrivalAt).getTime() +
            this.MAX_CONNECTION_MINUTES * 60_000,
        );

        // Filter from pre-loaded candidates in memory — no extra DB query
        const leg2Schedules = (allCandidates as ScheduleWithIncludes[]).filter(
          (s) => {
            const dep = new Date(s.departureAt).getTime();
            return (
              dep >= connWindowStart.getTime() &&
              dep <= connWindowEnd.getTime() &&
              s.stopTimes.some((st) => st.stationId === transitStationId)
            );
          },
        );

        for (const leg2 of leg2Schedules) {
          const leg2TransitStop = leg2.stopTimes.find(
            (s) => s.stationId === transitStationId,
          );
          const leg2DestStop = leg2.stopTimes.find(
            (s) => s.stationId === destinationStationId,
          );

          if (!leg2TransitStop || !leg2DestStop) continue;
          if (leg2TransitStop.sequence >= leg2DestStop.sequence) continue;

          const [leg1Result, leg2Result] = await Promise.all([
            this.buildScheduleResult(
              leg1,
              originStationId,
              transitStationId,
              totalPassengers,
              nationality,
            ),
            this.buildScheduleResult(
              leg2,
              transitStationId,
              destinationStationId,
              totalPassengers,
              nationality,
            ),
          ]);

          if (!leg1Result || !leg2Result) continue;
          if (!leg1Result.hasAvailability || !leg2Result.hasAvailability)
            continue;

          const leg2DepartureAt =
            leg2TransitStop.plannedDepartureAt ?? leg2.departureAt;
          const connectionMinutes = Math.round(
            (new Date(leg2DepartureAt).getTime() -
              new Date(leg1ArrivalAt).getTime()) /
              60_000,
          );

          const leg1MinFare = Math.min(
            ...(leg1Result.faresByClass as any[])
              .map((f: any) => f.baseFareMinor)
              .filter((n: number) => n > 0),
            Infinity,
          );
          const leg2MinFare = Math.min(
            ...(leg2Result.faresByClass as any[])
              .map((f: any) => f.baseFareMinor)
              .filter((n: number) => n > 0),
            Infinity,
          );
          const leg1MinDisplay = Math.min(
            ...(leg1Result.faresByClass as any[])
              .map((f: any) => f.displayAmountMinor)
              .filter((n: number) => n > 0),
            Infinity,
          );
          const leg2MinDisplay = Math.min(
            ...(leg2Result.faresByClass as any[])
              .map((f: any) => f.displayAmountMinor)
              .filter((n: number) => n > 0),
            Infinity,
          );
          const combinedMinFareMinor =
            (isFinite(leg1MinFare) ? leg1MinFare : 0) +
            (isFinite(leg2MinFare) ? leg2MinFare : 0);
          const combinedMinFareDisplay =
            (isFinite(leg1MinDisplay) ? leg1MinDisplay : 0) +
            (isFinite(leg2MinDisplay) ? leg2MinDisplay : 0);
          const displayCurrency =
            leg1Result.displayCurrency ??
            leg2Result.displayCurrency ??
            Currency.ETB;

          results.push({
            type: "TRANSIT",
            transitStationId,
            transitStationName: transitStop.station.name,
            connectionMinutes,
            leg1: leg1Result,
            leg2: leg2Result,
            displayCurrency,
            combinedMinFareMinor,
            combinedMinFareDisplay,
            departureAt: leg1Result.departureAt,
            arrivalAt: leg2Result.arrivalAt,
            totalDurationMinutes:
              leg1Result.durationMinutes +
              connectionMinutes +
              leg2Result.durationMinutes,
          });
        }
      }
    }

    return results;
  }

  private async buildScheduleResult(
    schedule: ScheduleWithIncludes,
    originStationId: string,
    destinationStationId: string,
    totalPassengers: number,
    nationality?: string,
  ) {
    const originStop = schedule.stopTimes.find(
      (s) => s.stationId === originStationId,
    );
    const destStop = schedule.stopTimes.find(
      (s) => s.stationId === destinationStationId,
    );
    if (!originStop || !destStop || originStop.sequence >= destStop.sequence)
      return null;

    // Segment-level cutoff: use the origin stop's own estimated arrival time, not the
    // schedule's overall departureAt (which is station A's time). This lets
    // B→D remain bookable even after A→D closes. Stop-level checkinMinutesBefore override →
    // route default → 30 min fallback — same resolution GuestBookingService applies at
    // booking-creation time, so a segment shown as bookable here stays bookable through
    // checkout instead of being rejected against a different, hardcoded cutoff.
    if (Date.now() >= resolveCheckinCutoff(schedule, originStop, originStationId).cutoffAt.getTime())
      return null;

    // Collect all valid seat IDs upfront for a single batch availability check
    const allValidSeatIds = schedule.coachAssignments.flatMap((a) =>
      a.coach.seats
        .filter((s: any) => s.status !== "BLOCKED" && s.seatNumber?.trim())
        .map((s: any) => s.id as string),
    );

    // Exclude schedules with no seats at all
    if (allValidSeatIds.length === 0) return null;

    // Run availability batch, fare calculation, and schedule-scoped seat blocks in parallel
    const [freeSeatsRaw, faresByClass, scheduleBlocks] = await Promise.all([
      this.segmentsService.getFreeSeatIds(
        schedule.id,
        allValidSeatIds,
        schedule.stopTimes,
        originStop.sequence,
        destStop.sequence,
      ),
      this.calculateFaresForSegment(
        schedule,
        originStationId,
        destinationStationId,
        nationality,
      ),
      this.prisma.seatBlock.findMany({
        where: {
          scheduleId: schedule.id,
          seatId: { in: allValidSeatIds },
        },
        select: { seatId: true },
      }),
    ]);
    const scheduleBlockedIds = new Set(
      scheduleBlocks.map((b: any) => b.seatId),
    );
    // Seats that are free from holds/bookings AND not schedule-blocked
    const freeSeats = new Set(
      [...freeSeatsRaw].filter((id) => !scheduleBlockedIds.has(id)),
    );

    // Compute per-class availability using the pre-computed free seat set. A coach type
    // has separate seat classes per nationality tier (e.g. "VIP Bed Upper (Local)" AND
    // "VIP Bed Upper (Intl)" on the same coach) — filter to the searching passenger's own
    // nationality first, otherwise a name-based `.find()` across both tiers would credit
    // all availability to whichever tier happens to come first in the query result,
    // leaving the other tier's class permanently at 0 ("Fully booked") even when seats
    // are actually free. Matched via the class's own bedPosition field (case-insensitive:
    // Seat.bedPosition is lowercase, SeatClass.bedPosition is uppercase) rather than a
    // name substring, since that's an exact, unambiguous signal.
    const nationalityUpper = (nationality ?? "").toUpperCase();
    const resolvedNationalityType =
      nationalityUpper === "ETHIOPIAN" || nationalityUpper === "DJIBOUTIAN"
        ? "LOCAL"
        : "INTERNATIONAL";

    const availabilityByClass: Record<string, number> = {};
    for (const assignment of schedule.coachAssignments) {
      const seatClasses = (
        assignment.coach.coachType?.seatClasses ?? []
      ).filter(
        (sc: any) =>
          !sc.nationalityType || sc.nationalityType === resolvedNationalityType,
      );
      const isBedCoach = assignment.coach.seats.some((s: any) => s.bedPosition);

      if (isBedCoach) {
        for (const bedPosition of ["upper", "middle", "lower"]) {
          let count = 0;
          for (const seat of assignment.coach.seats) {
            if (
              seat.bedPosition !== bedPosition ||
              seat.status === "BLOCKED" ||
              !seat.seatNumber?.trim()
            )
              continue;
            if (freeSeats.has(seat.id)) count++;
          }
          if (count > 0) {
            const matchingClass = seatClasses.find(
              (sc: any) => sc.bedPosition?.toLowerCase() === bedPosition,
            );
            if (matchingClass)
              availabilityByClass[matchingClass.name] =
                (availabilityByClass[matchingClass.name] ?? 0) + count;
          }
        }
      } else {
        let available = 0;
        for (const seat of assignment.coach.seats) {
          if (seat.status === "BLOCKED" || !seat.seatNumber?.trim()) continue;
          if (freeSeats.has(seat.id)) available++;
        }
        const names =
          seatClasses.length > 0
            ? seatClasses.map((sc: any) => sc.name)
            : ["Standard"];
        for (const name of names)
          availabilityByClass[name] =
            (availabilityByClass[name] ?? 0) + available;
      }
    }

    const coachTypes = this.buildCoachTypeDetails(
      schedule,
      faresByClass,
      nationality,
      availabilityByClass,
    );
    // Use the selected stop's own planned time, not the schedule's full-route span —
    // for stop-based (mid-route) boarding/alighting these differ from the train's
    // overall origin departure / final destination arrival.
    const legDepartureAt = originStop.plannedDepartureAt ?? schedule.departureAt;
    const legArrivalAt = destStop.plannedArrivalAt ?? schedule.arrivalAt;

    const displayCurrency =
      faresByClass[0]?.displayCurrency ??
      resolveCurrencyFromNationality(nationality);

    return {
      type: "DIRECT",
      scheduleId: schedule.id,
      trainNumber: schedule.train.number,
      trainName: schedule.train.name,
      origin: {
        id: originStop.stationId,
        code: originStop.station.code,
        name: originStop.station.name,
        city: originStop.station.city,
        sequence: originStop.sequence,
      },
      destination: {
        id: destStop.stationId,
        code: destStop.station.code,
        name: destStop.station.name,
        city: destStop.station.city,
        sequence: destStop.sequence,
      },
      departureAt: legDepartureAt,
      arrivalAt: legArrivalAt,
      durationMinutes: Math.round(
        (new Date(legArrivalAt).getTime() -
          new Date(legDepartureAt).getTime()) /
          60_000,
      ),
      status: schedule.status,
      stops: schedule.stopTimes
        .filter(
          (st) =>
            st.sequence >= originStop.sequence &&
            st.sequence <= destStop.sequence,
        )
        .map((st) => ({
          stationId: st.stationId,
          stationName: st.station.name,
          sequence: st.sequence,
          plannedArrivalAt: st.plannedArrivalAt,
          plannedDepartureAt: st.plannedDepartureAt,
        })),
      availabilityByClass,
      hasAvailability: Object.values(availabilityByClass).some(
        (n) => n >= totalPassengers,
      ),
      displayCurrency,
      faresByClass,
      coachTypes,
    };
  }

  async getFareQuote(dto: FareQuoteDto) {
    const schedule = await this.prisma.trainSchedule.findUnique({
      where: { id: dto.scheduleId },
      include: {
        originStation: true,
        destinationStation: true,
        stopTimes: { include: { station: true }, orderBy: { sequence: "asc" } },
      },
    });
    if (!schedule) throw new NotFoundException("Schedule not found");
    if (!schedule.routeId)
      throw new NotFoundException(
        "Schedule has no route configured for fare calculation",
      );

    const originStop = schedule.stopTimes.find(
      (s: any) => s.stationId === dto.originStationId,
    );
    const destStop = schedule.stopTimes.find(
      (s: any) => s.stationId === dto.destinationStationId,
    );
    if (!originStop || !destStop || originStop.sequence >= destStop.sequence) {
      throw new NotFoundException(
        "Origin or destination not found on this schedule",
      );
    }

    const seatClass = await this.prisma.seatClass.findFirst({
      where: { name: dto.seatClassName },
    });
    if (!seatClass)
      throw new NotFoundException(
        `Seat class '${dto.seatClassName}' not found`,
      );

    const fare = await this.fareEngine.calculate({
      routeId: schedule.routeId,
      originStationId: dto.originStationId,
      destinationStationId: dto.destinationStationId,
      seatClassId: seatClass.id,
      nationality: dto.nationality,
      scheduleId: dto.scheduleId,
      adultCount: dto.adultCount,
      childCount: dto.childCount ?? 0,
      promoCode: dto.promoCode,
    });

    const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * POINTS_TO_MINOR;
    const totalMinor = Math.max(0, fare.totalMinor - loyaltyMinor);

    const segmentRoute = `${originStop.station.code}-${destStop.station.code}`;
    const displayCurrency =
      dto.displayCurrency ?? resolveCurrencyFromNationality(dto.nationality);
    const displayTotalMinor =
      displayCurrency !== Currency.ETB
        ? await this.currencyService.convertAmount(
            totalMinor,
            Currency.ETB,
            displayCurrency,
          )
        : totalMinor;

    return {
      scheduleId: dto.scheduleId,
      originStationId: dto.originStationId,
      destinationStationId: dto.destinationStationId,
      segmentRoute,
      seatClassName: dto.seatClassName,
      nationality: dto.nationality,
      adultCount: fare.adultCount,
      childCount: fare.childCount,
      baseFareMinor: fare.baseFarePerPassengerMinor,
      adultFareMinor: fare.adultCount * fare.farePerPassengerMinor,
      childFareMinor: fare.paidChildrenCount * fare.farePerPassengerMinor,
      freeChildrenCount: fare.freeChildrenCount,
      paidChildrenCount: fare.paidChildrenCount,
      premiumMinor: fare.premiumPerPassenger,
      insuranceFeeMinor: fare.insurancePerPassenger,
      totalBaseFareMinor: fare.subtotalMinor,
      discountMinor: fare.discountMinor,
      taxesFeesMinor: 0,
      loyaltyRedemptionMinor: loyaltyMinor,
      totalMinor: displayTotalMinor,
      currency: displayCurrency,
      displayCurrency,
      displayTotalMinor,
    };
  }

  async getFareBreakdown(dto: FareBreakdownRequestDto) {
    const schedule = await this.prisma.trainSchedule.findUnique({
      where: { id: dto.scheduleId },
      select: {
        routeId: true,
        originStationId: true,
        destinationStationId: true,
      },
    });
    if (!schedule) throw new NotFoundException("Schedule not found");
    if (!schedule.routeId)
      throw new NotFoundException(
        "Schedule has no route configured for fare calculation",
      );

    const now = new Date();
    const displayCurrency = dto.displayCurrency ?? Currency.ETB;

    let parsedPassengers: FareBreakdownPassengerDto[];
    try {
      parsedPassengers = JSON.parse(dto.passengers as unknown as string);
    } catch {
      throw new NotFoundException("passengers must be a valid JSON array");
    }

    // Categorise passengers by age
    const categorised = parsedPassengers.map((p) => {
      const ageMs = now.getTime() - new Date(p.dateOfBirth).getTime();
      const ageYears = ageMs / (1000 * 60 * 60 * 24 * 365.25);
      return {
        ...p,
        category: (ageYears >= 5 ? "ADULT" : "CHILD") as "ADULT" | "CHILD",
        ageYears,
      };
    });

    const adultCount = categorised.filter((p) => p.category === "ADULT").length;
    const childCount = categorised.filter((p) => p.category === "CHILD").length;

    // Ask the fare engine for the authoritative free-child count using the full group
    // Use the first passenger's seatClassId as a representative — freeChildrenCount
    // depends only on adultCount/childCount, not on seat class.
    const groupFare = await this.fareEngine.calculate({
      routeId: schedule.routeId!,
      originStationId: dto.originStationId,
      destinationStationId: dto.destinationStationId,
      seatClassId: categorised[0].seatClassId,
      nationality: categorised[0].nationality,
      scheduleId: dto.scheduleId,
      adultCount,
      childCount,
    });
    const freeChildrenAllowed = groupFare.freeChildrenCount;

    // Pre-compute isFree per passenger index synchronously so the race-free
    // counter assignment isn't corrupted by concurrent Promise.all resolution.
    let freeChildrenUsed = 0;
    const isFreeByIndex = categorised.map((p) => {
      if (p.category === "CHILD" && freeChildrenUsed < freeChildrenAllowed) {
        freeChildrenUsed++;
        return true;
      }
      return false;
    });

    // Calculate per-passenger fare rate (engine called with 1 adult, 0 children — pure rate lookup)
    const passengerLines = await Promise.all(
      categorised.map(async (p, idx) => {
        const fare = await this.fareEngine.calculate({
          routeId: schedule.routeId!,
          originStationId: dto.originStationId,
          destinationStationId: dto.destinationStationId,
          seatClassId: p.seatClassId,
          nationality: p.nationality,
          scheduleId: dto.scheduleId,
          adultCount: 1,
          childCount: 0,
        });

        const isFree = isFreeByIndex[idx];

        const fareMinor = isFree
          ? fare.premiumPerPassenger + fare.insurancePerPassenger
          : fare.farePerPassengerMinor;
        const displayFareMinor =
          displayCurrency !== Currency.ETB
            ? await this.currencyService.convertAmount(
                fareMinor,
                Currency.ETB,
                displayCurrency,
              )
            : fareMinor;

        return {
          passengerName: p.passengerName,
          dateOfBirth: p.dateOfBirth,
          category: p.category,
          ageYears: Math.floor(p.ageYears),
          seatClassId: fare.seatClassId,
          seatClassName: fare.seatClassName,
          nationality: p.nationality ?? null,
          baseFareMinor: fare.baseFarePerPassengerMinor,
          premiumMinor: fare.premiumPerPassenger,
          insuranceFeeMinor: fare.insurancePerPassenger,
          fareMinor,
          isFree,
          displayCurrency,
          displayFareMinor,
        };
      }),
    );

    let subtotalMinor = passengerLines.reduce((sum, l) => sum + l.fareMinor, 0);

    let discountMinor = 0;
    if (dto.promoCode) {
      const promo = await this.prisma.promotion.findUnique({
        where: { code: dto.promoCode },
      });
      if (promo?.active && promo.validUntil > now) {
        discountMinor = promo.percentOff
          ? Math.round((subtotalMinor * promo.percentOff) / 100)
          : (promo.amountOffMinor ?? 0);
      }
    }

    const totalMinor = subtotalMinor - discountMinor;
    const displayTotalMinor =
      displayCurrency !== Currency.ETB
        ? await this.currencyService.convertAmount(
            totalMinor,
            Currency.ETB,
            displayCurrency,
          )
        : totalMinor;

    return {
      scheduleId: dto.scheduleId,
      originStationId: dto.originStationId,
      destinationStationId: dto.destinationStationId,
      passengers: passengerLines,
      subtotalMinor,
      discountMinor,
      totalMinor: displayTotalMinor,
      currency: displayCurrency,
      displayCurrency,
      displayTotalMinor,
    };
  }

  private async calculateFaresForSegment(
    schedule: ScheduleWithIncludes,
    originStationId: string,
    destinationStationId: string,
    nationality?: string,
  ): Promise<
    Array<{
      seatClassName: string;
      baseFareMinor: number;
      displayCurrency: Currency;
      displayAmountMinor: number;
    }>
  > {
    const displayCurrency = resolveCurrencyFromNationality(nationality);

    const nationalityUpper = (nationality ?? "").toUpperCase();
    const nationalityType =
      nationalityUpper === "ETHIOPIAN" || nationalityUpper === "DJIBOUTIAN"
        ? "LOCAL"
        : "INTERNATIONAL";

    // Collect seat class IDs from the schedule include for the ID set,
    // but fetch fresh records from DB so updated baseFareMinor is always current
    const seatClassIdSet = new Set<string>();
    for (const a of schedule.coachAssignments) {
      for (const sc of a.coach.coachType?.seatClasses ?? []) {
        if (sc.isActive) seatClassIdSet.add(sc.id);
      }
    }
    const freshSeatClasses = await this.prisma.seatClass.findMany({
      where: {
        id: { in: Array.from(seatClassIdSet) },
        isActive: true,
        OR: [{ nationalityType: null }, { nationalityType: nationalityType }],
      },
    });
    const seatClassMap = new Map(freshSeatClasses.map((sc) => [sc.id, sc]));
    const seatClasses = freshSeatClasses.sort(
      (a, b) => a.baseFareMinor - b.baseFareMinor,
    );

    if (seatClasses.length === 0) return [];

    if (schedule.routeId) {
      const results = await Promise.all(
        seatClasses.map(async (sc) => {
          try {
            const fare = await this.fareEngine.calculate({
              routeId: schedule.routeId!,
              originStationId,
              destinationStationId,
              seatClassId: sc.id,
              nationality,
              scheduleId: schedule.id,
            });
            return {
              // Use the input seat class name (sc.name) so it always matches what
              // buildCoachTypeDetails looks up via coachType.seatClasses. The fare
              // engine may resolve a nationality-specific variant (nationalitySeatClass)
              // whose name differs from sc.name, which would cause the class to be
              // silently dropped from coachTypes and show N/A on the results page.
              seatClassName: sc.name,
              baseFareMinor: fare.totalMinor,
              displayCurrency: fare.billingCurrency as Currency,
              displayAmountMinor: fare.totalInBillingCurrency,
            };
          } catch {
            return null;
          }
        }),
      );

      const validResults = results.filter(
        (
          r,
        ): r is {
          seatClassName: string;
          baseFareMinor: number;
          displayCurrency: Currency;
          displayAmountMinor: number;
        } => r !== null,
      );
      if (validResults.length > 0) return validResults;
    }

    // Fallback: use station codes from already-loaded stopTimes when available
    const originStop = schedule.stopTimes.find(
      (st) => st.stationId === originStationId,
    );
    const destStop = schedule.stopTimes.find(
      (st) => st.stationId === destinationStationId,
    );
    const originCode = originStop?.station?.code;
    const destCode = destStop?.station?.code;

    if (originCode && destCode) {
      const segmentRoute = `${originCode}-${destCode}`;
      const now = new Date();

      const fareRules = await this.prisma.fareRule.findMany({
        where: {
          route: segmentRoute,
          seatClassId: { in: seatClasses.map((sc: any) => sc.id) },
          validFrom: { lte: now },
          OR: [{ validUntil: null }, { validUntil: { gte: now } }],
        },
      });

      if (fareRules.length > 0) {
        const exchangeRate = await this.currencyService.getExchangeRate(
          Currency.ETB,
          displayCurrency,
        );
        const TAX_RATE = 0.05;
        return fareRules.map((rule) => {
          const totalMinor =
            rule.baseFareMinor + Math.round(rule.baseFareMinor * TAX_RATE);
          return {
            seatClassName:
              seatClassMap.get(rule.seatClassId)?.name ?? "Unknown",
            baseFareMinor: totalMinor,
            displayCurrency,
            displayAmountMinor: Math.round(totalMinor * exchangeRate),
          };
        });
      }
    }

    return [];
  }

  // buildCoachTypeDetails is pure in-memory — no async needed
  private buildCoachTypeDetails(
    schedule: ScheduleWithIncludes,
    faresByClass: Array<{
      seatClassName: string;
      baseFareMinor: number;
      displayCurrency: Currency;
      displayAmountMinor: number;
    }>,
    nationality?: string,
    availabilityByClass: Record<string, number> = {},
  ): Array<{
    coachTypeId: string;
    coachTypeName: string;
    coachTypeCode: string;
    coachId: string;
    classes: Array<{
      name: string;
      baseFareMinor: number;
      displayCurrency: Currency;
      displayAmountMinor: number;
      available: number;
    }>;
  }> {
    const coachTypeMap = new Map<
      string,
      { coachType: any; classNames: Set<string>; coachId: string }
    >();

    for (const assignment of schedule.coachAssignments) {
      const coachType = assignment.coach.coachType;
      if (!coachType) continue;

      if (!coachTypeMap.has(coachType.id)) {
        coachTypeMap.set(coachType.id, {
          coachType,
          classNames: new Set(),
          coachId: assignment.coach.id,
        });
      }

      const nationalityUpper = (nationality ?? "").toUpperCase();
      const resolvedNationalityType =
        nationalityUpper === "ETHIOPIAN" || nationalityUpper === "DJIBOUTIAN"
          ? "LOCAL"
          : "INTERNATIONAL";

      const entry = coachTypeMap.get(coachType.id)!;
      coachType.seatClasses?.forEach((sc: any) => {
        // Exclude classes that belong to the wrong nationality type
        if (
          sc.nationalityType &&
          sc.nationalityType !== resolvedNationalityType
        )
          return;
        if (faresByClass.some((f) => f.seatClassName === sc.name))
          entry.classNames.add(sc.name);
      });
    }

    const result = [];
    for (const [, { coachType, classNames, coachId }] of coachTypeMap) {
      const classes = Array.from(classNames)
        .map((className) => {
          const fareInfo = faresByClass.find(
            (f) => f.seatClassName === className,
          );
          if (!fareInfo) return null;
          return {
            name: className,
            baseFareMinor: fareInfo.baseFareMinor,
            displayCurrency: fareInfo.displayCurrency,
            displayAmountMinor: fareInfo.displayAmountMinor,
            available: availabilityByClass[className] ?? 0,
          };
        })
        .filter(
          (
            c,
          ): c is {
            name: string;
            baseFareMinor: number;
            displayCurrency: Currency;
            displayAmountMinor: number;
            available: number;
          } => c !== null,
        )
        .sort((a, b) => a.baseFareMinor - b.baseFareMinor);

      if (classes.length === 0) continue;
      result.push({
        coachTypeId: coachType.id,
        coachTypeName: coachType.name,
        coachTypeCode: coachType.code,
        coachId,
        classes,
      });
    }

    return result.sort((a, b) => {
      const minPriceA = Math.min(...a.classes.map((c) => c.baseFareMinor));
      const minPriceB = Math.min(...b.classes.map((c) => c.baseFareMinor));
      return minPriceA - minPriceB;
    });
  }
}
