<?php

namespace App\Libraries;

use App\Libraries\EgyptTime;
use Modules\Schedule\Models\ScheduleModel;
use Modules\Trip\Models\TripModel;
use Modules\Trip\Models\SubtripModel;
use Modules\Trip\Models\PickdropModel;

/**
 * Journey calendar dates — three meanings (do not mix APIs):
 *
 * 1. Bus run / manifest day — when the bus departs (driver printout, seat inventory key).
 * 2. Boarding day — passenger pickup calendar day (tickets.journeydata, ticket list search).
 * 3. User-selected date — what the UI sends; may be (1) or (2) depending on screen.
 *
 * Which method to use:
 * - Trip search / seat map: resolveSearchDepartureDates() — user date is boarding day at pickup.
 * - Legacy normalize-only: resolveBusRunDateForTripSearch().
 * - Booking, payment, journeylists, hold save: bus run day from trip search (normalize only).
 * - tickets.journeydata → bus run: boardingDayToBusRunDate() for inventory filters.
 * - tickets.journeydata on insert: getTicketJourneyDateForStorage(busRun, pick) → boarding day.
 *
 * pickdrops.trip_id stores subtrips.id (NOT trips.id); main-route stands use trips.id in some legacy rows.
 */
class TripJourneyDateHelper
{
    protected $tripModel;
    protected $subtripModel;
    protected $pickdropModel;
    protected $scheduleModel;

    /** @var array<int, object|null> */
    protected $tripCache = [];

    /** @var array<int, object|null> */
    protected $scheduleCache = [];

    /** @var array<int, array> */
    protected $mainPickdropsCache = [];

    /** @var array<int, array> */
    protected $subtripPickupsCache = [];

    /** @var array<int, object|null> */
    protected $pickdropByIdCache = [];

    /** @var array<string, string> */
    protected $boardingDateCache = [];

    /** @var array<string, string> */
    protected $busStartDateCache = [];

    /** @var array<string, string> */
    protected $displayDateCache = [];

    public function __construct()
    {
        $this->tripModel      = new TripModel();
        $this->subtripModel   = new SubtripModel();
        $this->pickdropModel  = new PickdropModel();
        $this->scheduleModel  = new ScheduleModel();
    }

    /**
     * Trip finder / seat map: user-selected calendar date is already the bus run day.
     */
    public function resolveBusRunDateForTripSearch(string $userSelectedDate): string
    {
        return EgyptTime::toDateString($userSelectedDate);
    }

    /**
     * Bus manifest day when saving a booking — trip search already passes the bus run day.
     */
    public function getManifestDateForJourneylist(
        int $mainTripId,
        int $subtripId,
        string $selectedCalendarDate,
        int $pickStandId
    ): string {
        unset($mainTripId, $subtripId, $pickStandId);

        return EgyptTime::toDateString($selectedCalendarDate);
    }

    /**
     * Convert tickets.journeydata (boarding calendar day) to bus run day for inventory matching.
     */
    public function boardingDayToBusRunDate(
        int $mainTripId,
        int $subtripId,
        string $boardingCalendarDate,
        int $pickStandId
    ): string {
        $boarding = EgyptTime::toDateString($boardingCalendarDate);

        if ($mainTripId <= 0 || $boarding === '1970-01-01') {
            return $boarding;
        }

        $mainTrip = $this->getTrip($mainTripId);
        if (! $mainTrip) {
            return $boarding;
        }

        $pickupDayOffset = $this->getPickupDayOffset($mainTrip, $mainTripId, $subtripId, $pickStandId);
        if ($pickupDayOffset === null || $pickupDayOffset < 1) {
            return $boarding;
        }

        return EgyptTime::toDateString($boarding . " -{$pickupDayOffset} day");
    }

    /**
     * @deprecated Use getManifestDateForJourneylist (booking) or resolveBusRunDateForTripSearch (search).
     */
    public function getBusStartDateForJourneylist(int $mainTripId, int $subtripId, string $userJourneyDate, int $pickStandId): string
    {
        $userJourneyDate = EgyptTime::toDateString($userJourneyDate);
        $cacheKey        = "{$mainTripId}|{$subtripId}|{$userJourneyDate}|{$pickStandId}|bus";

        if (isset($this->busStartDateCache[$cacheKey])) {
            return $this->busStartDateCache[$cacheKey];
        }

        $mainTrip = $this->getTrip($mainTripId);

        if (!$mainTrip) {
            return $this->busStartDateCache[$cacheKey] = $userJourneyDate;
        }

        $pickupDayOffset = $this->getPickupDayOffset($mainTrip, $mainTripId, $subtripId, $pickStandId);

        if ($pickupDayOffset === null) {
            return $this->busStartDateCache[$cacheKey] = $userJourneyDate;
        }

        return $this->busStartDateCache[$cacheKey] = date('Y-m-d', strtotime($userJourneyDate . " -{$pickupDayOffset} day"));
    }

    /**
     * Compute the passenger's actual boarding date from the normalized bus start date.
     * Boarding Date = Bus Start Date + offset of pickup stand.
     */
    public function getBoardingDateFromBusStartDate(int $mainTripId, int $subtripId, string $busStartDate, int $pickStandId): string
    {
        $busStartDate = EgyptTime::toDateString($busStartDate);
        $cacheKey     = "{$mainTripId}|{$subtripId}|{$busStartDate}|{$pickStandId}|board";

        if (isset($this->boardingDateCache[$cacheKey])) {
            return $this->boardingDateCache[$cacheKey];
        }

        $mainTrip = $this->getTrip($mainTripId);

        if (!$mainTrip) {
            return $this->boardingDateCache[$cacheKey] = $busStartDate;
        }

        $pickupDayOffset = $this->getPickupDayOffset($mainTrip, $mainTripId, $subtripId, $pickStandId);

        if ($pickupDayOffset === null) {
            return $this->boardingDateCache[$cacheKey] = $busStartDate;
        }

        return $this->boardingDateCache[$cacheKey] = date('Y-m-d', strtotime($busStartDate . " +{$pickupDayOffset} day"));
    }

    /**
     * Passenger boarding day for tickets.journeydata (ticket list / invoices).
     *
     * @param string $busStartDate Normalized bus run day from getManifestDateForJourneylist()
     */
    public function getTicketJourneyDateForStorage(
        int $mainTripId,
        int $subtripId,
        string $busStartDate,
        int $pickStandId
    ): string {
        $busStartDate = EgyptTime::toDateString($busStartDate);

        if ($mainTripId <= 0 || $busStartDate === '1970-01-01') {
            return $busStartDate;
        }

        return $this->getBoardingDateFromBusStartDate($mainTripId, $subtripId, $busStartDate, $pickStandId);
    }

    /**
     * Bus run day(s) from tickets.journeydata for inventory (handles legacy mis-storage).
     *
     * @return list<string>
     */
    public function resolveBusRunDaysFromTicketJourneyData(
        int $mainTripId,
        int $subtripId,
        string $ticketJourneyData,
        int $pickStandId
    ): array {
        $busRun = $this->resolveBusRunDayFromTicketJourneyData(
            $mainTripId,
            $subtripId,
            $ticketJourneyData,
            $pickStandId
        );

        return [$busRun];
    }

    /**
     * Bus run day from tickets.journeydata (boarding day, or legacy rows that stored bus run there).
     */
    public function resolveBusRunDayFromTicketJourneyData(
        int $mainTripId,
        int $subtripId,
        string $ticketJourneyData,
        int $pickStandId,
        ?string $manifestJourneyDate = null
    ): string {
        $ticketDay = EgyptTime::toDateString($ticketJourneyData);

        if ($mainTripId <= 0 || $ticketDay === '1970-01-01') {
            return $ticketDay;
        }

        if ($manifestJourneyDate !== null && $manifestJourneyDate !== '') {
            $manifest = EgyptTime::toDateString($manifestJourneyDate);
            if ($manifest !== '1970-01-01') {
                if ($manifest === $ticketDay) {
                    return $ticketDay;
                }

                return $manifest;
            }
        }

        $runIfBoarding = $this->boardingDayToBusRunDate($mainTripId, $subtripId, $ticketDay, $pickStandId);
        if ($this->getBoardingDateFromBusStartDate($mainTripId, $subtripId, $runIfBoarding, $pickStandId) === $ticketDay
            && $runIfBoarding !== $ticketDay) {
            return $runIfBoarding;
        }

        $boardingIfRun = $this->getBoardingDateFromBusStartDate(
            $mainTripId,
            $subtripId,
            $ticketDay,
            $pickStandId
        );
        if ($boardingIfRun !== $ticketDay
            && $this->boardingDayToBusRunDate($mainTripId, $subtripId, $boardingIfRun, $pickStandId) === $ticketDay) {
            return $ticketDay;
        }

        return $runIfBoarding;
    }

    /**
     * @deprecated Booking passes bus run day; use getManifestDateForJourneylist() or boardingDayToBusRunDate().
     */
    public function resolveServiceRunDateForInventory(
        int $mainTripId,
        int $subtripId,
        string $calendarDate,
        int $pickStandId
    ): string {
        return $this->getManifestDateForJourneylist($mainTripId, $subtripId, $calendarDate, $pickStandId);
    }

    /**
     * Journey date for ticket list, invoices, and API — normalized ticket/manifest day.
     */
    public function getPassengerBoardingDateForDisplay(
        int $mainTripId,
        int $subtripId,
        string $storedJourneyDate,
        int $pickStandId,
        ?string $manifestJourneyDate = null
    ): string {
        $stored = EgyptTime::toDateString($storedJourneyDate);
        if ($stored === '1970-01-01' || $mainTripId <= 0) {
            return $stored;
        }

        $manifestKey = $manifestJourneyDate !== null && $manifestJourneyDate !== ''
            ? EgyptTime::toDateString($manifestJourneyDate)
            : '';
        $cacheKey    = "{$mainTripId}|{$subtripId}|{$stored}|{$pickStandId}|{$manifestKey}";
        if (isset($this->displayDateCache[$cacheKey])) {
            return $this->displayDateCache[$cacheKey];
        }

        $manifest = null;
        if ($manifestJourneyDate !== null && $manifestJourneyDate !== '') {
            $manifest = EgyptTime::toDateString($manifestJourneyDate);
            if ($manifest !== '1970-01-01' && $manifest !== $stored) {
                return $this->displayDateCache[$cacheKey] = $stored;
            }
        }

        $mainTrip = $this->getTrip($mainTripId);
        $offset   = ($mainTrip && $subtripId > 0 && $pickStandId > 0)
            ? $this->getPickupDayOffset($mainTrip, $mainTripId, $subtripId, $pickStandId)
            : null;

        if ($offset !== null && $offset >= 1 && $subtripId > 0 && $pickStandId > 0) {
            $busRunIfStoredIsBoarding = $this->boardingDayToBusRunDate($mainTripId, $subtripId, $stored, $pickStandId);
            $boardingFromStoredAsRun  = $this->getTicketJourneyDateForStorage(
                $mainTripId,
                $subtripId,
                $stored,
                $pickStandId
            );
            $boardingIfStoredIsBusRun = $this->getBoardingDateFromBusStartDate(
                $mainTripId,
                $subtripId,
                $stored,
                $pickStandId
            );
            $canonicalBoardingStored  = $busRunIfStoredIsBoarding !== $stored
                && $this->getTicketJourneyDateForStorage($mainTripId, $subtripId, $busRunIfStoredIsBoarding, $pickStandId) === $stored;
            $storedActsAsBusRun       = $boardingFromStoredAsRun !== $stored
                && $boardingFromStoredAsRun === $boardingIfStoredIsBusRun
                && ! $canonicalBoardingStored;

            // Canonical: tickets.journeydata already holds passenger boarding day (TBOB9N5YFM → 2026-08-11).
            if ($canonicalBoardingStored) {
                return $this->displayDateCache[$cacheKey] = $stored;
            }

            // Legacy: tickets.journeydata holds bus run / service day (TBHUXO0JRK → 2026-08-10 → show 2026-08-11).
            if ($storedActsAsBusRun || ($manifest !== null && $manifest === $stored && $boardingFromStoredAsRun !== $stored)) {
                return $this->displayDateCache[$cacheKey] = $boardingFromStoredAsRun;
            }

            return $this->displayDateCache[$cacheKey] = $stored;
        }

        // Same-day pickup: tickets.journeydata is boarding day (or legacy bus run = boarding).
        if ($subtripId > 0 && $pickStandId > 0) {
            $runIfBoarding = $this->boardingDayToBusRunDate($mainTripId, $subtripId, $stored, $pickStandId);
            if ($this->getBoardingDateFromBusStartDate($mainTripId, $subtripId, $runIfBoarding, $pickStandId) === $stored) {
                return $this->displayDateCache[$cacheKey] = $stored;
            }

            $boardingIfStoredIsRun = $this->getBoardingDateFromBusStartDate(
                $mainTripId,
                $subtripId,
                $stored,
                $pickStandId
            );
            if ($boardingIfStoredIsRun !== $stored) {
                $recomputedRun = $this->boardingDayToBusRunDate(
                    $mainTripId,
                    $subtripId,
                    $boardingIfStoredIsRun,
                    $pickStandId
                );
                if ($recomputedRun === $stored) {
                    return $this->displayDateCache[$cacheKey] = $boardingIfStoredIsRun;
                }
            }
        }

        return $this->displayDateCache[$cacheKey] = $stored;
    }

    /**
     * One query for journeylists service dates keyed by booking_id (ticket list page batch).
     *
     * @param list<string> $bookingIds
     * @return array<string, string>
     */
    public function batchManifestDatesByBookingIds(array $bookingIds): array
    {
        $bookingIds = array_values(array_unique(array_filter(array_map(static function ($id) {
            return trim((string) $id);
        }, $bookingIds))));

        if ($bookingIds === []) {
            return [];
        }

        $map = [];
        $builder = $this->pickdropModel->db->table('journeylists');
        $builder->select('booking_id, MIN(DATE(journeydate)) AS manifest_journeydate', false)
            ->whereIn('booking_id', $bookingIds)
            ->where('deleted_at', null)
            ->groupBy('booking_id');

        foreach ($builder->get()->getResultArray() as $row) {
            $bid = trim((string) ($row['booking_id'] ?? ''));
            $day = EgyptTime::toDateString((string) ($row['manifest_journeydate'] ?? ''));
            if ($bid !== '' && $day !== '1970-01-01') {
                $map[$bid] = $day;
            }
        }

        return $map;
    }

    /**
     * Calendar dates to match when searching the ticket list by journey date.
     *
     * @return list<string>
     */
    public function getTicketSearchMatchDates(string $searchDate): array
    {
        $searchDate = date('Y-m-d', strtotime($searchDate));

        return array_values(array_unique([
            $searchDate,
            date('Y-m-d', strtotime($searchDate . ' -1 day')),
            date('Y-m-d', strtotime($searchDate . ' +1 day')),
        ]));
    }

    /**
     * Pre-load trips, schedules, and pickstands for a ticket/journey list page (no per-row display work).
     *
     * @param iterable<object> $rows Objects with trip_id, pick_stand_id
     */
    public function warmBoardingDateCache(iterable $rows): void
    {
        $tripIds      = [];
        $pickStandIds = [];

        foreach ($rows as $row) {
            $tripId = (int) ($row->trip_id ?? 0);
            if ($tripId > 0) {
                $tripIds[$tripId] = $tripId;
            }

            $pickStandId = (int) ($row->pick_stand_id ?? 0);
            if ($pickStandId > 0) {
                $pickStandIds[$pickStandId] = $pickStandId;
            }
        }

        foreach ($tripIds as $tripId) {
            $trip = $this->getTrip($tripId);
            $this->getMainSubtripPickdrops($tripId);
            if ($trip) {
                $this->getSchedule((int) ($trip->schedule_id ?? 0));
            }
        }

        if ($pickStandIds !== []) {
            foreach ($this->pickdropModel->whereIn('id', array_values($pickStandIds))->findAll() as $pickdrop) {
                $this->pickdropByIdCache[(int) $pickdrop->id] = $pickdrop;
            }
        }
    }

    /**
     * Normalize time string to 24h format for comparison (e.g. "11:30PM" -> "23:30").
     */
    protected function normalizeTime(string $time): string
    {
        $time = trim($time);
        if (empty($time)) {
            return '00:00';
        }
        $timestamp = strtotime('1970-01-01 ' . $time);

        return $timestamp !== false ? date('H:i', $timestamp) : '00:00';
    }

    protected function getTrip(int $mainTripId): ?object
    {
        if ($mainTripId <= 0) {
            return null;
        }

        if (!array_key_exists($mainTripId, $this->tripCache)) {
            $this->tripCache[$mainTripId] = $this->tripModel->find($mainTripId) ?: null;
        }

        return $this->tripCache[$mainTripId];
    }

    protected function getSchedule(int $scheduleId): ?object
    {
        if ($scheduleId <= 0) {
            return null;
        }

        if (!array_key_exists($scheduleId, $this->scheduleCache)) {
            $this->scheduleCache[$scheduleId] = $this->scheduleModel->find($scheduleId) ?: null;
        }

        return $this->scheduleCache[$scheduleId];
    }

    /**
     * Return how many days after the bus start date this pickup happens.
     */
    protected function getPickupDayOffset(object $mainTrip, int $mainTripId, int $subtripId, int $pickStandId): ?int
    {
        $schedule          = $this->getSchedule((int) ($mainTrip->schedule_id ?? 0));
        $mainTripStartTime = $schedule->start_time ?? '00:00';

        $mainPickdrops = $this->getMainSubtripPickdrops($mainTripId);
        if (! empty($mainPickdrops)) {
            $offset = $this->getOffsetFromPickdropSequence($mainPickdrops, $mainTripStartTime, $pickStandId);
            if ($offset !== null) {
                return $offset;
            }
        }

        // Subtrip-only pickups (e.g. BB @ 03:00) must win before stand_id alias on the main route.
        $offset = $this->getOffsetFromActualPickup($subtripId, $pickStandId, $mainTripStartTime);
        if ($offset !== null) {
            return $offset;
        }

        if (! empty($mainPickdrops)) {
            $offset = $this->getOffsetFromMainRouteByPickdropStand($mainPickdrops, $mainTripStartTime, $pickStandId);
            if ($offset !== null) {
                return $offset;
            }
        }

        return null;
    }

    /**
     * Match a subtrip-only pickup stand to the main trip pickdrop list by stand_id.
     */
    protected function getOffsetFromMainRouteByPickdropStand(array $mainPickdrops, string $mainTripStartTime, int $pickStandId): ?int
    {
        $pickdrop = $this->getPickdropById($pickStandId);
        if (!$pickdrop) {
            return null;
        }

        $standId = (int) ($pickdrop->stand_id ?? 0);
        if ($standId <= 0) {
            return null;
        }

        $subtripTime = $this->normalizeTime($pickdrop->time ?? '00:00');

        foreach ($mainPickdrops as $item) {
            if ((int) ($item->stand_id ?? 0) !== $standId) {
                continue;
            }

            // Same stand_id at different times is a different boarding day (e.g. AA 9:30 PM vs BB 3:00 AM).
            if ($this->normalizeTime($item->time ?? '00:00') !== $subtripTime) {
                continue;
            }

            return $this->getOffsetFromPickdropSequence($mainPickdrops, $mainTripStartTime, (int) $item->id);
        }

        return null;
    }

    protected function getMainSubtripPickdrops(int $mainTripId): array
    {
        if (array_key_exists($mainTripId, $this->mainPickdropsCache)) {
            return $this->mainPickdropsCache[$mainTripId];
        }

        $mainSubtrip = $this->subtripModel
            ->where('trip_id', $mainTripId)
            ->where('type', 'main')
            ->first();

        if (!$mainSubtrip) {
            return $this->mainPickdropsCache[$mainTripId] = [];
        }

        // pickdrops.trip_id is usually subtrips.id; legacy main-route rows use trips.id (see loadPickdropsForBookingForm).
        $tripIds = array_values(array_unique([
            (int) $mainSubtrip->id,
            $mainTripId,
        ]));

        return $this->mainPickdropsCache[$mainTripId] = $this->pickdropModel
            ->select('pickdrops.id, pickdrops.stand_id, pickdrops.time, pickdrops.type')
            ->whereIn('trip_id', $tripIds)
            ->orderBy('pickdrops.id', 'ASC')
            ->findAll();
    }

    protected function getOffsetFromPickdropSequence(array $pickdrops, string $mainTripStartTime, int $pickStandId): ?int
    {
        $targetStandId  = $this->resolveTargetStandId($pickdrops, $pickStandId);
        $targetPickdrop = $pickStandId > 0 ? $this->getPickdropById($pickStandId) : null;
        $targetTime     = $targetPickdrop !== null
            ? $this->normalizeTime($targetPickdrop->time ?? '00:00')
            : null;

        $lastStopTime  = $mainTripStartTime;
        $currentOffset = 0;

        foreach ($pickdrops as $item) {
            $itemTime     = $this->normalizeTime($item->time ?? '00:00');
            $lastStopTime = $this->normalizeTime($lastStopTime);

            if (strtotime('1970-01-01 ' . $itemTime) < strtotime('1970-01-01 ' . $lastStopTime)) {
                $currentOffset++;
            }
            $lastStopTime = $itemTime;

            if ($pickStandId > 0 && (int) $item->id === $pickStandId) {
                return $currentOffset;
            }

            if ($targetStandId !== null && (int) $item->stand_id === $targetStandId) {
                if ($targetTime !== null && $itemTime !== $targetTime) {
                    continue;
                }

                return $currentOffset;
            }
        }

        return null;
    }

    protected function resolveTargetStandId(array $pickdrops, int $pickStandId): ?int
    {
        if ($pickStandId <= 0) {
            return null;
        }

        foreach ($pickdrops as $item) {
            if ((int) $item->id === $pickStandId) {
                return (int) $item->stand_id;
            }
        }

        $pdRecord = $this->getPickdropById($pickStandId);

        return $pdRecord ? (int) $pdRecord->stand_id : $pickStandId;
    }

    protected function getPickdropById(int $pickStandId): ?object
    {
        if ($pickStandId <= 0) {
            return null;
        }

        if (!array_key_exists($pickStandId, $this->pickdropByIdCache)) {
            $this->pickdropByIdCache[$pickStandId] = $this->pickdropModel
                ->select('id, stand_id, time, type, trip_id')
                ->find($pickStandId) ?: null;
        }

        return $this->pickdropByIdCache[$pickStandId];
    }

    protected function getSubtripPickups(int $subtripId): array
    {
        if ($subtripId <= 0) {
            return [];
        }

        if (array_key_exists($subtripId, $this->subtripPickupsCache)) {
            return $this->subtripPickupsCache[$subtripId];
        }

        return $this->subtripPickupsCache[$subtripId] = $this->pickdropModel
            ->select('id, stand_id, time, type')
            ->where('trip_id', $subtripId)
            ->where('type', 1)
            ->orderBy('id', 'ASC')
            ->findAll();
    }

    protected function getOffsetFromActualPickup(int $subtripId, int $pickStandId, string $mainTripStartTime): ?int
    {
        if ($pickStandId <= 0) {
            return null;
        }

        $pickdrop = $this->getPickdropById($pickStandId);

        if (!$pickdrop) {
            $subtripPickups = $this->getSubtripPickups($subtripId);
            foreach ($subtripPickups as $row) {
                if ((int) $row->stand_id === $pickStandId) {
                    $pickdrop = $row;
                    break;
                }
            }
        }

        if (!$pickdrop) {
            return null;
        }

        $subtripPickups = $this->getSubtripPickups($subtripId);

        if (!empty($subtripPickups)) {
            $offset = $this->getOffsetFromPickdropSequence($subtripPickups, $mainTripStartTime, $pickStandId);
            if ($offset !== null) {
                return $offset;
            }
        }

        $pickupTime = $this->normalizeTime($pickdrop->time ?? '00:00');
        $startTime  = $this->normalizeTime($mainTripStartTime);

        return strtotime('1970-01-01 ' . $pickupTime) < strtotime('1970-01-01 ' . $startTime) ? 1 : 0;
    }

    /**
     * Canonical bus run day for a journeylists row (one physical overnight departure).
     *
     * Prefer tickets.journeydata (boarding day) when available — it uniquely identifies the run.
     */
    public function resolveManifestBusRunDay(
        int $mainTripId,
        int $subtripId,
        string $storedJourneyListDate,
        int $pickStandId,
        ?string $ticketJourneyData = null
    ): string {
        $stored = EgyptTime::toDateString($storedJourneyListDate);

        if ($mainTripId <= 0 || $pickStandId <= 0 || $stored === '1970-01-01') {
            return $stored;
        }

        // journeylists.journeydate is authoritative when it already stores the bus run day.
        if ($this->getBoardingDateFromBusStartDate($mainTripId, $subtripId, $stored, $pickStandId) !== $stored) {
            return $stored;
        }

        $ticketDay = $ticketJourneyData !== null && trim($ticketJourneyData) !== ''
            ? EgyptTime::toDateString($ticketJourneyData)
            : null;

        if ($ticketDay !== null && $ticketDay !== '1970-01-01') {
            return $this->resolveBusRunDayFromTicketJourneyData(
                $mainTripId,
                $subtripId,
                $ticketDay,
                $pickStandId
            );
        }

        return $this->boardingDayToBusRunDate($mainTripId, $subtripId, $stored, $pickStandId);
    }

    /**
     * Bus run day for a journeylists.journeydate value (legacy rows may have boarding day stored).
     */
    public function resolveManifestDateFromJourneyListRow(
        int $mainTripId,
        int $subtripId,
        string $storedJourneyListDate,
        int $pickStandId
    ): string {
        return $this->resolveManifestBusRunDay(
            $mainTripId,
            $subtripId,
            $storedJourneyListDate,
            $pickStandId
        );
    }

    /**
     * @deprecated Use rowMatchesJourneyListManifestSearch() with trip/subtrip/pick context.
     */
    public function rowMatchesJourneyListSearchDate(string $busStartDate, string $boardingDate, string $searchDate): bool
    {
        unset($boardingDate);

        return date('Y-m-d', strtotime($busStartDate)) === date('Y-m-d', strtotime($searchDate));
    }

    /**
     * Journey list = bus run day only. One overnight run appears on exactly one manifest date.
     *
     * @param string|null $ticketJourneyData Boarding day from tickets.journeydata when joined.
     */
    public function rowMatchesJourneyListManifestSearch(
        int $mainTripId,
        int $subtripId,
        string $storedJourneyListDate,
        int $pickStandId,
        string $manifestSearchDate,
        ?string $ticketJourneyData = null
    ): bool {
        $search = date('Y-m-d', strtotime($manifestSearchDate));
        $stored = date('Y-m-d', strtotime($storedJourneyListDate));

        if ($search === '1970-01-01' || $stored === '1970-01-01') {
            return false;
        }

        if ($mainTripId <= 0 || $pickStandId <= 0) {
            return $stored === $search;
        }

        $mainTrip = $this->getTrip($mainTripId);
        $offset   = $mainTrip
            ? $this->getPickupDayOffset($mainTrip, $mainTripId, $subtripId, $pickStandId)
            : null;

        $boardingForSearch = $this->getBoardingDateFromBusStartDate(
            $mainTripId,
            $subtripId,
            $search,
            $pickStandId
        );
        $maxBoardingOnRun = $this->getMaxBoardingDateForManifest($mainTripId, $subtripId, $search);

        // Legacy jl stored passenger boarding day for this bus run.
        if ($stored === $boardingForSearch) {
            return $this->boardingDayWithinManifestWindow($stored, $search, $maxBoardingOnRun);
        }

        $ticketDay = $ticketJourneyData !== null && trim($ticketJourneyData) !== ''
            ? EgyptTime::toDateString($ticketJourneyData)
            : null;

        if ($ticketDay !== null && $ticketDay !== '1970-01-01' && $ticketDay === $boardingForSearch) {
            return $this->boardingDayWithinManifestWindow($ticketDay, $search, $maxBoardingOnRun);
        }

        // Canonical jl stores bus run day (new bookings); same-day pickups also use stored === search.
        if ($stored === $search) {
            if ($offset === null || $offset <= 0) {
                return true;
            }

            return $boardingForSearch !== $stored;
        }

        if ($ticketDay !== null && $ticketDay !== '1970-01-01') {
            if ($this->boardingDayToBusRunDate($mainTripId, $subtripId, $ticketDay, $pickStandId) === $search) {
                return $this->boardingDayWithinManifestWindow($ticketDay, $search, $maxBoardingOnRun);
            }
        }

        return false;
    }

    /**
     * Last passenger boarding calendar day on one physical bus run (manifest search date = bus run day).
     * Overnight with offset 0 + 1 → two days only (e.g. 30 Jun + 1 Jul, not 2 Jul).
     */
    public function getMaxBoardingDateForManifest(int $mainTripId, int $subtripId, string $manifestSearchDate): string
    {
        $search = EgyptTime::toDateString($manifestSearchDate);
        if ($search === '1970-01-01' || $mainTripId <= 0) {
            return $search;
        }

        $max = $search;
        foreach ($this->listSegmentPickupStandIds($mainTripId, $subtripId) as $pickStandId) {
            $boarding = $this->getBoardingDateFromBusStartDate(
                $mainTripId,
                $subtripId,
                $search,
                (int) $pickStandId
            );
            if ($boarding > $max) {
                $max = $boarding;
            }
        }

        return $max;
    }

    /**
     * Boarding day must fall on the manifest bus run window (run day .. max boarding), never a third day.
     */
    protected function boardingDayWithinManifestWindow(
        string $boardingDay,
        string $manifestSearchDate,
        string $maxBoardingOnRun
    ): bool {
        $boarding = EgyptTime::toDateString($boardingDay);
        $search     = EgyptTime::toDateString($manifestSearchDate);
        $max        = EgyptTime::toDateString($maxBoardingOnRun);

        if ($boarding === '1970-01-01' || $search === '1970-01-01') {
            return false;
        }

        return $boarding >= $search && $boarding <= $max;
    }

    /**
     * Bus run (manifest) day for one journeylists row — handles legacy jl/ticket storage.
     */
    public function resolveJourneyListRowBusRunDay(
        int $mainTripId,
        int $subtripId,
        string $storedJourneyListDate,
        int $pickStandId,
        ?string $ticketJourneyData = null
    ): string {
        $stored = EgyptTime::toDateString($storedJourneyListDate);

        if ($mainTripId <= 0 || $pickStandId <= 0 || $stored === '1970-01-01') {
            return $stored;
        }

        $mainTrip = $this->getTrip($mainTripId);
        $offset   = $mainTrip
            ? $this->getPickupDayOffset($mainTrip, $mainTripId, $subtripId, $pickStandId)
            : null;

        $ticketDay = $ticketJourneyData !== null && trim($ticketJourneyData) !== ''
            ? EgyptTime::toDateString($ticketJourneyData)
            : null;

        $boardingIfStoredIsRun = $this->getBoardingDateFromBusStartDate(
            $mainTripId,
            $subtripId,
            $stored,
            $pickStandId
        );

        // Canonical jl bus run day (stored service date for overnight run).
        if ($boardingIfStoredIsRun !== $stored
            && $this->boardingDayToBusRunDate($mainTripId, $subtripId, $boardingIfStoredIsRun, $pickStandId) === $stored) {
            return $stored;
        }

        $runIfStoredIsBoarding = $this->boardingDayToBusRunDate($mainTripId, $subtripId, $stored, $pickStandId);
        if ($this->getBoardingDateFromBusStartDate($mainTripId, $subtripId, $runIfStoredIsBoarding, $pickStandId) === $stored
            && $runIfStoredIsBoarding !== $stored) {
            return $runIfStoredIsBoarding;
        }

        if ($ticketDay !== null && $ticketDay !== '1970-01-01') {
            $runFromTicket = $this->boardingDayToBusRunDate($mainTripId, $subtripId, $ticketDay, $pickStandId);
            if ($this->getBoardingDateFromBusStartDate($mainTripId, $subtripId, $runFromTicket, $pickStandId) === $ticketDay
                && $runFromTicket !== $ticketDay) {
                return $runFromTicket;
            }

            $boardingIfTicketIsRun = $this->getBoardingDateFromBusStartDate(
                $mainTripId,
                $subtripId,
                $ticketDay,
                $pickStandId
            );
            if ($boardingIfTicketIsRun !== $ticketDay) {
                return $ticketDay;
            }

            if ($offset !== null && $offset >= 1) {
                $adjustedTicket = EgyptTime::toDateString($ticketDay . ' -1 day');
                if ($adjustedTicket !== '1970-01-01') {
                    $runFromAdjusted = $this->boardingDayToBusRunDate(
                        $mainTripId,
                        $subtripId,
                        $adjustedTicket,
                        $pickStandId
                    );
                    if ($this->getBoardingDateFromBusStartDate(
                        $mainTripId,
                        $subtripId,
                        $runFromAdjusted,
                        $pickStandId
                    ) === $adjustedTicket) {
                        return $runFromAdjusted;
                    }
                }
            }
        }

        if ($offset !== null && $offset >= 1) {
            $adjustedStored = EgyptTime::toDateString($stored . ' -1 day');
            if ($adjustedStored !== '1970-01-01') {
                $runFromAdjustedStored = $this->boardingDayToBusRunDate(
                    $mainTripId,
                    $subtripId,
                    $adjustedStored,
                    $pickStandId
                );
                if ($this->getBoardingDateFromBusStartDate(
                    $mainTripId,
                    $subtripId,
                    $runFromAdjustedStored,
                    $pickStandId
                ) === $adjustedStored) {
                    return $runFromAdjustedStored;
                }
            }
        }

        return $this->boardingDayToBusRunDate($mainTripId, $subtripId, $stored, $pickStandId);
    }

    /**
     * Prefetch journeylists rows for manifest search (±1 day for legacy boarding-day storage).
     *
     * @param \CodeIgniter\Database\BaseBuilder|\CodeIgniter\Model $builder
     */
    public function applyJourneyListManifestDatePrefetch($builder, string $manifestSearchDate): void
    {
        $builder->whereIn(
            'journeylists.journeydate',
            $this->getJourneyListPrefetchDates($manifestSearchDate)
        );
    }

    /**
     * @return list<string>
     */
    public function getJourneyListPrefetchDates(string $manifestSearchDate): array
    {
        $search = date('Y-m-d', strtotime($manifestSearchDate));

        return array_values(array_unique([
            $search,
            date('Y-m-d', strtotime($search . ' +1 day')),
        ]));
    }

    /**
     * Calendar dates to prefetch from journeylists for a manifest search.
     *
     * Includes adjacent days so rows stored with boarding-day journeydate are still loaded.
     *
     * @return string[]
     */
    public function getBusStartDatesForBoardingSearch(string $searchDate): array
    {
        return $this->getTicketSearchMatchDates($searchDate);
    }

    /**
     * Manifest day to store on journeylists at booking time (always bus run day).
     */
    public function resolveManifestDateForJourneyListStorage(
        int $mainTripId,
        int $subtripId,
        string $calendarDate,
        int $pickStandId
    ): string {
        return $this->getManifestDateForJourneylist(
            $mainTripId,
            $subtripId,
            date('Y-m-d', strtotime($calendarDate)),
            $pickStandId
        );
    }

    /**
     * Parse a user search string that is meant to be a calendar date (Y-m-d, d-m-Y, d/m/Y).
     */
    public static function parseSearchDate(string $value): ?string
    {
        $value = trim($value);
        if ($value === '') {
            return null;
        }

        $formats = ['Y-m-d', 'd-m-Y', 'd/m/Y', 'm/d/Y'];
        foreach ($formats as $format) {
            $dt = \DateTime::createFromFormat($format, $value);
            if ($dt && $dt->format($format) === $value) {
                return $dt->format('Y-m-d');
            }
        }

        if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $value)) {
            return date('Y-m-d', strtotime($value));
        }

        return null;
    }

    /**
     * Match tickets.journeydata for ticket-list date search (stored journey date).
     */
    public function applyTicketBoardingDateSearch(\CodeIgniter\Database\BaseBuilder $builder, string $searchDate): void
    {
        $matchDates = $this->getTicketSearchMatchDates($searchDate);

        $builder->groupStart();
        foreach ($matchDates as $i => $d) {
            if ($i === 0) {
                $builder->where('DATE(tickets.journeydata)', $d);
            } else {
                $builder->orWhere('DATE(tickets.journeydata)', $d);
            }
        }
        $builder->groupEnd();
    }

    /**
     * Physical bus run (service date) for a passenger boarding-day search on one subtrip.
     */
    public function resolveCanonicalServiceDateForBoardingSearch(
        int $mainTripId,
        int $subtripId,
        string $boardingSearchDate
    ): ?string {
        $pickups = $this->pickStandIdsBoardingOnSearchDate($mainTripId, $subtripId, $boardingSearchDate);
        if ($pickups === []) {
            return null;
        }

        $search = EgyptTime::toDateString($boardingSearchDate);
        $mainTrip = $this->getTrip($mainTripId);
        if (! $mainTrip) {
            return $search;
        }

        foreach ([$search, EgyptTime::toDateString($search . ' -1 day'), EgyptTime::toDateString($search . ' -2 day')] as $serviceDate) {
            foreach ($pickups as $pickStandId) {
                if ($this->getBoardingDateFromBusStartDate($mainTripId, $subtripId, $serviceDate, $pickStandId) === $search) {
                    return $serviceDate;
                }
            }
        }

        return $search;
    }

    /**
     * Pickup stands on a bus run whose manifest day equals the admin search date.
     *
     * @return list<int>
     */
    public function pickStandIdsForServiceSearchDate(int $mainTripId, int $subtripId, string $serviceSearchDate): array
    {
        $search = EgyptTime::toDateString($serviceSearchDate);
        if ($search === '1970-01-01' || $mainTripId <= 0) {
            return [];
        }

        $ids = [];
        foreach ($this->listSegmentPickupStandIds($mainTripId, $subtripId) as $pickStandId) {
            $boarding = $this->getBoardingDateFromBusStartDate($mainTripId, $subtripId, $search, $pickStandId);
            if ($boarding !== '1970-01-01') {
                $ids[] = $pickStandId;
            }
        }

        return $ids;
    }

    /**
     * Pickup stand IDs (pickdrops.id) whose boarding calendar day equals the search date on one bus run.
     *
     * @return list<int>
     */
    public function pickStandIdsBoardingOnSearchDate(int $mainTripId, int $subtripId, string $boardingSearchDate): array
    {
        $search = EgyptTime::toDateString($boardingSearchDate);
        if ($search === '1970-01-01' || $mainTripId <= 0) {
            return [];
        }

        $pickups = $this->listSegmentPickupStandIds($mainTripId, $subtripId);
        if ($pickups === []) {
            return [];
        }

        $mainTrip    = $this->getTrip($mainTripId);
        $prevService = EgyptTime::toDateString($search . ' -1 day');

        $sameDayIds = [];
        $overnightIds = [];

        foreach ($pickups as $pickStandId) {
            if ($this->getBoardingDateFromBusStartDate($mainTripId, $subtripId, $search, $pickStandId) === $search) {
                $sameDayIds[] = $pickStandId;
            }
            if ($this->getBoardingDateFromBusStartDate($mainTripId, $subtripId, $prevService, $pickStandId) === $search) {
                $overnightIds[] = $pickStandId;
            }
        }

        $sameDayHasEvening = false;
        if ($mainTrip && $sameDayIds !== []) {
            foreach ($sameDayIds as $pickStandId) {
                if ($this->getPickupDayOffset($mainTrip, $mainTripId, $subtripId, $pickStandId) === 0) {
                    $sameDayHasEvening = true;
                    break;
                }
            }
        }

        // Same calendar evening (offset 0) on service date = search day — e.g. 16 Aug 10 PM / 11 PM.
        if ($sameDayHasEvening) {
            return $sameDayIds;
        }

        // Morning / after-midnight boarders on the bus that left the previous evening — e.g. 17 Aug 1 AM / 4 AM.
        if ($overnightIds !== []) {
            return $overnightIds;
        }

        if ($sameDayIds !== []) {
            return $sameDayIds;
        }

        return [];
    }

    /**
     * True when this segment has an evening (offset 0) pickup boarding on the search calendar day.
     */
    public function segmentHasEveningPickupOnSearchDate(int $mainTripId, int $subtripId, string $searchDate): bool
    {
        $search = EgyptTime::toDateString($searchDate);
        if ($search === '1970-01-01' || $mainTripId <= 0) {
            return false;
        }

        $mainTrip = $this->getTrip($mainTripId);
        if (! $mainTrip) {
            return false;
        }

        foreach ($this->listSegmentPickupStandIds($mainTripId, $subtripId) as $pickStandId) {
            if ($this->getBoardingDateFromBusStartDate($mainTripId, $subtripId, $search, $pickStandId) !== $search) {
                continue;
            }
            if ($this->getPickupDayOffset($mainTrip, $mainTripId, $subtripId, $pickStandId) === 0) {
                return true;
            }
        }

        return false;
    }

    /**
     * @param list<int> $pickStandIds
     */
    protected function anyPickupBoardsOnServiceDate(
        int $mainTripId,
        int $subtripId,
        array $pickStandIds,
        string $serviceDate,
        string $boardingSearchDate
    ): bool {
        foreach ($pickStandIds as $pickStandId) {
            if ($this->getBoardingDateFromBusStartDate($mainTripId, $subtripId, $serviceDate, $pickStandId) === $boardingSearchDate) {
                return true;
            }
        }

        return false;
    }

    /**
     * @return list<int>
     */
    public function listSegmentPickupStandIds(int $mainTripId, int $subtripId): array
    {
        $ids = [];
        foreach ($this->getBookableSegmentPickups($mainTripId, $subtripId) as $pickup) {
            if ((int) ($pickup->type ?? 0) !== 1) {
                continue;
            }
            $id = (int) ($pickup->id ?? 0);
            if ($id > 0) {
                $ids[] = $id;
            }
        }

        return $ids;
    }

    /**
     * True when this subtrip has both same-day and after-midnight pickup stands (cross-midnight run).
     */
    public function tripSpansMultipleBoardingCalendarDays(int $mainTripId, int $subtripId): bool
    {
        $pickups = $this->listSegmentPickupStandIds($mainTripId, $subtripId);
        if (count($pickups) < 2) {
            return false;
        }

        $mainTrip = $this->getTrip($mainTripId);
        if ($mainTrip === null) {
            return false;
        }

        $hasSameDayPickup = false;
        $hasAfterMidnight = false;
        foreach ($pickups as $pickStandId) {
            $offset = $this->getPickupDayOffset($mainTrip, $mainTripId, $subtripId, (int) $pickStandId);
            if ($offset === 0) {
                $hasSameDayPickup = true;
            }
            if ($offset !== null && $offset > 0) {
                $hasAfterMidnight = true;
            }
        }

        return $hasSameDayPickup && $hasAfterMidnight;
    }

    /**
     * Absolute boarding datetime at a pickup stand on a physical bus run (service date).
     */
    public function getBoardingDateTime(int $mainTripId, int $subtripId, string $serviceDate, int $pickStandId): string
    {
        $serviceDate = EgyptTime::toDateString($serviceDate);
        if ($serviceDate === '1970-01-01') {
            return '1970-01-01 00:00:00';
        }

        $pickdrop = $this->getPickdropById($pickStandId);
        $time     = $this->normalizeTime($pickdrop->time ?? '00:00');
        $boarding = $this->getBoardingDateFromBusStartDate($mainTripId, $subtripId, $serviceDate, $pickStandId);

        return EgyptTime::parse($boarding . ' ' . $time)->format('Y-m-d H:i:s');
    }

    /**
     * Passenger journey list / ticket list: match by boarding day at pickup, not bus service date.
     */
    public function rowMatchesPassengerBoardingListSearch(
        int $mainTripId,
        int $subtripId,
        int $pickStandId,
        string $boardingSearchDate,
        ?string $ticketJourneyData = null,
        ?string $manifestServiceDate = null
    ): bool {
        $search = EgyptTime::toDateString($boardingSearchDate);
        if ($search === '1970-01-01') {
            return false;
        }

        if ($ticketJourneyData !== null && trim($ticketJourneyData) !== '') {
            $ticketDay = EgyptTime::toDateString($ticketJourneyData);
            if ($ticketDay === $search) {
                return true;
            }

            if ($mainTripId > 0 && $pickStandId > 0
                && $this->getBoardingDateFromBusStartDate($mainTripId, $subtripId, $ticketDay, $pickStandId) === $search) {
                return true;
            }
        }

        if ($manifestServiceDate !== null && trim($manifestServiceDate) !== '' && $mainTripId > 0 && $pickStandId > 0) {
            $manifestDay = EgyptTime::toDateString($manifestServiceDate);
            if ($this->getBoardingDateFromBusStartDate($mainTripId, $subtripId, $manifestDay, $pickStandId) === $search) {
                return true;
            }
        }

        return false;
    }

    /**
     * Service dates (bus run days) that may contain passengers boarding on $boardingSearchDate.
     *
     * @return list<string>
     */
    public function getServiceDatesForBoardingSearch(string $boardingSearchDate): array
    {
        $search = EgyptTime::toDateString($boardingSearchDate);

        return array_values(array_unique([
            $search,
            date('Y-m-d', strtotime($search . ' -1 day')),
            date('Y-m-d', strtotime($search . ' -2 day')),
        ]));
    }

    /**
     * Prefetch journeylists for passenger boarding-date search (not driver service-date manifest).
     *
     * @param \CodeIgniter\Database\BaseBuilder|\CodeIgniter\Model $builder
     */
    public function applyJourneyListBoardingDatePrefetch($builder, string $boardingSearchDate): void
    {
        $manifestDates = $this->getServiceDatesForBoardingSearch($boardingSearchDate);
        $ticketDates   = $this->getTicketSearchMatchDates($boardingSearchDate);

        $builder->groupStart();
        $builder->whereIn('journeylists.journeydate', $manifestDates);
        $builder->orGroupStart();
        foreach ($ticketDates as $i => $d) {
            if ($i === 0) {
                $builder->where('DATE(tickets.journeydata)', $d);
            } else {
                $builder->orWhere('DATE(tickets.journeydata)', $d);
            }
        }
        $builder->groupEnd();
        $builder->groupEnd();
    }

    /**
     * Main route has at least one pickup at or after the scheduled departure time (same calendar evening).
     */
    protected function mainRouteHasSameEveningPickup(int $mainTripId): bool
    {
        $mainTrip = $this->getTrip($mainTripId);
        if (! $mainTrip) {
            return false;
        }

        $schedule          = $this->getSchedule((int) ($mainTrip->schedule_id ?? 0));
        $mainTripStartTime = $this->normalizeTime($schedule->start_time ?? '00:00');
        $mainPickdrops     = $this->getMainSubtripPickdrops($mainTripId);

        foreach ($mainPickdrops as $pickup) {
            if ((int) ($pickup->type ?? 0) !== 1) {
                continue;
            }

            $pickupTime = $this->normalizeTime($pickup->time ?? '00:00');
            if (strtotime('1970-01-01 ' . $pickupTime) >= strtotime('1970-01-01 ' . $mainTripStartTime)) {
                return true;
            }
        }

        return false;
    }

    /**
     * True when every pickup on the booked segment is after midnight relative to the main schedule start.
     */
    protected function segmentHasOnlyAfterMidnightPickups(int $mainTripId, int $subtripId): bool
    {
        $mainTrip = $this->getTrip($mainTripId);
        if (! $mainTrip) {
            return false;
        }

        $schedule          = $this->getSchedule((int) ($mainTrip->schedule_id ?? 0));
        $mainTripStartTime = $this->normalizeTime($schedule->start_time ?? '00:00');
        $pickups           = $this->getBookableSegmentPickups($mainTripId, $subtripId);

        if ($pickups === []) {
            return false;
        }

        foreach ($pickups as $pickup) {
            $pickupTime = $this->normalizeTime($pickup->time ?? '00:00');
            if (strtotime('1970-01-01 ' . $pickupTime) >= strtotime('1970-01-01 ' . $mainTripStartTime)) {
                return false;
            }
        }

        return true;
    }

    /**
     * Pickup points for the segment being booked (main route or subtrip).
     *
     * @return array<int, object>
     */
    protected function getBookableSegmentPickups(int $mainTripId, int $subtripId): array
    {
        $subtrip = $this->subtripModel->find($subtripId);
        if (! $subtrip) {
            return $this->getMainSubtripPickdrops($mainTripId);
        }

        if (($subtrip->type ?? '') === 'main') {
            return $this->getMainSubtripPickdrops($mainTripId);
        }

        $subtripPickups = $this->getSubtripPickups($subtripId);

        return $subtripPickups !== [] ? $subtripPickups : $this->getMainSubtripPickdrops($mainTripId);
    }
}
