-- 01_tickets_schema.sql — fleettickets · INC/CRQ ticket store (raw-jsonb-first)
-- ─────────────────────────────────────────────────────────────────────────────
-- Consolidated final-state schema for the field-ops ticket layer. This supersedes
-- the historical tracksolid migrations 21→23 (where this feature was first built);
-- fleettickets now owns the `tickets` schema. The schema lives in the shared
-- database (`tracksolid_db` today) so the existing dashboard_api read-API and the
-- FleetOps Tickets map keep working unchanged.
--
--   tickets.inc / tickets.crq   one raw-jsonb row per ticket (ticket_id + raw +
--                               derived geom/geo_source). INC = incident/fault,
--                               CRQ = new-installation.
--   tickets.geo_clusters        cluster -> coordinate gazetteer (coarse fallback)
--   tickets.geo_locations       cleaned-location -> coordinate cache (precise)
--   reporting.fn_tickets_for_map GeoJSON read function consumed by dashboard_api
--
-- geom resolution: feed coords (raw lat/lng) -> location cache -> cluster centroid
-- -> none. Idempotent: safe on a fresh DB and re-appliable on the live DB.
-- Requires PostGIS (present on the shared DB; on a brand-new DB a superuser must
-- run CREATE EXTENSION postgis first).
-- ─────────────────────────────────────────────────────────────────────────────

CREATE EXTENSION IF NOT EXISTS postgis;
CREATE SCHEMA IF NOT EXISTS tickets;
CREATE SCHEMA IF NOT EXISTS reporting;   -- shared read layer (the fn lives here for dashboard_api)
SET search_path = tickets, public;

-- ── normalize helper (generic upper/collapse/trim key) ───────────────────────
CREATE OR REPLACE FUNCTION tickets.norm_cluster(p text)
 RETURNS text LANGUAGE sql IMMUTABLE PARALLEL SAFE
AS $fn$ SELECT NULLIF(upper(regexp_replace(trim(COALESCE(p, '')), '\s+', ' ', 'g')), '') $fn$;

-- ── gazetteer: cluster -> coordinates (coarse fallback) ──────────────────────
CREATE TABLE IF NOT EXISTS tickets.geo_clusters (
  cluster_key text PRIMARY KEY,
  region      text,
  lat         double precision,
  lng         double precision,
  geom        geometry(Point, 4326),
  source      text,
  verified    boolean     NOT NULL DEFAULT false,
  updated_at  timestamptz NOT NULL DEFAULT now()
);

CREATE OR REPLACE FUNCTION tickets.tg_geo_clusters_geom()
 RETURNS trigger LANGUAGE plpgsql AS $fn$
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 $fn$;
DROP TRIGGER IF EXISTS trg_geo_clusters_geom ON tickets.geo_clusters;
CREATE TRIGGER trg_geo_clusters_geom BEFORE INSERT OR UPDATE ON tickets.geo_clusters
  FOR EACH ROW EXECUTE FUNCTION tickets.tg_geo_clusters_geom();

-- ── location geocode cache: cleaned location_name -> coordinates (precise) ───
-- query_key = tickets.norm_cluster(location_name); resolve joins on it without
-- re-deriving the place-extraction regex (that lives in the loader).
CREATE TABLE IF NOT EXISTS tickets.geo_locations (
  query_key     text PRIMARY KEY,
  location_name text,
  cluster       text,
  region        text,
  query         text,
  lat           double precision,
  lng           double precision,
  geom          geometry(Point, 4326),
  confidence    numeric,
  provider      text,
  verified      boolean     NOT NULL DEFAULT false,
  updated_at    timestamptz NOT NULL DEFAULT now()
);

CREATE OR REPLACE FUNCTION tickets.tg_geo_locations_geom()
 RETURNS trigger LANGUAGE plpgsql AS $fn$
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 $fn$;
DROP TRIGGER IF EXISTS trg_geo_locations_geom ON tickets.geo_locations;
CREATE TRIGGER trg_geo_locations_geom BEFORE INSERT OR UPDATE ON tickets.geo_locations
  FOR EACH ROW EXECUTE FUNCTION tickets.tg_geo_locations_geom();

-- ── per-type ticket tables (raw-jsonb-first) ─────────────────────────────────
CREATE TABLE IF NOT EXISTS tickets.inc (
  ticket_id   text PRIMARY KEY,
  raw         jsonb NOT NULL,
  geom        geometry(Point, 4326),
  geo_source  text,                            -- 'feed' | 'location' | 'cluster' | 'none'
  ingested_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS tickets.crq (LIKE tickets.inc INCLUDING ALL);

-- ── geom trigger — read from raw; never clobber a deliberate geom-only update ─
CREATE OR REPLACE FUNCTION tickets.tg_ticket_geom()
 RETURNS trigger LANGUAGE plpgsql AS $fn$
DECLARE
  v_lat double precision := NULLIF(NEW.raw->>'latitude','')::double precision;
  v_lng double precision := NULLIF(NEW.raw->>'longitude','')::double precision;
  g geometry(Point, 4326);
BEGIN
  IF TG_OP = 'UPDATE' AND NEW.raw IS NOT DISTINCT FROM OLD.raw THEN
    RETURN NEW;                                -- geom/geo_source-only update — keep caller's value
  END IF;
  IF v_lat IS NOT NULL AND v_lng IS NOT NULL
     AND v_lat BETWEEN -90 AND 90 AND v_lng BETWEEN -180 AND 180
     AND NOT (v_lat = 0 AND v_lng = 0) THEN
    NEW.geom := ST_SetSRID(ST_MakePoint(v_lng, v_lat), 4326);
    NEW.geo_source := 'feed';
  ELSE
    SELECT gc.geom INTO g FROM tickets.geo_clusters gc
     WHERE gc.cluster_key = tickets.norm_cluster(NEW.raw->>'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 $fn$;

DROP TRIGGER IF EXISTS trg_inc_geom ON tickets.inc;
CREATE TRIGGER trg_inc_geom BEFORE INSERT OR UPDATE ON tickets.inc
  FOR EACH ROW EXECUTE FUNCTION tickets.tg_ticket_geom();
DROP TRIGGER IF EXISTS trg_crq_geom ON tickets.crq;
CREATE TRIGGER trg_crq_geom BEFORE INSERT OR UPDATE ON tickets.crq
  FOR EACH ROW EXECUTE FUNCTION tickets.tg_ticket_geom();

-- ── resolve — prefer location cache, else cluster centroid (non-feed rows) ───
CREATE OR REPLACE FUNCTION tickets.resolve_ticket_geoms()
 RETURNS integer LANGUAGE plpgsql AS $fn$
DECLARE n integer; m integer;
BEGIN
  UPDATE tickets.inc t
     SET geom = COALESCE(loc.geom, gc.geom),
         geo_source = CASE WHEN loc.geom IS NOT NULL THEN 'location'
                           WHEN gc.geom  IS NOT NULL THEN 'cluster' ELSE 'none' END
    FROM tickets.inc base
    LEFT JOIN tickets.geo_locations loc
      ON loc.query_key = tickets.norm_cluster(base.raw->>'location_name') AND loc.geom IS NOT NULL
    LEFT JOIN tickets.geo_clusters gc
      ON gc.cluster_key = tickets.norm_cluster(base.raw->>'cluster') AND gc.geom IS NOT NULL
   WHERE t.ticket_id = base.ticket_id
     AND t.geo_source IS DISTINCT FROM 'feed'
     AND (t.geom IS DISTINCT FROM COALESCE(loc.geom, gc.geom)
          OR t.geo_source IS DISTINCT FROM CASE WHEN loc.geom IS NOT NULL THEN 'location'
                                                WHEN gc.geom  IS NOT NULL THEN 'cluster' ELSE 'none' END);
  GET DIAGNOSTICS n = ROW_COUNT;

  UPDATE tickets.crq t
     SET geom = COALESCE(loc.geom, gc.geom),
         geo_source = CASE WHEN loc.geom IS NOT NULL THEN 'location'
                           WHEN gc.geom  IS NOT NULL THEN 'cluster' ELSE 'none' END
    FROM tickets.crq base
    LEFT JOIN tickets.geo_locations loc
      ON loc.query_key = tickets.norm_cluster(base.raw->>'location_name') AND loc.geom IS NOT NULL
    LEFT JOIN tickets.geo_clusters gc
      ON gc.cluster_key = tickets.norm_cluster(base.raw->>'cluster') AND gc.geom IS NOT NULL
   WHERE t.ticket_id = base.ticket_id
     AND t.geo_source IS DISTINCT FROM 'feed'
     AND (t.geom IS DISTINCT FROM COALESCE(loc.geom, gc.geom)
          OR t.geo_source IS DISTINCT FROM CASE WHEN loc.geom IS NOT NULL THEN 'location'
                                                WHEN gc.geom  IS NOT NULL THEN 'cluster' ELSE 'none' END);
  GET DIAGNOSTICS m = ROW_COUNT;
  RETURN n + m;
END $fn$;

-- ── indexes ───────────────────────────────────────────────────────────────────
CREATE INDEX IF NOT EXISTS ix_inc_status_raw     ON tickets.inc ((raw->>'normalized_status'));
CREATE INDEX IF NOT EXISTS ix_inc_actionable_raw ON tickets.inc (((raw->>'is_actionable')::boolean))
  WHERE (raw->>'is_actionable')::boolean;
CREATE INDEX IF NOT EXISTS ix_inc_cluster_raw    ON tickets.inc (tickets.norm_cluster(raw->>'cluster'));
CREATE INDEX IF NOT EXISTS ix_inc_loc_raw        ON tickets.inc (tickets.norm_cluster(raw->>'location_name'));
CREATE INDEX IF NOT EXISTS ix_inc_geom           ON tickets.inc USING gist (geom);
CREATE INDEX IF NOT EXISTS ix_crq_status_raw     ON tickets.crq ((raw->>'normalized_status'));
CREATE INDEX IF NOT EXISTS ix_crq_actionable_raw ON tickets.crq (((raw->>'is_actionable')::boolean))
  WHERE (raw->>'is_actionable')::boolean;
CREATE INDEX IF NOT EXISTS ix_crq_cluster_raw    ON tickets.crq (tickets.norm_cluster(raw->>'cluster'));
CREATE INDEX IF NOT EXISTS ix_crq_loc_raw        ON tickets.crq (tickets.norm_cluster(raw->>'location_name'));
CREATE INDEX IF NOT EXISTS ix_crq_geom           ON tickets.crq USING gist (geom);

-- ── read function (GeoJSON; consumed by dashboard_api GET /webhook/tickets) ──
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 $fn$
DECLARE v_result jsonb;
BEGIN
  p_service_type := lower(NULLIF(p_service_type, ''));
  p_status       := NULLIF(p_status, '');
  WITH filtered AS (
    SELECT 'inc'::text AS service_type, raw, geom, geo_source FROM tickets.inc
     WHERE geom IS NOT NULL
       AND (p_service_type IS NULL OR p_service_type = 'inc')
       AND (p_status IS NULL OR raw->>'normalized_status' = p_status)
       AND (NOT p_open_only OR (raw->>'is_actionable')::boolean IS TRUE)
    UNION ALL
    SELECT 'crq'::text AS service_type, raw, geom, geo_source FROM tickets.crq
     WHERE geom IS NOT NULL
       AND (p_service_type IS NULL OR p_service_type = 'crq')
       AND (p_status IS NULL OR raw->>'normalized_status' = p_status)
       AND (NOT p_open_only OR (raw->>'is_actionable')::boolean 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 (raw->>'is_actionable')::boolean IS TRUE),
      'by_status',    (SELECT jsonb_object_agg(s, c)
                         FROM (SELECT raw->>'normalized_status' AS s, COUNT(*) AS c
                                 FROM filtered GROUP BY raw->>'normalized_status') z)
    ),
    'geojson', jsonb_build_object(
      'type', 'FeatureCollection',
      'features', COALESCE(jsonb_agg(
        jsonb_build_object(
          'type', 'Feature',
          'properties', jsonb_build_object(
            'ticket_id',     raw->>'ticket_id',
            'service_type',  service_type,
            'status',        raw->>'normalized_status',
            'raw_status',    raw->>'raw_status',
            'cluster',       raw->>'cluster',
            'region',        raw->>'region',
            'location_name', raw->>'location_name',
            'department',    raw->>'department',
            'owner',         raw->>'owner',
            'assigned_team', raw->>'assigned_team',
            'sla_status',    raw->>'sla_status',
            'is_actionable', (raw->>'is_actionable')::boolean,
            'geo_source',    geo_source,
            'created_at',    raw->>'created_at_service',
            'scheduled_at',  raw->>'scheduled_at'
          ),
          'geometry', ST_AsGeoJSON(geom)::jsonb
        )
      ), '[]'::jsonb)
    )
  ) INTO v_result FROM filtered;
  RETURN v_result;
END $fn$;

COMMENT ON FUNCTION reporting.fn_tickets_for_map(text, text, boolean) IS
  'INC/CRQ tickets (tickets.inc + tickets.crq, raw-jsonb-first) as GeoJSON. fleettickets 01.';

-- ── grants (guarded: roles may not exist on a fresh DB) ───────────────────────
DO $grants$
BEGIN
  IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'tracksolid_owner') THEN
    GRANT USAGE, CREATE ON SCHEMA tickets TO tracksolid_owner;
    GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA tickets TO tracksolid_owner;
    GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA tickets TO tracksolid_owner;
  END IF;
  IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'dashboard_ro') THEN
    GRANT USAGE ON SCHEMA tickets TO dashboard_ro;
    GRANT SELECT ON tickets.inc, tickets.crq, tickets.geo_clusters, tickets.geo_locations 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 USAGE ON SCHEMA tickets TO grafana_ro;
    GRANT SELECT ON tickets.inc, tickets.crq, tickets.geo_clusters, tickets.geo_locations TO grafana_ro;
    GRANT EXECUTE ON FUNCTION reporting.fn_tickets_for_map(text, text, boolean) TO grafana_ro;
  END IF;
END $grants$;
