import log from 'loglevel';

import { SourceStopId, StopId } from '../stops/stops.js';
import { SerializedRoute } from '../timetable/io.js';
import type { RouteId, TripRouteIndex } from '../timetable/route.js';
import { PickUpDropOffTypes, Route } from '../timetable/route.js';
import {
  ServiceRoute,
  ServiceRouteId,
  StopAdjacency,
} from '../timetable/timetable.js';
import { FrequenciesMap } from './frequencies.js';
import { GtfsRouteId, GtfsRoutesMap } from './routes.js';
import { ServiceId, ServiceIds } from './services.js';
import { GtfsStopsMap } from './stops.js';
import { GtfsTime, toTime } from './time.js';
import { TransfersMap } from './transfers.js';
import { hashIds, parseCsv } from './utils.js';

export type GtfsTripId = string;

export type GtfsTripIdsMap = Map<GtfsTripId, GtfsRouteId>;

export type TripsMapping = Map<
  GtfsTripId,
  { routeId: RouteId; tripRouteIndex: TripRouteIndex }
>;

type TripEntry = {
  route_id: GtfsRouteId;
  service_id: ServiceId;
  trip_id: GtfsTripId;
};

export type GtfsPickupDropOffType =
  | '' // Not specified
  | '0' // Regularly scheduled
  | '1' // Not available
  | '2' // Must phone agency
  | '3'; // Must coordinate with driver

type StopTimeEntry = {
  trip_id: GtfsTripId;
  arrival_time?: GtfsTime;
  departure_time?: GtfsTime;
  stop_id: SourceStopId;
  stop_sequence: number;
  pickup_type?: GtfsPickupDropOffType;
  drop_off_type?: GtfsPickupDropOffType;
};

export type SerializedPickUpDropOffType = 0 | 1 | 2 | 3;

/**
 * Intermediate data structure for building routes during parsing
 */
type RouteBuilder = {
  serviceRouteId: ServiceRouteId;
  stops: StopId[];
  trips: Array<{
    gtfsTripId: GtfsTripId;
    firstDeparture: number;
    arrivalTimes: number[];
    departureTimes: number[];
    pickUpTypes: SerializedPickUpDropOffType[];
    dropOffTypes: SerializedPickUpDropOffType[];
  }>;
};

/**
 * Encodes pickup/drop-off types into a Uint8Array using 2 bits per value.
 * Layout per byte: [drop_off_1][pickup_1][drop_off_0][pickup_0] for stops 0 and 1
 */
export const encodePickUpDropOffTypes = (
  pickUpTypes: SerializedPickUpDropOffType[],
  dropOffTypes: SerializedPickUpDropOffType[],
): Uint8Array => {
  const stopsCount = pickUpTypes.length;
  // Each byte stores 2 pickup/drop-off pairs (4 bits each)
  const arraySize = Math.ceil(stopsCount / 2);
  const encoded = new Uint8Array(arraySize);

  for (let i = 0; i < stopsCount; i++) {
    const byteIndex = Math.floor(i / 2);
    const isSecondPair = i % 2 === 1;
    const dropOffType = dropOffTypes[i];
    const pickUpType = pickUpTypes[i];

    if (
      dropOffType !== undefined &&
      pickUpType !== undefined &&
      byteIndex < encoded.length
    ) {
      if (isSecondPair) {
        // Second pair: upper 4 bits
        const currentByte = encoded[byteIndex];
        if (currentByte !== undefined) {
          encoded[byteIndex] =
            currentByte | (dropOffType << 4) | (pickUpType << 6);
        }
      } else {
        // First pair: lower 4 bits
        const currentByte = encoded[byteIndex];
        if (currentByte !== undefined) {
          encoded[byteIndex] = currentByte | dropOffType | (pickUpType << 2);
        }
      }
    }
  }
  return encoded;
};

/**
 * Sorts trips by departure time and creates optimized typed arrays
 */
const finalizeRouteFromBuilder = (
  builder: RouteBuilder,
): [SerializedRoute, GtfsTripId[]] => {
  builder.trips.sort((a, b) => a.firstDeparture - b.firstDeparture);

  const stopsCount = builder.stops.length;
  const tripsCount = builder.trips.length;
  const stopsArray = new Uint32Array(builder.stops);
  const stopTimesArray = new Uint16Array(stopsCount * tripsCount * 2);
  const allPickUpTypes: SerializedPickUpDropOffType[] = [];
  const allDropOffTypes: SerializedPickUpDropOffType[] = [];

  const gtfsTripIds = [];
  for (let tripIndex = 0; tripIndex < tripsCount; tripIndex++) {
    const trip = builder.trips[tripIndex];
    if (!trip) {
      throw new Error(`Missing trip data at index ${tripIndex}`);
    }
    gtfsTripIds.push(trip.gtfsTripId);
    const baseIndex = tripIndex * stopsCount * 2;

    for (let stopIndex = 0; stopIndex < stopsCount; stopIndex++) {
      const timeIndex = baseIndex + stopIndex * 2;
      const arrivalTime = trip.arrivalTimes[stopIndex];
      const departureTime = trip.departureTimes[stopIndex];
      const pickUpType = trip.pickUpTypes[stopIndex];
      const dropOffType = trip.dropOffTypes[stopIndex];

      if (
        arrivalTime === undefined ||
        departureTime === undefined ||
        pickUpType === undefined ||
        dropOffType === undefined
      ) {
        throw new Error(
          `Missing trip data for trip ${tripIndex} at stop ${stopIndex}`,
        );
      }

      stopTimesArray[timeIndex] = arrivalTime;
      stopTimesArray[timeIndex + 1] = departureTime;
      allDropOffTypes.push(dropOffType);
      allPickUpTypes.push(pickUpType);
    }
  }
  // Use 2-bit encoding for pickup/drop-off types
  const pickupDropOffTypesArray = encodePickUpDropOffTypes(
    allPickUpTypes,
    allDropOffTypes,
  );
  return [
    {
      serviceRouteId: builder.serviceRouteId,
      stops: stopsArray,
      stopTimes: stopTimesArray,
      pickupDropOffTypes: pickupDropOffTypesArray,
    },
    gtfsTripIds,
  ];
};

/**
 * Parses the trips.txt file from a GTFS feed
 *
 * @param tripsStream The readable stream containing the trips data.
 * @param serviceIds A mapping of service IDs to corresponding route IDs.
 * @param serviceRoutes A mapping of route IDs to route details.
 * @returns A mapping of trip IDs to corresponding route IDs.
 */
export const parseTrips = async (
  tripsStream: NodeJS.ReadableStream,
  serviceIds: ServiceIds,
  validGtfsRoutes: GtfsRoutesMap,
): Promise<GtfsTripIdsMap> => {
  const trips: GtfsTripIdsMap = new Map();
  for await (const rawLine of parseCsv(tripsStream, ['stop_sequence'])) {
    const line = rawLine as TripEntry;
    if (!serviceIds.has(line.service_id)) {
      // The trip doesn't correspond to an active service
      continue;
    }
    if (!validGtfsRoutes.has(line.route_id)) {
      // The trip doesn't correspond to a supported route
      continue;
    }
    trips.set(line.trip_id, line.route_id);
  }
  return trips;
};

export const buildStopsAdjacencyStructure = (
  serviceRoutes: ServiceRoute[],
  routes: Route[],
  transfersMap: TransfersMap,
  nbStops: number,
  activeStops: Set<StopId>,
): StopAdjacency[] => {
  const stopsAdjacency = new Array<StopAdjacency>(nbStops);
  for (let i = 0; i < nbStops; i++) {
    stopsAdjacency[i] = {
      routes: [],
    };
  }
  for (let index = 0; index < routes.length; index++) {
    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
    const route = routes[index]!;
    for (let j = 0; j < route.getNbStops(); j++) {
      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
      const stop = route.stops[j]!;
      if (activeStops.has(stop)) {
        // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
        stopsAdjacency[stop]!.routes.push(index);
      }
    }
    const serviceRoute = serviceRoutes[route.serviceRoute()];
    if (serviceRoute === undefined) {
      throw new Error(
        `Service route ${route.serviceRoute()} not found for route ${index}.`,
      );
    }
    serviceRoute.routes.push(index);
  }
  for (const [stop, transfers] of transfersMap) {
    for (let i = 0; i < transfers.length; i++) {
      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
      const transfer = transfers[i]!;
      if (activeStops.has(stop) || activeStops.has(transfer.destination)) {
        // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
        const stopAdj = stopsAdjacency[stop]!;
        if (!stopAdj.transfers) {
          stopAdj.transfers = [];
        }
        stopAdj.transfers.push(transfer);
        activeStops.add(transfer.destination);
        activeStops.add(stop);
      }
    }
  }
  return stopsAdjacency;
};

/**
 * Parses the stop_times.txt data from a GTFS feed.
 *
 * @param stopTimesStream The readable stream containing the stop times data.
 * @param stopsMap A map of parsed stops from the GTFS feed.
 * @param activeTripIds A map of valid trip IDs to corresponding route IDs.
 * @param activeStopIds A set of valid stop IDs.
 * @param frequenciesMap An optional map of frequency windows per trip.
 *   When provided, trips listed in this map are expanded into one concrete
 *   trip per departure that fits within each frequency window, instead of
 *   being kept as a single template trip.
 * @returns A mapping of route IDs to route details. The routes returned correspond to the set of trips from GTFS that share the same stop list.
 */
export const parseStopTimes = async (
  stopTimesStream: NodeJS.ReadableStream,
  stopsMap: GtfsStopsMap,
  activeTripIds: GtfsTripIdsMap,
  activeStopIds: Set<StopId>,
  frequenciesMap?: FrequenciesMap,
): Promise<{
  routes: Route[];
  serviceRoutesMap: Map<GtfsRouteId, ServiceRouteId>;
  tripsMapping: TripsMapping;
}> => {
  /**
   * Adds a trip to the appropriate route builder.
   * If the trip has frequency windows, it is expanded into one concrete trip
   * per departure that fits within each window (last departure strictly before
   * end_time). Otherwise the trip is added as-is.
   */
  const addTrip = (currentTripId: GtfsTripId) => {
    const gtfsRouteId = activeTripIds.get(currentTripId);

    if (!gtfsRouteId || stops.length === 0) {
      stops = [];
      arrivalTimes = [];
      departureTimes = [];
      pickUpTypes = [];
      dropOffTypes = [];
      return;
    }

    const firstDeparture = departureTimes[0];
    if (firstDeparture === undefined) {
      log.warn(`Empty trip ${currentTripId}`);
      stops = [];
      arrivalTimes = [];
      departureTimes = [];
      pickUpTypes = [];
      dropOffTypes = [];
      return;
    }

    const routeId = `${gtfsRouteId}_${hashIds(stops)}`;
    let routeBuilder = routeBuilders.get(routeId);
    if (!routeBuilder) {
      let serviceRouteId = serviceRoutesMap.get(gtfsRouteId);
      if (serviceRouteId === undefined) {
        serviceRouteId = currentServiceRouteId;
        serviceRoutesMap.set(gtfsRouteId, serviceRouteId);
        currentServiceRouteId = currentServiceRouteId + 1;
      }
      routeBuilder = {
        serviceRouteId,
        stops,
        trips: [],
      };
      routeBuilders.set(routeId, routeBuilder);
      for (const stop of stops) {
        activeStopIds.add(stop);
      }
    }

    const frequencyWindows = frequenciesMap?.get(currentTripId);
    if (frequencyWindows && frequencyWindows.length > 0) {
      // The stop times in stop_times.txt are a template. Compute offsets
      // relative to the template's first departure (T0) and generate one
      // concrete trip per departure within each frequency window.
      const T0 = firstDeparture;
      for (const window of frequencyWindows) {
        let S = window.startTime;
        while (S < window.endTime) {
          const offset = S - T0;
          routeBuilder.trips.push({
            firstDeparture: S,
            gtfsTripId: `${currentTripId}:freq:${S}`,
            arrivalTimes: arrivalTimes.map((t) => t + offset),
            departureTimes: departureTimes.map((t) => t + offset),
            pickUpTypes: [...pickUpTypes],
            dropOffTypes: [...dropOffTypes],
          });
          S += window.headwayMins;
        }
      }
    } else {
      routeBuilder.trips.push({
        firstDeparture,
        gtfsTripId: currentTripId,
        arrivalTimes: arrivalTimes,
        departureTimes: departureTimes,
        pickUpTypes: pickUpTypes,
        dropOffTypes: dropOffTypes,
      });
    }

    stops = [];
    arrivalTimes = [];
    departureTimes = [];
    pickUpTypes = [];
    dropOffTypes = [];
  };

  type BuilderRouteId = string;
  const routeBuilders: Map<BuilderRouteId, RouteBuilder> = new Map();
  const serviceRoutesMap: Map<GtfsRouteId, ServiceRouteId> = new Map();

  // incrementally generate service route IDs
  let currentServiceRouteId = 0;

  let previousSeq = 0;
  let stops: StopId[] = [];
  let arrivalTimes: number[] = [];
  let departureTimes: number[] = [];
  let pickUpTypes: SerializedPickUpDropOffType[] = [];
  let dropOffTypes: SerializedPickUpDropOffType[] = [];
  let currentTripId: GtfsTripId | undefined = undefined;

  for await (const rawLine of parseCsv(stopTimesStream, ['stop_sequence'])) {
    const line = rawLine as StopTimeEntry;
    if (line.trip_id === currentTripId && line.stop_sequence <= previousSeq) {
      log.warn(
        `Stop sequences not increasing for trip ${line.trip_id}: ${line.stop_sequence} > ${previousSeq}.`,
      );
      continue;
    }
    if (!line.arrival_time && !line.departure_time) {
      log.warn(
        `Missing arrival or departure time for ${line.trip_id} at stop ${line.stop_id}.`,
      );
      continue;
    }
    if (line.pickup_type === '1' && line.drop_off_type === '1') {
      // Warning: could potentially lead to issues if there is an in-seat transfer
      // at this stop - it can be not boardable nor alightable but still useful for an in-seat transfer.
      // This doesn't seem to happen in practice for now so keeping this condition to save memory.
      continue;
    }
    if (currentTripId && line.trip_id !== currentTripId && stops.length > 0) {
      addTrip(currentTripId);
    }
    currentTripId = line.trip_id;

    const stopData = stopsMap.get(line.stop_id);
    if (!stopData) {
      log.warn(`Unknown stop ID: ${line.stop_id}`);
      continue;
    }
    stops.push(stopData.id);

    const departure = line.departure_time ?? line.arrival_time;
    const arrival = line.arrival_time ?? line.departure_time;

    if (!arrival || !departure) {
      log.warn(`Missing time data for ${line.trip_id} at stop ${line.stop_id}`);
      continue;
    }
    arrivalTimes.push(toTime(arrival));
    departureTimes.push(toTime(departure));
    pickUpTypes.push(parsePickupDropOffType(line.pickup_type));
    dropOffTypes.push(parsePickupDropOffType(line.drop_off_type));

    previousSeq = line.stop_sequence;
  }
  if (currentTripId) {
    addTrip(currentTripId);
  }

  const routesAdjacency: Route[] = [];
  const tripsMapping = new Map<
    GtfsTripId,
    { routeId: RouteId; tripRouteIndex: TripRouteIndex }
  >();
  for (const [, routeBuilder] of routeBuilders) {
    const [routeData, gtfsTripIds] = finalizeRouteFromBuilder(routeBuilder);
    const routeId = routesAdjacency.length;
    routesAdjacency.push(
      new Route(
        routeId,
        routeData.stopTimes,
        routeData.pickupDropOffTypes,
        routeData.stops,
        routeData.serviceRouteId,
      ),
    );
    gtfsTripIds.forEach((tripId, index) => {
      tripsMapping.set(tripId, {
        routeId,
        tripRouteIndex: index,
      });
    });
  }
  return { routes: routesAdjacency, serviceRoutesMap, tripsMapping };
};

const parsePickupDropOffType = (
  gtfsType?: GtfsPickupDropOffType,
): SerializedPickUpDropOffType => {
  switch (gtfsType) {
    default:
      return PickUpDropOffTypes.REGULAR;
    case '0':
      return PickUpDropOffTypes.REGULAR;
    case '1':
      return PickUpDropOffTypes.NOT_AVAILABLE;
    case '2':
      return PickUpDropOffTypes.MUST_PHONE_AGENCY;
    case '3':
      return PickUpDropOffTypes.MUST_COORDINATE_WITH_DRIVER;
  }
};
