<?php
/**
 * ApiController — mobile API for the driver app.
 *
 * URL convention:  /Api/<action>
 *   e.g. POST /Api/login,  GET /Api/me,  POST /Api/pushPosition
 *
 * Auth: Bearer token in the Authorization header (also accepts ?token= for
 * GET convenience during testing). Login/health/logout skip auth. Every other
 * endpoint calls requireAuth() at the top.
 *
 * Response envelope:
 *   Success:  {"ok": true,  "data": {...} }
 *   Failure:  {"ok": false, "msg":  "...", "code": "..."}   with HTTP 4xx/5xx
 *
 * Registered in Router::PUBLIC_CONTROLLERS so it bypasses session + CSRF —
 * bearer auth is our security boundary.
 */
class ApiController extends Controller
{
    private ?array  $auth      = null;   // set by requireAuth()
    private ?int    $tokenId   = null;
    private ?int    $driverId  = null;
    private float   $startedAt = 0.0;
    private string  $endpointName = '';

    // ═════════════════════════════════════════════════════════════════════
    // Dispatchable actions
    // ═════════════════════════════════════════════════════════════════════

    /** GET /Api/health — no auth. Sanity-check the API is reachable + time-synced. */
    public function healthAction(): void
    {
        $this->begin('health');
        $this->ok(['server_time' => date('c'), 'server_ts' => time(), 'app_env' => defined('APP_ENV') ? APP_ENV : 'unknown']);
    }

    /**
     * POST /Api/login
     * Body: {"phone":"+255...", "pin":"1234", "device_label":"...", "device_platform":"android", "app_version":"1.0.0"}
     * → { ok:true, data: {token, expires_at, driver} }
     */
    public function loginAction(): void
    {
        $this->begin('login');
        if (!$this->isPost()) $this->fail(405, 'method_not_allowed', 'POST required.');

        $body  = $this->body();
        $phone = trim((string)($body['phone'] ?? ''));
        $pin   = trim((string)($body['pin']   ?? ''));

        $tokensModel = new ApiTokens();
        $driverId    = $tokensModel->authenticateByPhonePin($phone, $pin);
        if (!$driverId) $this->fail(401, 'invalid_credentials', 'Phone or PIN not recognised, or mobile login not enabled for this driver.');

        $issued = $tokensModel->issue($driverId, [
            'device_label'    => $body['device_label']    ?? null,
            'device_platform' => $body['device_platform'] ?? null,
            'app_version'     => $body['app_version']     ?? null,
            'ip'              => $_SERVER['REMOTE_ADDR']  ?? null,
            'ua'              => $_SERVER['HTTP_USER_AGENT'] ?? null,
        ]);

        // Bounce through verifyAndTouch to get the standard driver payload
        $verified = $tokensModel->verifyAndTouch($issued['token']);
        $this->driverId = $driverId;
        $this->tokenId  = $issued['token_id'];

        $this->ok([
            'token'      => $issued['token'],
            'expires_at' => $issued['expires_at'],
            'driver'     => $verified['driver'] ?? null,
        ]);
    }

    /** POST /Api/logout — revokes the current bearer token. */
    public function logoutAction(): void
    {
        $this->begin('logout');
        $this->requireAuth();
        (new ApiTokens())->revoke($this->tokenId);
        $this->ok(['revoked' => true]);
    }

    /** GET /Api/me — returns the driver's profile + assigned truck (if any). */
    public function meAction(): void
    {
        $this->begin('me');
        $this->requireAuth();

        $db = new DB();
        $truck = $db->mySelect(
            "SELECT t.c0d AS truck_id, t.reg_no, t.make, t.model, t.current_odometer, t.fuel_tank_liters,
                    t.capacity_kg
               FROM driver_truck_assignments da
          JOIN trucks t ON t.c0d = da.truck_id
              WHERE da.driver_id = :did AND da.deleted = 0
                AND (da.assigned_to IS NULL OR da.assigned_to >= CURDATE())
           ORDER BY da.assigned_from DESC
              LIMIT 1",
            [':did' => $this->driverId]
        );

        $this->ok([
            'driver' => $this->auth['driver'],
            'truck'  => $truck[0] ?? null,
        ]);
    }

    /**
     * GET /Api/activeTrip — the currently-active trip (dispatched or in_transit)
     * for this driver, plus stops. Null if none.
     */
    public function activeTripAction(): void
    {
        $this->begin('activeTrip');
        $this->requireAuth();

        $db = new DB();
        $rs = $db->mySelect(
            "SELECT t.c0d AS trip_id, t.trip_no, t.status, t.cargo_type, t.cargo_description, t.weight_kg,
                    t.planned_pickup, t.planned_delivery, t.actual_start, t.odometer_start,
                    tr.request_no,
                    tk.c0d AS truck_id, tk.reg_no, tk.make, tk.model, tk.current_odometer,
                    TRIM(CONCAT(c.fname, ' ', c.lname)) AS customer_name, c.c0d AS customer_id,
                    t.origin_address, t.destination_address, t.notes
               FROM trips t
          LEFT JOIN trip_requests tr ON tr.c0d = t.trip_request_id
          LEFT JOIN trucks       tk ON tk.c0d = t.truck_id
          LEFT JOIN customers    c  ON c.c0d  = t.customer_id
              WHERE t.driver_id = :did AND t.deleted = 0
                AND t.status IN ('dispatched','in_transit','delivered')
           ORDER BY FIELD(t.status,'in_transit','delivered','dispatched'), t.planned_pickup DESC
              LIMIT 1",
            [':did' => $this->driverId]
        );
        $trip = $rs[0] ?? null;
        if (!$trip) { $this->ok(['trip' => null]); return; }

        $stops = $db->mySelect(
            "SELECT c0d AS stop_id, seq_no, kind, contact_name, contact_phone,
                    location_name, address, city, lat, lng,
                    planned_arrival, planned_departure, actual_arrival, actual_departure,
                    stop_notes
               FROM trip_stops
              WHERE trip_id = :tid
           ORDER BY seq_no ASC",
            [':tid' => (int)$trip['trip_id']]
        ) ?: [];

        $this->ok(['trip' => $trip + ['stops' => $stops]]);
    }

    /** GET /Api/trips — recent history (last 30) for this driver. */
    public function tripsAction(): void
    {
        $this->begin('trips');
        $this->requireAuth();

        $db = new DB();
        $rows = $db->mySelect(
            "SELECT t.c0d AS trip_id, t.trip_no, t.status, t.planned_pickup, t.actual_start,
                    t.actual_end, t.actual_km, tk.reg_no AS truck_reg,
                    TRIM(CONCAT(c.fname, ' ', c.lname)) AS customer_name
               FROM trips t
          LEFT JOIN trucks    tk ON tk.c0d = t.truck_id
          LEFT JOIN customers c  ON c.c0d  = t.customer_id
              WHERE t.driver_id = :did AND t.deleted = 0
           ORDER BY t.planned_pickup DESC
              LIMIT 30",
            [':did' => $this->driverId]
        ) ?: [];
        $this->ok(['trips' => $rows]);
    }

    /**
     * POST /Api/tripStart/:id
     * Body: {odometer_start, photos?: [{category, base64, ext, caption?, taken_lat?, taken_lng?, taken_at?}]}
     * The photos array is optional but the mobile app is expected to capture:
     *   truck_plate_horse, truck_plate_trailer, truck_front, cargo_loadout, odometer_start
     */
    public function tripStartAction($id = 0): void
    {
        $this->begin('tripStart');
        $this->requireAuth();
        if (!$this->isPost()) $this->fail(405, 'method_not_allowed', 'POST required.');

        $tripId = (int)$id;
        if (!$this->driverOwnsTrip($tripId)) $this->fail(403, 'not_your_trip', 'Trip is not assigned to you.');

        $body = $this->body();
        $r = (new Trips())->start($tripId, ['odometer_start' => $body['odometer_start'] ?? ''], 0);
        if (!$r['ok']) $this->fail(422, 'trip_start_failed', $r['msg']);

        $attached = 0; $errors = [];
        if (!empty($body['photos']) && is_array($body['photos'])) {
            foreach ($body['photos'] as $p) {
                $res = $this->attachTripPhoto($tripId, $p);
                if ($res['ok']) $attached++;
                else            $errors[] = ($p['category'] ?? '?') . ': ' . $res['msg'];
            }
        }

        $this->ok(['msg' => $r['msg'], 'photos_attached' => $attached, 'photo_errors' => $errors]);
    }

    /** POST /Api/tripDeliver/:id */
    public function tripDeliverAction($id = 0): void
    {
        $this->begin('tripDeliver');
        $this->requireAuth();
        if (!$this->isPost()) $this->fail(405, 'method_not_allowed', 'POST required.');

        $tripId = (int)$id;
        if (!$this->driverOwnsTrip($tripId)) $this->fail(403, 'not_your_trip', 'Trip is not assigned to you.');

        $r = (new Trips())->markDelivered($tripId, 0);
        if (!$r['ok']) $this->fail(422, 'trip_deliver_failed', $r['msg']);
        $this->ok(['msg' => $r['msg']]);
    }

    /** POST /Api/tripClose/:id  {odometer_end, invoice_amount?, notes?} */
    public function tripCloseAction($id = 0): void
    {
        $this->begin('tripClose');
        $this->requireAuth();
        if (!$this->isPost()) $this->fail(405, 'method_not_allowed', 'POST required.');

        $tripId = (int)$id;
        if (!$this->driverOwnsTrip($tripId)) $this->fail(403, 'not_your_trip', 'Trip is not assigned to you.');

        $r = (new Trips())->close($tripId, $this->body(), 0);
        if (!$r['ok']) $this->fail(422, 'trip_close_failed', $r['msg']);
        $this->ok(['msg' => $r['msg']]);
    }

    /**
     * POST /Api/pushPosition
     * Body: {lat, lng, speed_kph, heading_deg?, ts, ignition?, odometer_km?, fuel_level_l?, altitude_m?}
     * Resolves the driver's currently-assigned truck → its GPS device, then
     * delegates to GpsPositions::ingest(). Auto-registers a phone-side "OsmAnd"
     * device on the truck if none exists yet.
     */
    public function pushPositionAction(): void
    {
        $this->begin('pushPosition');
        $this->requireAuth();
        if (!$this->isPost()) $this->fail(405, 'method_not_allowed', 'POST required.');

        $body = $this->body();
        $lat = $body['lat'] ?? null; $lng = $body['lng'] ?? null;
        if (!is_numeric($lat) || !is_numeric($lng)) $this->fail(422, 'bad_coords', 'lat and lng required as numbers.');

        $db = new DB();
        // Resolve truck via active driver assignment
        $rs = $db->mySelect(
            "SELECT da.truck_id
               FROM driver_truck_assignments da
              WHERE da.driver_id = :did AND da.deleted = 0
                AND (da.assigned_to IS NULL OR da.assigned_to >= CURDATE())
           ORDER BY da.assigned_from DESC LIMIT 1",
            [':did' => $this->driverId]
        );
        $truckId = (int)($rs[0]['truck_id'] ?? 0);
        if (!$truckId) $this->fail(409, 'no_truck', 'No active truck assignment for this driver.');

        // Find (or create) a mobile OsmAnd device for this truck
        $devRs = $db->mySelect(
            "SELECT c0d FROM gps_devices WHERE truck_id = :tid AND protocol = 'osmand' AND deleted = 0 LIMIT 1",
            [':tid' => $truckId]
        );
        $deviceId = (int)($devRs[0]['c0d'] ?? 0);
        if (!$deviceId) {
            $code = 'MOBILE-D' . $this->driverId . '-T' . $truckId;
            $ins = $db->insertUpdate(
                "INSERT INTO gps_devices (tenant_id, device_code, device_type, protocol, truck_id, status, notes, created_on)
                 VALUES (1, :c, 'mobile_phone', 'osmand', :tid, 'active', 'Auto-registered mobile-app device.', NOW())",
                [':c' => $code, ':tid' => $truckId]
            );
            $deviceId = (int)$ins['last_id'];
        }

        $ts = $body['ts'] ?? null;
        if (is_numeric($ts))     $ts = date('Y-m-d H:i:s', (int)$ts);
        elseif (is_string($ts))  $ts = date('Y-m-d H:i:s', strtotime($ts) ?: time());
        else                     $ts = date('Y-m-d H:i:s');

        $r = (new GpsPositions())->ingest([
            'device_id'       => $deviceId,
            'ts'              => $ts,
            'lat'             => (float)$lat,
            'lng'             => (float)$lng,
            'speed_kph'       => isset($body['speed_kph']) ? (float)$body['speed_kph'] : null,
            'heading_deg'     => isset($body['heading_deg']) ? (float)$body['heading_deg'] : null,
            'altitude_m'      => isset($body['altitude_m']) ? (float)$body['altitude_m'] : null,
            'ignition'        => isset($body['ignition']) ? (int)!!$body['ignition'] : null,
            'odometer_can_km' => isset($body['odometer_km']) ? (int)$body['odometer_km'] : null,
            'fuel_level_l'    => isset($body['fuel_level_l']) ? (float)$body['fuel_level_l'] : null,
        ]);
        if (!$r['ok']) $this->fail(422, 'ingest_failed', $r['msg'] ?? 'Position rejected.');

        $this->ok(['position_id' => $r['position_id'], 'events' => $r['events']]);
    }

    /**
     * POST /Api/fuelEntry
     * Body: {truck_id?, liters, price_per_liter?, gross_amount?, odometer_reading?,
     *        station_name?, station_location?, fuel_type?, payment_method?, notes?,
     *        receipt_base64?, receipt_ext? }
     */
    public function fuelEntryAction(): void
    {
        $this->begin('fuelEntry');
        $this->requireAuth();
        if (!$this->isPost()) $this->fail(405, 'method_not_allowed', 'POST required.');

        $body = $this->body();
        $body['driver_id'] = $this->driverId;
        if (empty($body['truck_id'])) $body['truck_id'] = $this->resolveDriverTruckId() ?: 0;

        // Receipt photo — base64 in JSON body → save to /uploads/fuel/
        if (!empty($body['receipt_base64'])) {
            $up = $this->savePhotoBase64($body['receipt_base64'], 'fuel', $body['receipt_ext'] ?? 'jpg');
            if (!$up['ok']) $this->fail(422, 'photo_failed', $up['msg']);
            $body['receipt_url']       = $up['url'];
            $body['receipt_file_name'] = $up['name'];
        }

        $r = (new FuelEntries())->create($body, 0, $this->auth['driver']['full_name'] ?? 'DriverApp');
        if (!$r['ok']) $this->fail(422, 'fuel_save_failed', $r['msg']);
        $this->ok(['id' => $r['id'], 'msg' => $r['msg']]);
    }

    /**
     * POST /Api/expense
     * Body: {expense_type_id, gross_amount, vat_amount?, description?, expense_date?,
     *        payment_method?, currency?, receipt_base64?, receipt_ext?}
     */
    public function expenseAction(): void
    {
        $this->begin('expense');
        $this->requireAuth();
        if (!$this->isPost()) $this->fail(405, 'method_not_allowed', 'POST required.');

        $body = $this->body();
        $body['driver_id'] = $this->driverId;
        $body['status']    = 'submitted';   // driver-submitted expenses skip 'draft'
        if (empty($body['truck_id'])) $body['truck_id'] = $this->resolveDriverTruckId() ?: 0;

        if (!empty($body['receipt_base64'])) {
            $up = $this->savePhotoBase64($body['receipt_base64'], 'expense', $body['receipt_ext'] ?? 'jpg');
            if (!$up['ok']) $this->fail(422, 'photo_failed', $up['msg']);
            $body['receipt_url']       = $up['url'];
            $body['receipt_file_name'] = $up['name'];
            $body['receipt_file_size'] = $up['size'];
        }

        $r = (new Expenses())->create($body, 0);
        if (!$r['ok']) $this->fail(422, 'expense_save_failed', $r['msg']);
        $this->ok(['id' => $r['id'], 'msg' => $r['msg']]);
    }

    /**
     * POST /Api/workRequest
     * Body: {truck_id?, symptom, urgency?, fault_location?, truck_odometer?, status?}
     */
    public function workRequestAction(): void
    {
        $this->begin('workRequest');
        $this->requireAuth();
        if (!$this->isPost()) $this->fail(405, 'method_not_allowed', 'POST required.');

        $body = $this->body();
        $body['driver_id'] = $this->driverId;
        if (empty($body['truck_id'])) $body['truck_id'] = $this->resolveDriverTruckId() ?: 0;
        if (empty($body['status']))   $body['status']   = 'submitted';   // driver submits directly to Ops

        $r = (new WorkRequests())->create($body, 0);
        if (!$r['ok']) $this->fail(422, 'wr_save_failed', $r['msg']);
        $this->ok(['id' => $r['id'], 'wr_no' => $r['wr_no'] ?? null, 'msg' => $r['msg']]);
    }

    /** GET /Api/geofences — active fences, so the client can prefilter. */
    public function geofencesAction(): void
    {
        $this->begin('geofences');
        $this->requireAuth();
        $this->ok(['geofences' => (new Geofences())->activeAll()]);
    }

    /**
     * POST /Api/tripAttach/:trip_id
     * Body: {category, base64, ext, caption?, trip_stop_id?, taken_at?, taken_lat?, taken_lng?}
     */
    public function tripAttachAction($trip_id = 0): void
    {
        $this->begin('tripAttach');
        $this->requireAuth();
        if (!$this->isPost()) $this->fail(405, 'method_not_allowed', 'POST required.');

        $tripId = (int)$trip_id;
        if (!$this->driverOwnsTrip($tripId)) $this->fail(403, 'not_your_trip', 'Trip is not assigned to you.');

        $res = $this->attachTripPhoto($tripId, $this->body());
        if (!$res['ok']) $this->fail(422, 'attach_failed', $res['msg']);
        $this->ok(['attachment_id' => $res['id'], 'file_url' => $res['url']]);
    }

    /** GET /Api/tripAttachments/:trip_id — list photos on this trip. */
    public function tripAttachmentsAction($trip_id = 0): void
    {
        $this->begin('tripAttachments');
        $this->requireAuth();
        $tripId = (int)$trip_id;
        if (!$this->driverOwnsTrip($tripId)) $this->fail(403, 'not_your_trip', 'Trip is not assigned to you.');
        $this->ok(['attachments' => (new TripAttachments())->forTrip($tripId)]);
    }

    /**
     * GET /Api/myPayslips — the last 12 payslips for the calling driver.
     * Returns a compact projection (no PDF bytes); use payslipPdf to fetch
     * the PDF for one record.
     */
    public function myPayslipsAction(): void
    {
        $this->begin('myPayslips');
        $this->requireAuth();
        $db = new DB();
        $rows = $db->mySelect(
            "SELECT pr.c0d AS record_id,
                    pp.period_no, pp.period_start, pp.period_end, pp.status AS period_status,
                    pr.base_salary, pr.commission, pr.allowances, pr.other_earnings,
                    pr.gross_pay, pr.paye, pr.nssf_ee, pr.health_ins, pr.other_deductions,
                    pr.net_pay
               FROM payroll_records pr
               JOIN payroll_periods pp ON pp.c0d = pr.period_id
              WHERE pr.driver_id = :did
                AND pp.status IN ('approved','paid')
                AND pp.deleted = 0
           ORDER BY pp.period_start DESC
              LIMIT 12",
            [':did' => $this->driverId]
        ) ?: [];
        $this->ok(['payslips' => $rows]);
    }

    /**
     * GET /Api/payslipPdf/:record_id — streams the FPDF payslip for one of
     * this driver's own payroll records. Uses bearer auth (bypasses session)
     * so the mobile app can open it in an in-app browser tab.
     */
    public function payslipPdfAction($record_id = 0): void
    {
        $this->begin('payslipPdf');
        $this->requireAuth();
        $recId = (int)$record_id;

        $rec = (new PayrollPeriods())->recordById($recId);
        if (!$rec)                                     $this->fail(404, 'not_found', 'Payslip not found.');
        if ((int)$rec['driver_id'] !== $this->driverId) $this->fail(403, 'not_yours', 'Payslip is not yours.');

        if (!class_exists('FPDF'))           require_once ROOT . '/core/FPDF.php';
        if (!class_exists('PacificPayslipPdf')) require_once ROOT . '/app/models/PacificPayslipPdf.php';

        // Reset the JSON headers begin() set — this response is PDF, not JSON.
        header_remove('Content-Type');
        header_remove('X-Content-Type-Options');

        $pdf = new PacificPayslipPdf();
        $pdf->renderOne($rec);

        if (ob_get_length()) ob_clean();
        $filename = 'Payslip-' . preg_replace('/[^A-Za-z0-9_-]/', '_',
            ($rec['period_no'] . '-' . ($rec['employee_no'] ?: $rec['full_name']))) . '.pdf';
        $pdf->Output('I', $filename);
        exit();
    }

    /**
     * GET /Api/myP9?year=YYYY — annual PAYE certificate PDF for the calling
     * driver. Streams the FPDF inline; the mobile app opens the URL in an
     * OS browser tab with a bearer token in the query string.
     */
    public function myP9Action(): void
    {
        $this->begin('myP9');
        $this->requireAuth();
        $year = (int)($_GET['year'] ?? date('Y'));
        if ($year < 2000 || $year > 2100) $year = (int)date('Y');

        $cert = (new TaxReturns())->p9($this->driverId, $year);
        if (!$cert) $this->fail(404, 'no_p9', 'No approved payroll records for you in ' . $year . '.');

        if (!class_exists('FPDF'))           require_once ROOT . '/core/FPDF.php';
        if (!class_exists('PacificP9Pdf'))   require_once ROOT . '/app/models/PacificP9Pdf.php';

        header_remove('Content-Type');
        header_remove('X-Content-Type-Options');

        $pdf = new PacificP9Pdf();
        $pdf->renderOne($cert);

        if (ob_get_length()) ob_clean();
        $filename = 'P9-' . $year . '-' . preg_replace('/[^A-Za-z0-9_-]/', '_',
            ($cert['driver']['employee_no'] ?: $cert['driver']['full_name'])) . '.pdf';
        $pdf->Output('I', $filename);
        exit();
    }

    /**
     * GET /Api/myDocs — this driver's compliance documents, with an
     * expiry-severity tag so the mobile app can colour the list without
     * recomputing dates in JavaScript.
     */
    public function myDocsAction(): void
    {
        $this->begin('myDocs');
        $this->requireAuth();

        $db = new DB();
        $driver = $db->mySelect(
            "SELECT licence_no, licence_class, licence_expires
               FROM drivers WHERE c0d = :id LIMIT 1",
            [':id' => $this->driverId]
        );
        $driver = $driver[0] ?? [];

        $docs = $db->mySelect(
            "SELECT dd.c0d, dd.doc_no, dd.issuer, dd.issued_on, dd.expires_on,
                    dd.file_url, dd.file_name,
                    dt.display_name AS doc_type_name, dt.warn_days
               FROM driver_docs dd
          LEFT JOIN driver_doc_types dt ON dt.c0d = dd.doc_type_id
              WHERE dd.driver_id = :did AND dd.deleted = 0
           ORDER BY dd.expires_on IS NULL, dd.expires_on ASC",
            [':did' => $this->driverId]
        ) ?: [];

        $today = strtotime('today');
        foreach ($docs as &$d) {
            $exp = $d['expires_on'] ? strtotime($d['expires_on']) : null;
            if (!$exp) { $d['severity'] = 'ok'; $d['days_to_expiry'] = null; continue; }
            $days = (int)round(($exp - $today) / 86400);
            $d['days_to_expiry'] = $days;
            $d['severity'] = $days < 0 ? 'expired' : ($days <= 14 ? 'urgent' : ($days <= 30 ? 'soon' : 'ok'));
        }

        // Licence expiry surfaces the same way as an ordinary doc so the
        // mobile UI can render one uniform list.
        $licence = null;
        if (!empty($driver['licence_no']) || !empty($driver['licence_expires'])) {
            $exp = $driver['licence_expires'] ? strtotime($driver['licence_expires']) : null;
            $days = $exp ? (int)round(($exp - $today) / 86400) : null;
            $licence = [
                'doc_type_name'   => 'Driving Licence',
                'doc_no'          => $driver['licence_no'] ?: '—',
                'issuer'          => 'NIT (National Institute of Transport)',
                'expires_on'      => $driver['licence_expires'],
                'file_url'        => null,
                'file_name'       => null,
                'days_to_expiry'  => $days,
                'severity'        => $days === null ? 'ok'
                                    : ($days < 0 ? 'expired'
                                    : ($days <= 14 ? 'urgent'
                                    : ($days <= 30 ? 'soon' : 'ok'))),
                'class'           => $driver['licence_class'] ?? '',
            ];
        }

        $this->ok(['licence' => $licence, 'docs' => $docs]);
    }

    // ═════════════════════════════════════════════════════════════════════
    // Auth + envelope helpers
    // ═════════════════════════════════════════════════════════════════════

    private function begin(string $name): void
    {
        $this->startedAt    = microtime(true);
        $this->endpointName = $name;
        header('Content-Type: application/json');
        header('X-Content-Type-Options: nosniff');
    }

    private function requireAuth(): void
    {
        $token = $this->bearerToken() ?: (string)($_GET['token'] ?? '');
        if ($token === '') $this->fail(401, 'no_token', 'Missing bearer token.');

        $ver = (new ApiTokens())->verifyAndTouch($token);
        if (!$ver) $this->fail(401, 'invalid_token', 'Token invalid, expired, or revoked.');

        $this->auth     = $ver;
        $this->tokenId  = $ver['token_id'];
        $this->driverId = $ver['driver_id'];
    }

    private function bearerToken(): string
    {
        $h = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
        if ($h === '' && function_exists('apache_request_headers')) {
            foreach ((array)apache_request_headers() as $k => $v) {
                if (strcasecmp($k, 'Authorization') === 0) { $h = $v; break; }
            }
        }
        if (stripos($h, 'Bearer ') === 0) return trim(substr($h, 7));
        return '';
    }

    private function body(): array
    {
        $raw = file_get_contents('php://input');
        $j   = json_decode((string)$raw, true);
        if (is_array($j)) return $j;
        return $_POST ?: [];   // form-encoded fallback (multipart uploads)
    }

    private function isPost(): bool
    {
        return ($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST';
    }

    private function ok(array $data): void
    {
        $ms = (int)round((microtime(true) - $this->startedAt) * 1000);
        (new ApiTokens())->log($this->driverId, $this->tokenId, $this->endpointName, $_SERVER['REQUEST_METHOD'] ?? '?', 200, null, $ms);
        echo json_encode(['ok' => true, 'data' => $data]);
        exit();
    }

    private function fail(int $status, string $code, string $msg): void
    {
        http_response_code($status);
        $ms = (int)round((microtime(true) - $this->startedAt) * 1000);
        (new ApiTokens())->log($this->driverId, $this->tokenId, $this->endpointName, $_SERVER['REQUEST_METHOD'] ?? '?', $status, $code . ': ' . $msg, $ms);
        echo json_encode(['ok' => false, 'code' => $code, 'msg' => $msg]);
        exit();
    }

    // ─── domain helpers ─────────────────────────────────────────────────

    private function driverOwnsTrip(int $tripId): bool
    {
        if (!$tripId) return false;
        $rs = (new DB())->mySelect("SELECT driver_id FROM trips WHERE c0d = :id AND deleted = 0 LIMIT 1", [':id' => $tripId]);
        return !empty($rs) && (int)$rs[0]['driver_id'] === (int)$this->driverId;
    }

    private function resolveDriverTruckId(): ?int
    {
        $rs = (new DB())->mySelect(
            "SELECT truck_id FROM driver_truck_assignments
              WHERE driver_id = :did AND deleted = 0
                AND (assigned_to IS NULL OR assigned_to >= CURDATE())
           ORDER BY assigned_from DESC LIMIT 1",
            [':did' => $this->driverId]
        );
        return !empty($rs) ? (int)$rs[0]['truck_id'] : null;
    }

    /**
     * Save one base64 photo + insert a trip_attachments row.
     * Called both from tripAttachAction (single) and tripStartAction (batch).
     */
    private function attachTripPhoto(int $tripId, array $p): array
    {
        $cat = (string)($p['category'] ?? 'other');
        if (!isset(TripAttachments::CATEGORIES[$cat])) $cat = 'other';
        if (empty($p['base64'])) return ['ok' => false, 'msg' => 'Missing base64 photo payload.'];

        $up = $this->savePhotoBase64((string)$p['base64'], 'trip/' . $tripId, (string)($p['ext'] ?? 'jpg'));
        if (!$up['ok']) return $up;

        $id = (new TripAttachments())->create([
            'trip_id'      => $tripId,
            'trip_stop_id' => !empty($p['trip_stop_id']) ? (int)$p['trip_stop_id'] : null,
            'category'     => $cat,
            'caption'      => $p['caption'] ?? null,
            'file_url'     => $up['url'],
            'file_name'    => $up['name'],
            'file_size'    => $up['size'],
            'mime_type'    => 'image/' . preg_replace('/[^a-z0-9]/', '', strtolower((string)($p['ext'] ?? 'jpg'))),
            'taken_at'     => $p['taken_at']  ?? null,
            'taken_lat'    => $p['taken_lat'] ?? null,
            'taken_lng'    => $p['taken_lng'] ?? null,
            'source'       => 'mobile',
            'driver_id'    => $this->driverId,
        ]);
        return ['ok' => true, 'id' => $id, 'url' => $up['url']];
    }

    /**
     * Save a base64-encoded photo to /uploads/<scope>/. Returns
     * {ok, url, name, size} or {ok:false, msg}. Enforces 8 MB cap + jpg/png/webp.
     */
    private function savePhotoBase64(string $b64, string $scope, string $ext): array
    {
        $ext = strtolower(preg_replace('/[^a-z0-9]/', '', $ext));
        if (!in_array($ext, ['jpg','jpeg','png','webp'], true)) return ['ok' => false, 'msg' => "Extension .$ext not allowed."];

        // Strip data-URL prefix if present
        $b64 = preg_replace('/^data:[^;]+;base64,/', '', $b64);
        $bin = base64_decode($b64, true);
        if ($bin === false || $bin === '') return ['ok' => false, 'msg' => 'Bad base64 payload.'];
        $max = defined('MAX_UPLOAD_SIZE') ? MAX_UPLOAD_SIZE : (8*1024*1024);
        if (strlen($bin) > $max) return ['ok' => false, 'msg' => 'Photo exceeds max size.'];

        // Allow nested subdirs like "trip/12" — sanitise each segment individually.
        $safeScope = implode('/', array_map(
            fn($s) => preg_replace('/[^a-z0-9_]/', '_', strtolower($s)),
            array_filter(explode('/', $scope), fn($s) => $s !== '')
        ));
        $destDir = UPLOAD_PATH . str_replace('/', DS, $safeScope);
        if (!is_dir($destDir)) @mkdir($destDir, 0775, true);
        $name = date('Ymd_His') . '_' . bin2hex(random_bytes(6)) . '.' . $ext;
        $path = $destDir . DS . $name;
        if (@file_put_contents($path, $bin) === false) return ['ok' => false, 'msg' => 'Could not save photo.'];

        return ['ok' => true, 'url' => UPLOAD_URL . $safeScope . '/' . $name, 'name' => $name, 'size' => strlen($bin)];
    }
}
