-- 21_tickets.sql
-- Field-ops TICKET layer for FleetOps: INC (incident / customer fault) + CRQ
-- (new-installation request). Tickets are produced by the client's email
-- automation as full-snapshot files in the rustfs `tickets` bucket
-- (automations/{inc,crq}/latest.json) and ingested by tools/import_tickets.py
-- into tracksolid.tickets. The map read layer is reporting.fn_tickets_for_map,
-- served by dashboard_api GET /webhook/tickets and rendered as INC/CRQ overlay
-- layers in the FleetOps "Tickets" tab (FleetNow-style map).
--
-- Geocoding: source rows carry NO coordinates (latitude/longitude are null in
-- every row observed). geom is resolved from a small cluster gazetteer
-- (tracksolid.geo_clusters), or directly from the feed lat/lng if the upstream
-- ever starts populating them. Until the gazetteer is seeded, geom is NULL and
-- tickets are simply not mapped (fn_tickets_for_map filters geom IS NOT NULL).
--
-- Safe to re-apply (IF NOT EXISTS / CREATE OR REPLACE / DROP TRIGGER IF EXISTS).

SET search_path = tracksolid, reporting, public;

-- ── normalize helper (cluster_key) ────────────────────────────────────────────
-- upper + collapse internal whitespace + trim; '' → NULL. IMMUTABLE so it can
-- back a functional index.
CREATE OR REPLACE FUNCTION tracksolid.norm_cluster(p text)
 RETURNS text LANGUAGE sql IMMUTABLE PARALLEL SAFE
AS $function$
  SELECT NULLIF(upper(regexp_replace(trim(COALESCE(p, '')), '\s+', ' ', 'g')), '')
$function$;

-- ── gazetteer: normalized cluster → coordinates ──────────────────────────────
CREATE TABLE IF NOT EXISTS tracksolid.geo_clusters (
  cluster_key text PRIMARY KEY,             -- tracksolid.norm_cluster(cluster)
  region      text,
  lat         double precision,
  lng         double precision,
  geom        geometry(Point, 4326),
  source      text,                         -- 'nominatim' | 'manual' | …
  verified    boolean     NOT NULL DEFAULT false,
  updated_at  timestamptz NOT NULL DEFAULT now()
);

CREATE OR REPLACE FUNCTION tracksolid.tg_geo_clusters_geom()
 RETURNS trigger LANGUAGE plpgsql
AS $function$
BEGIN
  IF NEW.lat IS NOT NULL AND NEW.lng IS NOT NULL
     AND NEW.lat BETWEEN -90 AND 90 AND NEW.lng BETWEEN -180 AND 180
     AND NOT (NEW.lat = 0 AND NEW.lng = 0) THEN
    NEW.geom := ST_SetSRID(ST_MakePoint(NEW.lng, NEW.lat), 4326);
  ELSE
    NEW.geom := NULL;
  END IF;
  NEW.updated_at := now();
  RETURN NEW;
END $function$;

DROP TRIGGER IF EXISTS trg_geo_clusters_geom ON tracksolid.geo_clusters;
CREATE TRIGGER trg_geo_clusters_geom
  BEFORE INSERT OR UPDATE ON tracksolid.geo_clusters
  FOR EACH ROW EXECUTE FUNCTION tracksolid.tg_geo_clusters_geom();

-- ── tickets ───────────────────────────────────────────────────────────────────
-- Columns mirror the source snapshot schema (32 fields, identical for INC/CRQ)
-- 1:1, plus locally-derived geom / geo_source / raw / ingested_at.
CREATE TABLE IF NOT EXISTS tracksolid.tickets (
  ticket_id          text PRIMARY KEY,
  source_type        text,
  service_type       text,                  -- 'inc' | 'crq' (type discriminator)
  bucket             text,
  raw_status         text,
  normalized_status  text,
  created_at_service timestamptz,
  scheduled_at       timestamptz,
  closed_at          timestamptz,
  last_seen_at       timestamptz,
  first_seen_at      timestamptz,
  week_start         date,
  week_end           date,
  cluster            text,
  region             text,
  location_name      text,
  latitude           double precision,
  longitude          double precision,
  department         text,
  assigned_team      text,
  owner              text,
  sla_status         text,
  mttr               double precision,
  is_auto_created    boolean,
  is_auto_closed     boolean,
  is_alarm           boolean,
  is_actionable      boolean,
  source_s3_bucket   text,
  source_s3_key      text,
  source_snapshot_id bigint,
  created_at         timestamptz,
  updated_at         timestamptz,
  -- locally derived
  geom               geometry(Point, 4326),
  geo_source         text,                  -- 'feed' | 'cluster' | 'none'
  raw                jsonb,                 -- full source row (hedge for new fields)
  ingested_at        timestamptz NOT NULL DEFAULT now(),
  CONSTRAINT tickets_service_type_chk
    CHECK (service_type IS NULL OR service_type IN ('inc', 'crq'))
);

-- geom resolution: feed coords first, else cluster gazetteer, else none.
CREATE OR REPLACE FUNCTION tracksolid.tg_tickets_geom()
 RETURNS trigger LANGUAGE plpgsql
AS $function$
DECLARE
  g geometry(Point, 4326);
BEGIN
  IF NEW.latitude IS NOT NULL AND NEW.longitude IS NOT NULL
     AND NEW.latitude  BETWEEN  -90 AND  90
     AND NEW.longitude BETWEEN -180 AND 180
     AND NOT (NEW.latitude = 0 AND NEW.longitude = 0) THEN
    NEW.geom       := ST_SetSRID(ST_MakePoint(NEW.longitude, NEW.latitude), 4326);
    NEW.geo_source := 'feed';
  ELSE
    SELECT gc.geom INTO g
      FROM tracksolid.geo_clusters gc
     WHERE gc.cluster_key = tracksolid.norm_cluster(NEW.cluster)
       AND gc.geom IS NOT NULL
     LIMIT 1;
    IF g IS NOT NULL THEN
      NEW.geom := g; NEW.geo_source := 'cluster';
    ELSE
      NEW.geom := NULL; NEW.geo_source := 'none';
    END IF;
  END IF;
  RETURN NEW;
END $function$;

DROP TRIGGER IF EXISTS trg_tickets_geom ON tracksolid.tickets;
CREATE TRIGGER trg_tickets_geom
  BEFORE INSERT OR UPDATE ON tracksolid.tickets
  FOR EACH ROW EXECUTE FUNCTION tracksolid.tg_tickets_geom();

-- Re-resolve geoms for already-loaded tickets after the gazetteer is (re)seeded,
-- without re-running ingestion. Skips rows already pinned by feed coordinates.
-- Returns the number of rows updated. Call: SELECT tracksolid.resolve_ticket_geoms();
CREATE OR REPLACE FUNCTION tracksolid.resolve_ticket_geoms()
 RETURNS integer LANGUAGE plpgsql
AS $function$
DECLARE n integer;
BEGIN
  UPDATE tracksolid.tickets t
     SET geom = gc.geom, geo_source = 'cluster'
    FROM tracksolid.geo_clusters gc
   WHERE gc.cluster_key = tracksolid.norm_cluster(t.cluster)
     AND gc.geom IS NOT NULL
     AND t.geo_source IS DISTINCT FROM 'feed'
     AND t.geom IS DISTINCT FROM gc.geom;
  GET DIAGNOSTICS n = ROW_COUNT;
  RETURN n;
END $function$;

-- ── indexes ───────────────────────────────────────────────────────────────────
CREATE INDEX IF NOT EXISTS ix_tickets_service_type ON tracksolid.tickets (service_type);
CREATE INDEX IF NOT EXISTS ix_tickets_status       ON tracksolid.tickets (normalized_status);
CREATE INDEX IF NOT EXISTS ix_tickets_type_status  ON tracksolid.tickets (service_type, normalized_status);
CREATE INDEX IF NOT EXISTS ix_tickets_actionable   ON tracksolid.tickets (is_actionable) WHERE is_actionable;
CREATE INDEX IF NOT EXISTS ix_tickets_geom         ON tracksolid.tickets USING gist (geom);
CREATE INDEX IF NOT EXISTS ix_tickets_cluster      ON tracksolid.tickets (tracksolid.norm_cluster(cluster));

-- ── map read function ─────────────────────────────────────────────────────────
-- Modeled on reporting.fn_live_positions: NULLIF-cleaned params, single filtered
-- CTE, returns { summary, geojson:FeatureCollection }. Maps only geocoded rows
-- (geom IS NOT NULL). p_open_only restricts to actionable (open) tickets — the
-- map default, since the bulk of INC are CLOSED.
CREATE OR REPLACE FUNCTION reporting.fn_tickets_for_map(
  p_service_type text    DEFAULT NULL,
  p_status       text    DEFAULT NULL,
  p_open_only    boolean DEFAULT true
)
 RETURNS jsonb LANGUAGE plpgsql STABLE
AS $function$
DECLARE
  v_result jsonb;
BEGIN
  p_service_type := lower(NULLIF(p_service_type, ''));
  p_status       := NULLIF(p_status, '');
  WITH filtered AS (
    SELECT *
    FROM tracksolid.tickets
    WHERE geom IS NOT NULL
      AND (p_service_type IS NULL OR service_type      = p_service_type)
      AND (p_status       IS NULL OR normalized_status = p_status)
      AND (NOT p_open_only OR is_actionable IS TRUE)
  )
  SELECT jsonb_build_object(
    'summary', jsonb_build_object(
      'ticket_count', COUNT(*),
      'inc',          COUNT(*) FILTER (WHERE service_type = 'inc'),
      'crq',          COUNT(*) FILTER (WHERE service_type = 'crq'),
      'open',         COUNT(*) FILTER (WHERE is_actionable IS TRUE),
      'by_status',    (SELECT jsonb_object_agg(s, c)
                         FROM (SELECT normalized_status AS s, COUNT(*) AS c
                                 FROM filtered GROUP BY normalized_status) z)
    ),
    'geojson', jsonb_build_object(
      'type', 'FeatureCollection',
      'features', COALESCE(jsonb_agg(
        jsonb_build_object(
          'type', 'Feature',
          'properties', jsonb_build_object(
            'ticket_id',     ticket_id,
            'service_type',  service_type,
            'status',        normalized_status,
            'raw_status',    raw_status,
            'cluster',       cluster,
            'region',        region,
            'location_name', location_name,
            'department',    department,
            'owner',         owner,
            'assigned_team', assigned_team,
            'sla_status',    sla_status,
            'is_actionable', is_actionable,
            'geo_source',    geo_source,
            'created_at',    to_char(created_at_service, 'YYYY-MM-DD HH24:MI:SS'),
            'scheduled_at',  to_char(scheduled_at,       'YYYY-MM-DD HH24:MI:SS')
          ),
          'geometry', ST_AsGeoJSON(geom)::jsonb
        )
      ), '[]'::jsonb)
    )
  ) INTO v_result FROM filtered;

  RETURN v_result;
END $function$;

COMMENT ON FUNCTION reporting.fn_tickets_for_map(text, text, boolean) IS
  'INC/CRQ tickets as a GeoJSON FeatureCollection for the FleetOps Tickets map. Migration 21.';

-- ── grants (guarded: roles may not exist on a fresh DB) ───────────────────────
DO $grants$
BEGIN
  IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'dashboard_ro') THEN
    GRANT USAGE ON SCHEMA tracksolid TO dashboard_ro;
    GRANT SELECT ON tracksolid.tickets, tracksolid.geo_clusters TO dashboard_ro;
    GRANT EXECUTE ON FUNCTION reporting.fn_tickets_for_map(text, text, boolean) TO dashboard_ro;
  END IF;
  IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'grafana_ro') THEN
    GRANT SELECT ON tracksolid.tickets, tracksolid.geo_clusters TO grafana_ro;
    GRANT EXECUTE ON FUNCTION reporting.fn_tickets_for_map(text, text, boolean) TO grafana_ro;
  END IF;
END $grants$;
