-- 11_reporting_schema.sql
-- Map-dashboard read layer consumed by dashboard_api_rev.py:
--   reporting.fn_live_positions / fn_vehicle_track / fn_trips_for_map
--   + the v_trips materialized view, filter/summary views, and refresh_log.
-- Captured from prod 2026-06-05 to close the reproducibility gap (these objects
-- lived only on the live DB, in no migration). Every object uses IF NOT EXISTS /
-- CREATE OR REPLACE so the file is safe to re-apply.
--
-- NOTE: the v_trips materialized view is created WITH DATA (populated once). On
-- prod it is owned by role `reporting_refresher` and kept current by an external
-- REFRESH job; that role + refresh schedule are infrastructure, NOT created here.
-- On a fresh rebuild v_trips is populated at creation but will go stale until a
-- refresh job is wired (see docs). The dashboard map functions still work, just
-- against the snapshot until then.

CREATE SCHEMA IF NOT EXISTS reporting;

-- Bodies reference base tables unqualified (devices, trips, …) + PostGIS;
-- resolve via search_path so this applies cleanly on a fresh DB.
SET search_path = tracksolid, reporting, public;

-- ── helper ───────────────────────────────────────────────────────────────────
CREATE OR REPLACE FUNCTION reporting.normalize_plate(p text)
 RETURNS text
 LANGUAGE sql
 IMMUTABLE PARALLEL SAFE
AS $function$
  SELECT regexp_replace(
           regexp_replace(trim(p), '\s+', ' ', 'g'),
           '(\d) ([A-Z])$', '\1\2'
         )
$function$;

-- ── refresh audit table ──────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS reporting.refresh_log (
  refreshed_at timestamptz DEFAULT now() NOT NULL,
  source text DEFAULT 'n8n'::text NOT NULL,
  duration_ms integer,
  row_count integer,
  notes text
);

-- ── v_trips materialized view (+ indexes) ────────────────────────────────────
CREATE MATERIALIZED VIEW IF NOT EXISTS reporting.v_trips AS
 WITH device_trip_counts AS (
         SELECT trips.imei,
            count(*) AS trip_count
           FROM trips
          GROUP BY trips.imei
        ), primary_device AS (
         SELECT DISTINCT ON ((reporting.normalize_plate(d_1.vehicle_number))) reporting.normalize_plate(d_1.vehicle_number) AS vehicle_number_norm,
            d_1.imei AS primary_imei
           FROM devices d_1
             LEFT JOIN device_trip_counts c USING (imei)
          WHERE d_1.vehicle_number IS NOT NULL AND d_1.enabled_flag = 1
          ORDER BY (reporting.normalize_plate(d_1.vehicle_number)), (COALESCE(c.trip_count, 0::bigint)) DESC, d_1.activation_time, d_1.imei
        )
 SELECT t.id AS trip_id,
    t.imei,
    d.device_name,
    reporting.normalize_plate(d.vehicle_number) AS vehicle_number,
    d.vehicle_models,
    d.vehicle_category,
    d.cost_centre,
    d.assigned_city,
    d.driver_name AS assigned_driver,
    (t.start_time AT TIME ZONE 'Africa/Nairobi'::text) AS start_time,
    (t.end_time AT TIME ZONE 'Africa/Nairobi'::text) AS end_time,
    (t.start_time AT TIME ZONE 'Africa/Nairobi'::text)::date AS trip_date,
    EXTRACT(hour FROM (t.start_time AT TIME ZONE 'Africa/Nairobi'::text))::integer AS start_hour,
    EXTRACT(dow FROM (t.start_time AT TIME ZONE 'Africa/Nairobi'::text))::integer AS start_dow,
    row_number() OVER (PARTITION BY t.imei, ((t.start_time AT TIME ZONE 'Africa/Nairobi'::text)::date) ORDER BY t.start_time) AS daily_seq,
    t.distance_km,
    t.avg_speed_kmh,
    t.max_speed_kmh,
    t.idle_time_s,
    t.driving_time_s,
    t.fuel_consumed_l,
    t.waypoints_count,
    t.start_address,
    t.end_address,
    t.start_geom,
    t.end_geom,
    t.route_geom,
    st_asgeojson(st_simplifypreservetopology(t.route_geom, 0.00005::double precision))::json AS route_geojson,
    st_numpoints(t.route_geom) >= 2 AND st_length(t.route_geom::geography) > 50::double precision AS is_meaningful_route,
    (t.updated_at AT TIME ZONE 'Africa/Nairobi'::text) AS updated_at
   FROM trips t
     LEFT JOIN devices d USING (imei)
     LEFT JOIN primary_device pd ON pd.vehicle_number_norm = reporting.normalize_plate(d.vehicle_number)
  WHERE d.vehicle_number IS NULL OR pd.primary_imei IS NULL OR t.imei = pd.primary_imei
WITH DATA;

CREATE INDEX IF NOT EXISTS ix_v_trips_city_trip_date ON reporting.v_trips USING btree (assigned_city, trip_date);
CREATE INDEX IF NOT EXISTS ix_v_trips_cost_centre_trip_date ON reporting.v_trips USING btree (cost_centre, trip_date);
CREATE INDEX IF NOT EXISTS ix_v_trips_driver_trip_date ON reporting.v_trips USING btree (assigned_driver, trip_date);
CREATE INDEX IF NOT EXISTS ix_v_trips_imei_trip_date ON reporting.v_trips USING btree (imei, trip_date);
CREATE INDEX IF NOT EXISTS ix_v_trips_meaningful_date ON reporting.v_trips USING btree (trip_date) WHERE is_meaningful_route;
CREATE INDEX IF NOT EXISTS ix_v_trips_trip_date ON reporting.v_trips USING btree (trip_date);
CREATE UNIQUE INDEX IF NOT EXISTS ix_v_trips_trip_id ON reporting.v_trips USING btree (trip_id);

-- ── views (dependency-ordered) ───────────────────────────────────────────────
CREATE OR REPLACE VIEW reporting.v_live_positions AS
 WITH primary_device AS (
         SELECT DISTINCT ON ((reporting.normalize_plate(d_1.vehicle_number))) reporting.normalize_plate(d_1.vehicle_number) AS vehicle_number,
            d_1.imei AS primary_imei
           FROM devices d_1
             LEFT JOIN live_positions lp_1 ON lp_1.imei = d_1.imei
          WHERE d_1.vehicle_number IS NOT NULL AND d_1.enabled_flag = 1
          ORDER BY (reporting.normalize_plate(d_1.vehicle_number)), (
                CASE
                    WHEN (d_1.mc_type = ANY (ARRAY['GT06E'::text, 'X3'::text, 'AT4'::text])) AND lp_1.gps_time >= (now() - '24:00:00'::interval) THEN 0
                    ELSE 1
                END), lp_1.gps_time DESC NULLS LAST, (
                CASE d_1.mc_type
                    WHEN 'GT06E'::text THEN 1
                    WHEN 'X3'::text THEN 2
                    WHEN 'AT4'::text THEN 3
                    WHEN 'JC400P'::text THEN 4
                    ELSE 5
                END), d_1.activation_time, d_1.imei
        )
 SELECT lp.imei,
    pd.vehicle_number,
    d.driver_name AS assigned_driver,
    d.cost_centre,
    d.assigned_city,
    d.vehicle_category,
    d.vehicle_models,
    d.mc_type,
        CASE d.mc_type
            WHEN 'GT06E'::text THEN 'tracker'::text
            WHEN 'X3'::text THEN 'tracker'::text
            WHEN 'AT4'::text THEN 'tracker'::text
            WHEN 'JC400P'::text THEN 'camera'::text
            ELSE 'other'::text
        END AS device_kind,
    lp.lat,
    lp.lng,
    lp.speed,
    lp.direction,
    lp.acc_status,
    lp.device_status,
    lp.gps_signal,
    lp.gps_num,
    lp.current_mileage,
    lp.loc_desc,
    lp.gps_time,
    lp.updated_at,
    (lp.gps_time AT TIME ZONE 'Africa/Nairobi'::text) AS gps_time_eat,
    (lp.updated_at AT TIME ZONE 'Africa/Nairobi'::text) AS updated_at_eat,
    round(EXTRACT(epoch FROM now() - lp.gps_time) / 3600::numeric, 2) AS source_age_hours
   FROM live_positions lp
     JOIN primary_device pd ON pd.primary_imei = lp.imei
     JOIN devices d ON d.imei = lp.imei;

CREATE OR REPLACE VIEW reporting.v_trips_today AS
 SELECT trip_id,
    imei,
    device_name,
    vehicle_number,
    vehicle_models,
    vehicle_category,
    cost_centre,
    assigned_city,
    assigned_driver,
    start_time,
    end_time,
    trip_date,
    start_hour,
    start_dow,
    daily_seq,
    distance_km,
    avg_speed_kmh,
    max_speed_kmh,
    idle_time_s,
    driving_time_s,
    fuel_consumed_l,
    waypoints_count,
    start_address,
    end_address,
    start_geom,
    end_geom,
    route_geom,
    route_geojson,
    is_meaningful_route,
    updated_at
   FROM reporting.v_trips
  WHERE trip_date = (now() AT TIME ZONE 'Africa/Nairobi'::text)::date;

CREATE OR REPLACE VIEW reporting.v_filter_drivers AS
 SELECT DISTINCT assigned_driver AS driver
   FROM reporting.v_trips
  WHERE assigned_driver IS NOT NULL
  ORDER BY assigned_driver;

CREATE OR REPLACE VIEW reporting.v_filter_cost_centres AS
 SELECT DISTINCT cost_centre
   FROM reporting.v_trips
  WHERE cost_centre IS NOT NULL
  ORDER BY cost_centre;

CREATE OR REPLACE VIEW reporting.v_filter_vehicles AS
 SELECT vehicle_number,
    string_agg(DISTINCT assigned_driver, ', '::text ORDER BY assigned_driver) AS drivers,
    (array_agg(cost_centre ORDER BY start_time DESC NULLS LAST))[1] AS cost_centre,
    (array_agg(assigned_city ORDER BY start_time DESC NULLS LAST))[1] AS assigned_city
   FROM reporting.v_trips
  WHERE vehicle_number IS NOT NULL
  GROUP BY vehicle_number
  ORDER BY vehicle_number;

CREATE OR REPLACE VIEW reporting.v_filter_cities AS
 SELECT DISTINCT assigned_city
   FROM reporting.v_trips
  WHERE assigned_city IS NOT NULL
  ORDER BY assigned_city;

CREATE OR REPLACE VIEW reporting.v_daily_summary AS
 SELECT trip_date,
    cost_centre,
    assigned_city,
    vehicle_number,
    assigned_driver,
    count(*) AS trip_count,
    sum(distance_km) AS total_km,
    sum(driving_time_s)::numeric / 3600.0 AS driving_hours,
    sum(idle_time_s)::numeric / 3600.0 AS idle_hours,
    round(100.0 * sum(idle_time_s)::numeric / NULLIF(sum(idle_time_s + driving_time_s), 0)::numeric, 1) AS idle_pct,
    min(start_time) AS first_trip_start,
    max(end_time) AS last_trip_end,
    avg(avg_speed_kmh) AS avg_speed_kmh,
    max(max_speed_kmh) AS max_speed_kmh,
    st_asgeojson(st_simplifypreservetopology(st_collect(route_geom), 0.00005::double precision))::json AS day_routes_geojson
   FROM reporting.v_trips
  WHERE is_meaningful_route
  GROUP BY trip_date, cost_centre, assigned_city, vehicle_number, assigned_driver;

CREATE OR REPLACE VIEW reporting.v_weekly_summary AS
 SELECT date_trunc('week'::text, trip_date::timestamp with time zone)::date AS week_start,
    cost_centre,
    assigned_city,
    vehicle_number,
    assigned_driver,
    count(*) AS trip_count,
    count(DISTINCT trip_date) AS active_days,
    sum(distance_km) AS total_km,
    sum(driving_time_s)::numeric / 3600.0 AS driving_hours,
    sum(idle_time_s)::numeric / 3600.0 AS idle_hours,
    avg(distance_km) AS avg_trip_km
   FROM reporting.v_trips
  WHERE is_meaningful_route
  GROUP BY (date_trunc('week'::text, trip_date::timestamp with time zone)::date), cost_centre, assigned_city, vehicle_number, assigned_driver;

CREATE OR REPLACE VIEW reporting.v_monthly_summary AS
 SELECT date_trunc('month'::text, trip_date::timestamp with time zone)::date AS month_start,
    to_char(trip_date::timestamp with time zone, 'YYYY-MM'::text) AS month_label,
    cost_centre,
    assigned_city,
    vehicle_category,
    vehicle_number,
    assigned_driver,
    count(*) AS trip_count,
    count(DISTINCT trip_date) AS active_days,
    sum(distance_km) AS total_km,
    sum(driving_time_s)::numeric / 3600.0 AS driving_hours,
    sum(idle_time_s)::numeric / 3600.0 AS idle_hours,
    round(100.0 * sum(idle_time_s)::numeric / NULLIF(sum(idle_time_s + driving_time_s), 0)::numeric, 1) AS idle_pct,
    round(sum(distance_km) / NULLIF(count(DISTINCT trip_date), 0)::numeric, 1) AS km_per_active_day,
    round(sum(distance_km) / NULLIF(count(*), 0)::numeric, 1) AS km_per_trip,
    avg(avg_speed_kmh) AS avg_speed_kmh,
    max(max_speed_kmh) AS peak_speed_kmh
   FROM reporting.v_trips
  WHERE is_meaningful_route
  GROUP BY (date_trunc('month'::text, trip_date::timestamp with time zone)::date), (to_char(trip_date::timestamp with time zone, 'YYYY-MM'::text)), cost_centre, assigned_city, vehicle_category, vehicle_number, assigned_driver;

CREATE OR REPLACE VIEW reporting.v_daily_cost_centre AS
 SELECT trip_date,
    cost_centre,
    count(DISTINCT imei) AS active_vehicles,
    count(DISTINCT assigned_driver) AS active_drivers,
    count(*) AS trip_count,
    sum(distance_km) AS total_km,
    sum(driving_time_s)::numeric / 3600.0 AS driving_hours,
    sum(idle_time_s)::numeric / 3600.0 AS idle_hours,
    round(100.0 * sum(idle_time_s)::numeric / NULLIF(sum(idle_time_s + driving_time_s), 0)::numeric, 1) AS idle_pct,
    round(sum(distance_km) / NULLIF(count(DISTINCT imei), 0)::numeric, 1) AS km_per_vehicle
   FROM reporting.v_trips
  WHERE is_meaningful_route AND cost_centre IS NOT NULL
  GROUP BY trip_date, cost_centre;

CREATE OR REPLACE VIEW reporting.v_weekly_cost_centre AS
 SELECT date_trunc('week'::text, trip_date::timestamp with time zone)::date AS week_start,
    cost_centre,
    count(DISTINCT imei) AS active_vehicles,
    count(DISTINCT assigned_driver) AS active_drivers,
    count(DISTINCT trip_date) AS active_days,
    count(*) AS trip_count,
    sum(distance_km) AS total_km,
    sum(driving_time_s)::numeric / 3600.0 AS driving_hours,
    sum(idle_time_s)::numeric / 3600.0 AS idle_hours,
    round(100.0 * sum(idle_time_s)::numeric / NULLIF(sum(idle_time_s + driving_time_s), 0)::numeric, 1) AS idle_pct,
    round(sum(distance_km) / NULLIF(count(DISTINCT imei), 0)::numeric, 1) AS km_per_vehicle
   FROM reporting.v_trips
  WHERE is_meaningful_route AND cost_centre IS NOT NULL
  GROUP BY (date_trunc('week'::text, trip_date::timestamp with time zone)::date), cost_centre;

CREATE OR REPLACE VIEW reporting.v_monthly_cost_centre AS
 SELECT date_trunc('month'::text, trip_date::timestamp with time zone)::date AS month_start,
    to_char(trip_date::timestamp with time zone, 'YYYY-MM'::text) AS month_label,
    cost_centre,
    count(DISTINCT imei) AS active_vehicles,
    count(DISTINCT assigned_driver) AS active_drivers,
    count(DISTINCT trip_date) AS active_days,
    count(*) AS trip_count,
    sum(distance_km) AS total_km,
    sum(driving_time_s)::numeric / 3600.0 AS driving_hours,
    sum(idle_time_s)::numeric / 3600.0 AS idle_hours,
    round(100.0 * sum(idle_time_s)::numeric / NULLIF(sum(idle_time_s + driving_time_s), 0)::numeric, 1) AS idle_pct,
    round(sum(distance_km) / NULLIF(count(DISTINCT imei), 0)::numeric, 1) AS km_per_vehicle
   FROM reporting.v_trips
  WHERE is_meaningful_route AND cost_centre IS NOT NULL
  GROUP BY (date_trunc('month'::text, trip_date::timestamp with time zone)::date), (to_char(trip_date::timestamp with time zone, 'YYYY-MM'::text)), cost_centre;

-- ── refresh_log index ────────────────────────────────────────────────────────
CREATE INDEX IF NOT EXISTS ix_refresh_log_refreshed_at ON reporting.refresh_log USING btree (refreshed_at DESC);

-- ── map functions ────────────────────────────────────────────────────────────
CREATE OR REPLACE FUNCTION reporting.fn_live_positions(p_cost_centre text DEFAULT NULL::text, p_acc_status text DEFAULT NULL::text)
 RETURNS jsonb
 LANGUAGE plpgsql
 STABLE
AS $function$
DECLARE
  v_result jsonb;
BEGIN
  p_cost_centre := NULLIF(p_cost_centre, '');
  p_acc_status := NULLIF(p_acc_status, '');
  WITH filtered AS (
    SELECT * FROM reporting.v_live_positions
    WHERE (p_cost_centre IS NULL OR cost_centre = p_cost_centre)
      AND (p_acc_status  IS NULL OR acc_status  = p_acc_status)
  )
  SELECT jsonb_build_object(
    'summary', jsonb_build_object(
      'vehicle_count',       COUNT(*),
      -- "moving" and "parked" both restrict to devices that have reported
      -- within the OFFLINE_THRESHOLD (24 h) so they represent the live
      -- fleet, not equipment-failure stragglers. "offline" is its own
      -- counter for the > 24 h tail.
      'moving',              COUNT(*) FILTER (WHERE acc_status = '1'
                                              AND source_age_hours < 24),
      'parked',              COUNT(*) FILTER (WHERE acc_status = '0'
                                              AND source_age_hours < 24),
      'offline',             COUNT(*) FILTER (WHERE source_age_hours >= 24),
      'median_speed_moving', percentile_cont(0.5) WITHIN GROUP (ORDER BY speed)
                               FILTER (WHERE acc_status = '1'
                                       AND source_age_hours < 24
                                       AND speed > 0),
      'last_batch_at',       to_char(MAX(updated_at) AT TIME ZONE 'Africa/Nairobi',
                                     'YYYY-MM-DD HH24:MI:SS'),
      'oldest_fix_at',       to_char(MIN(gps_time)   AT TIME ZONE 'Africa/Nairobi',
                                     'YYYY-MM-DD HH24:MI:SS'),
      'newest_fix_at',       to_char(MAX(gps_time)   AT TIME ZONE 'Africa/Nairobi',
                                     'YYYY-MM-DD HH24:MI:SS'),
      'last_batch_utc',      MAX(updated_at),
      'newest_fix_utc',      MAX(gps_time)
    ),
    'geojson', jsonb_build_object(
      'type', 'FeatureCollection',
      'features', COALESCE(jsonb_agg(
        jsonb_build_object(
          'type', 'Feature',
          'properties', jsonb_build_object(
            'imei',            imei,
            'vehicle_number',  vehicle_number,
            'driver',          assigned_driver,
            'cost_centre',     cost_centre,
            'assigned_city',   assigned_city,
            'vehicle_category', vehicle_category,
            'mc_type',         mc_type,
            'device_kind',     device_kind,
            'source_age_hours', source_age_hours,
            'speed',           speed,
            'direction',       direction,
            'acc_status',      acc_status,
            'device_status',   device_status,
            'gps_signal',      gps_signal,
            'gps_num',         gps_num,
            'current_mileage', current_mileage,
            'loc_desc',        loc_desc,
            'gps_time',        to_char(gps_time   AT TIME ZONE 'Africa/Nairobi',
                                       'YYYY-MM-DD HH24:MI:SS'),
            'updated_at',      to_char(updated_at AT TIME ZONE 'Africa/Nairobi',
                                       'YYYY-MM-DD HH24:MI:SS'),
            'gps_time_utc',    gps_time,
            'updated_at_utc',  updated_at
          ),
          'geometry', jsonb_build_object(
            'type', 'Point',
            'coordinates', jsonb_build_array(lng, lat)
          )
        )
      ), '[]'::jsonb)
    )
  ) INTO v_result FROM filtered;

  RETURN v_result;
END $function$;

CREATE OR REPLACE FUNCTION reporting.fn_vehicle_track(p_vehicle_number text, p_hours integer DEFAULT 1)
 RETURNS jsonb
 LANGUAGE sql
 STABLE
AS $function$
  -- IMEI lookup reuses the already-deduped reporting.v_live_positions instead
  -- of re-running the primary_device CTE against tracksolid.trips. That keeps
  -- reporting_reader off tracksolid.trips entirely.
  WITH pts AS (
    SELECT ph.gps_time, ph.lat, ph.lng, ph.speed, ph.direction
    FROM tracksolid.position_history ph
    JOIN reporting.v_live_positions lv ON lv.imei = ph.imei
    WHERE lv.vehicle_number = reporting.normalize_plate(p_vehicle_number)
      AND ph.gps_time >= NOW() - make_interval(hours => GREATEST(p_hours, 1))
    ORDER BY ph.gps_time
  )
  SELECT jsonb_build_object(
    'type', 'Feature',
    'properties', jsonb_build_object(
      'vehicle_number', reporting.normalize_plate(p_vehicle_number),
      'hours',          p_hours,
      'points',         (SELECT COUNT(*) FROM pts),
      'first_fix',      (SELECT to_char(MIN(gps_time) AT TIME ZONE 'Africa/Nairobi',
                                        'YYYY-MM-DD HH24:MI:SS') FROM pts),
      'last_fix',       (SELECT to_char(MAX(gps_time) AT TIME ZONE 'Africa/Nairobi',
                                        'YYYY-MM-DD HH24:MI:SS') FROM pts)
    ),
    'geometry', jsonb_build_object(
      'type', 'LineString',
      'coordinates', COALESCE(
        (SELECT jsonb_agg(jsonb_build_array(lng, lat) ORDER BY gps_time) FROM pts),
        '[]'::jsonb)
    )
  );
$function$;

CREATE OR REPLACE FUNCTION reporting.fn_trips_for_map(p_vehicle_numbers text[] DEFAULT NULL::text[], p_driver text DEFAULT NULL::text, p_cost_centre text DEFAULT NULL::text, p_assigned_city text DEFAULT NULL::text, p_start_date date DEFAULT NULL::date, p_end_date date DEFAULT NULL::date)
 RETURNS jsonb
 LANGUAGE plpgsql
 STABLE
AS $function$
DECLARE
  v_start  date := COALESCE(p_start_date, (NOW() AT TIME ZONE 'Africa/Nairobi')::date);
  v_end    date := COALESCE(p_end_date,   (NOW() AT TIME ZONE 'Africa/Nairobi')::date);
  v_days   int  := v_end - v_start + 1;
  v_result jsonb;
BEGIN
  p_driver := NULLIF(p_driver, '');
  p_cost_centre := NULLIF(p_cost_centre, '');
  p_assigned_city := NULLIF(p_assigned_city, '');
  -- 31-day guardrail: tripped only when NO filter is set AND range > 31 days.
  -- Vehicle list (non-empty), driver, cost-centre, OR city each waives it.
  IF (p_vehicle_numbers IS NULL OR cardinality(p_vehicle_numbers) = 0)
     AND p_driver IS NULL
     AND p_cost_centre IS NULL
     AND p_assigned_city IS NULL
     AND v_days > 31 THEN
    RAISE EXCEPTION
      'Range too wide for trip-grain map (% days). Pick a vehicle, driver, cost centre, or city — or narrow the period to 31 days or fewer.',
      v_days
      USING ERRCODE = 'check_violation';
  END IF;

  WITH filtered AS (
    SELECT *
    FROM reporting.v_trips
    WHERE is_meaningful_route
      AND (p_vehicle_numbers IS NULL
           OR cardinality(p_vehicle_numbers) = 0
           OR vehicle_number = ANY(p_vehicle_numbers))
      AND (p_driver         IS NULL OR assigned_driver = p_driver)
      AND (p_cost_centre    IS NULL OR cost_centre     = p_cost_centre)
      AND (p_assigned_city  IS NULL OR assigned_city   = p_assigned_city)
      AND (p_start_date     IS NULL OR trip_date      >= p_start_date)
      AND (p_end_date       IS NULL OR trip_date      <= p_end_date)
  )
  SELECT jsonb_build_object(
    'summary', jsonb_build_object(
      'trip_count',      COUNT(*),
      'total_km',        ROUND(COALESCE(SUM(distance_km), 0)::numeric, 1),
      'driving_hours',   ROUND((COALESCE(SUM(driving_time_s), 0) / 3600.0)::numeric, 1),
      'idle_hours',      ROUND((COALESCE(SUM(idle_time_s),    0) / 3600.0)::numeric, 1),
      'unique_vehicles', COUNT(DISTINCT vehicle_number),
      'unique_drivers',  COUNT(DISTINCT assigned_driver),
      'date_min',        MIN(trip_date),
      'date_max',        MAX(trip_date),
      -- First trip's start (chronologically first) + reverse-geocoded location
      'first_trip_start_time',
        (array_agg(to_char(start_time, 'YYYY-MM-DD HH24:MI:SS') ORDER BY start_time))[1],
      'first_trip_start_address',
        (array_agg(start_address ORDER BY start_time))[1],
      'first_trip_vehicle',
        (array_agg(vehicle_number ORDER BY start_time))[1],
      -- Last trip's end (chronologically latest) + reverse-geocoded location
      'last_trip_end_time',
        (array_agg(to_char(end_time, 'YYYY-MM-DD HH24:MI:SS') ORDER BY end_time DESC NULLS LAST))[1],
      'last_trip_end_address',
        (array_agg(end_address ORDER BY end_time DESC NULLS LAST))[1],
      'last_trip_vehicle',
        (array_agg(vehicle_number ORDER BY end_time DESC NULLS LAST))[1]
    ),
    'geojson', jsonb_build_object(
      'type', 'FeatureCollection',
      'features', COALESCE(jsonb_agg(
        jsonb_build_object(
          'type', 'Feature',
          'properties', jsonb_build_object(
            'trip_id',        trip_id,
            'vehicle_number', vehicle_number,
            'driver',         assigned_driver,
            'cost_centre',    cost_centre,
            'assigned_city',  assigned_city,
            'trip_date',      trip_date,
            'daily_seq',      daily_seq,
            'start_time',     to_char(start_time, 'YYYY-MM-DD HH24:MI:SS'),
            'end_time',       to_char(end_time,   'YYYY-MM-DD HH24:MI:SS'),
            'distance_km',    ROUND(COALESCE(distance_km, 0)::numeric, 2),
            'duration_min',   ROUND((COALESCE(driving_time_s, 0) / 60.0)::numeric, 0)
          ),
          'geometry', route_geojson
        )
        ORDER BY vehicle_number, trip_date, daily_seq
      ), '[]'::jsonb)
    )
  )
  INTO v_result
  FROM filtered;

  RETURN v_result;
END
$function$;

-- ── grants (guarded: roles may not exist on a fresh DB) ───────────────────────
DO $grants$
BEGIN
  IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname='grafana_ro') THEN
    GRANT USAGE ON SCHEMA reporting TO grafana_ro;
    GRANT SELECT ON ALL TABLES IN SCHEMA reporting TO grafana_ro;
    GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA reporting TO grafana_ro;
  END IF;
  IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname='tracksolid_owner') THEN
    GRANT USAGE ON SCHEMA reporting TO tracksolid_owner;
  END IF;
END $grants$;
