-- ============================================================================
-- LUPMIS2 — external layer definitions
--
-- Stores the layers a user adds through the map's "Add External Layer" dialog,
-- so that they persist, can be shared within a district, and survive a
-- re-install of the application.
--
-- Two tables:
--
--   spatial.hlp_layer_types      the kinds of layer that can be added
--                                (wms, wfs, xyz, cog, and whatever comes next)
--   spatial.es_external_layers   one row per layer a user has added
--
-- The four fields the dialog collects are layer_type, url, layer_name and
-- title. Everything else in es_external_layers exists for one of three reasons,
-- marked in the column comments:
--
--   [dialog]  captured directly from the Add External Layer form
--   [parity]  needed to describe the external layers the application already
--             hard-codes; without these, moving those layers into the database
--             would lose information the map currently relies on
--   [admin]   ownership, scoping, ordering and audit
--
-- Target: PostgreSQL 10+ (uses GENERATED ALWAYS AS IDENTITY).
-- Schema: `spatial` is used for consistency with the rest of LUPMIS, although
--         neither table holds geometry — see the notes at the end.
--
-- NOTE ON SEQUENCING. The application still hard-codes the four layer types in
-- the Add External Layer dialog and will keep doing so until the endpoint in
-- note 4 exists. Until then hlp_layer_types is the database's own record, and
-- the two must be kept in step by hand: adding a row here does not make the
-- type appear in the dialog, and adding a radio button there without a row
-- here will be rejected by the foreign key.
-- ============================================================================


-- ============================================================================
-- 1. Helper table — the available layer types
--
-- This exists so a new kind of layer (WMTS, ArcGIS REST, vector tiles, a
-- GeoPackage URL) can be introduced by inserting a row, with no change to the
-- schema, no deployment and no code release on the database side.
--
-- `hlp_` marks it as a lookup/helper table rather than data.
-- ============================================================================

CREATE TABLE spatial.hlp_layer_types (
    code                text        PRIMARY KEY,
                        -- the value stored in es_external_layers.layer_type and
                        -- sent in the API payload: lowercase, no spaces
    label               text        NOT NULL,
                        -- what the dialog shows on the button, e.g. 'WMS'
    description         text,
                        -- one line explaining the type, for a tooltip or docs

    -- ---- rules the client and the database both need -----------------------
    requires_layer_name boolean     NOT NULL DEFAULT false,
                        -- true for WMS/WFS, where a service publishes many
                        -- layers and one must be named. This is the rule that
                        -- used to be a hard-coded CHECK constraint.
    layer_name_label    text,
                        -- what to call that field for this type, e.g.
                        -- 'WMS LAYERS parameter' vs 'WFS typename'
    url_placeholder     text,
                        -- example URL shown in the empty form field
    hint                text,
                        -- extra guidance shown when the type is selected

    -- ---- housekeeping ------------------------------------------------------
    sort_order          integer     NOT NULL DEFAULT 0,
    is_active           boolean     NOT NULL DEFAULT true,
                        -- false retires a type: no new layers may use it, but
                        -- existing layers of that type keep working
    created_at          timestamptz NOT NULL DEFAULT now(),
    updated_at          timestamptz NOT NULL DEFAULT now(),

    -- The code travels through URLs, JSON payloads and CSS class names, so it
    -- is restricted to a plain lowercase identifier.
    CONSTRAINT hlp_layer_types_code_chk
        CHECK (code ~ '^[a-z][a-z0-9_]*$')
);

COMMENT ON TABLE spatial.hlp_layer_types IS
    'Lookup of layer types available in the Add External Layer dialog. A new '
    'type is added by inserting a row — no schema change required.';
COMMENT ON COLUMN spatial.hlp_layer_types.requires_layer_name IS
    'Whether es_external_layers.layer_name is mandatory for this type. '
    'Enforced by the trigger on that table, because a CHECK constraint cannot '
    'read another table.';
COMMENT ON COLUMN spatial.hlp_layer_types.is_active IS
    'false stops new layers being created with this type without invalidating '
    'the ones that already exist.';

INSERT INTO spatial.hlp_layer_types
    (code,  label, description,
     requires_layer_name, layer_name_label, url_placeholder, hint, sort_order)
VALUES
    ('wms', 'WMS', 'OGC Web Map Service — server-rendered map images.',
     true,  'WMS LAYERS parameter (e.g. workspace:layer)',
     'https://example.com/wms', NULL, 10),

    ('wfs', 'WFS', 'OGC Web Feature Service — vector features as GeoJSON.',
     true,  'WFS typename (e.g. workspace:layer)',
     'https://example.com/wfs', NULL, 20),

    ('xyz', 'XYZ', 'Pre-rendered tile pyramid addressed by z/x/y.',
     false, NULL,
     'https://example.com/tiles/{z}/{x}/{y}.png', NULL, 30),

    ('cog', 'COG', 'Cloud-Optimized GeoTIFF streamed by byte-range request.',
     false, NULL,
     'https://example.com/data/elevation_cog.tif',
     'The server must allow CORS and byte-range requests.', 40);


-- ============================================================================
-- 2. The layers themselves
-- ============================================================================

CREATE TABLE spatial.es_external_layers (
    id                 bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,

    -- ---- from the Add External Layer dialog -------------------------------
    layer_type         text        NOT NULL
                       REFERENCES spatial.hlp_layer_types (code)
                       ON UPDATE CASCADE ON DELETE RESTRICT,
                                               -- [dialog] see hlp_layer_types
    url                text        NOT NULL,   -- [dialog] service or file URL
    layer_name         text,                   -- [dialog] required when the type
                                               --          says so; see the trigger
    title              text        NOT NULL,   -- [dialog] name in the layer switcher

    -- ---- scoping and ownership -------------------------------------------
    districtid         integer,                -- [admin] NULL = every district
    userid             integer     NOT NULL,   -- [admin] who added it
    is_shared          boolean     NOT NULL DEFAULT false,
                                               -- [admin] false = creator only;
                                               --         true  = the district

    -- ---- rendering -------------------------------------------------------
    layer_group_id     integer     NOT NULL DEFAULT 8,
                                               -- [admin] 8 = "External Sources"
    style_name         text,                   -- [parity] WMS STYLES parameter
    server_type        text,                   -- [parity] geoserver | mapserver | qgis | NULL
    opacity            numeric(3,2) NOT NULL DEFAULT 1.00,   -- [parity]
    z_index            integer,                -- [parity] negative sits behind overlays
    visible_by_default boolean     NOT NULL DEFAULT false,   -- [parity]
    online_only        boolean     NOT NULL DEFAULT false,
                                               -- [parity] warn when toggled on offline
    attribution        text,                   -- [parity] credit/licence; often required
    legend_url         text,                   -- [parity] legend shown while visible
    min_zoom           smallint,               -- [parity]
    max_zoom           smallint,               -- [parity]

    -- ---- lifecycle and audit ---------------------------------------------
    description        text,                   -- [admin] free note about the source
    is_active          boolean     NOT NULL DEFAULT true,    -- [admin] soft delete
    sort_order         integer     NOT NULL DEFAULT 0,       -- [admin]
    created_at         timestamptz NOT NULL DEFAULT now(),   -- [admin]
    updated_at         timestamptz NOT NULL DEFAULT now(),   -- [admin]
    updated_by         integer,                              -- [admin]

    -- ---- constraints ------------------------------------------------------
    -- layer_type is validated by the foreign key above, and the conditional
    -- layer_name rule by the trigger below, because a CHECK constraint cannot
    -- read another table.

    -- The PWA is served over HTTPS, so a browser blocks any http:// sub-resource
    -- as mixed content. An http URL cannot render, it just fails silently — so
    -- it is rejected here rather than stored and puzzled over later.
    CONSTRAINT es_external_layers_https_chk
        CHECK (url ~* '^https://'),

    CONSTRAINT es_external_layers_opacity_chk
        CHECK (opacity >= 0 AND opacity <= 1),

    CONSTRAINT es_external_layers_zoom_chk
        CHECK (min_zoom IS NULL OR max_zoom IS NULL OR min_zoom <= max_zoom)
);


-- ---------------------------------------------------------------------------
-- The rule that moved out of the schema and into the data
--
-- `requires_layer_name` now lives in hlp_layer_types, so it has to be enforced
-- somewhere that can read it. A CHECK constraint cannot; a trigger can.
--
-- It raises check_violation deliberately, so any API error handling that
-- already distinguishes constraint failures keeps working unchanged.
--
-- The is_active test applies to INSERT only: retiring a type should stop new
-- layers being created with it without freezing the ones that already exist.
-- ---------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION spatial.es_external_layers_validate()
RETURNS trigger AS $$
DECLARE
    t spatial.hlp_layer_types%ROWTYPE;
BEGIN
    SELECT * INTO t
      FROM spatial.hlp_layer_types
     WHERE code = NEW.layer_type;
    -- The foreign key guarantees a row exists, so no NOT FOUND branch here.

    IF TG_OP = 'INSERT' AND NOT t.is_active THEN
        RAISE EXCEPTION 'Layer type "%" is retired and cannot be used for new layers',
            NEW.layer_type
            USING ERRCODE = 'check_violation';
    END IF;

    IF t.requires_layer_name
       AND (NEW.layer_name IS NULL OR btrim(NEW.layer_name) = '') THEN
        RAISE EXCEPTION 'Layer type "%" requires layer_name (%)',
            NEW.layer_type, COALESCE(t.layer_name_label, 'layer name')
            USING ERRCODE = 'check_violation';
    END IF;

    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER es_external_layers_validate_trg
    BEFORE INSERT OR UPDATE ON spatial.es_external_layers
    FOR EACH ROW EXECUTE FUNCTION spatial.es_external_layers_validate();


-- ---------------------------------------------------------------------------
-- Foreign keys to districts and users
--
-- Left commented because the referenced table and column names were not
-- verifiable from the application side — it only ever sees districts and users
-- through the API. Confirm them, then enable:
--
--   SELECT table_schema, table_name FROM information_schema.tables
--    WHERE table_name ILIKE '%district%' OR table_name ILIKE '%user%';
--
-- ON DELETE RESTRICT for the district: removing a district that still has
-- layers attached should be a decision, not a side effect.
-- ---------------------------------------------------------------------------
-- ALTER TABLE spatial.es_external_layers
--     ADD CONSTRAINT es_external_layers_district_fk
--     FOREIGN KEY (districtid) REFERENCES spatial.districts (districtid)
--     ON DELETE RESTRICT;
--
-- ALTER TABLE spatial.es_external_layers
--     ADD CONSTRAINT es_external_layers_user_fk
--     FOREIGN KEY (userid) REFERENCES public.users (id)
--     ON DELETE RESTRICT;


-- ---------------------------------------------------------------------------
-- Stop the same layer being added twice
--
-- COALESCE is needed because NULL never equals NULL: without it a duplicate
-- would be allowed whenever districtid or layer_name is NULL, which is exactly
-- the XYZ and COG case.
-- ---------------------------------------------------------------------------
CREATE UNIQUE INDEX es_external_layers_unique_idx
    ON spatial.es_external_layers (
        COALESCE(districtid, -1),
        layer_type,
        url,
        COALESCE(layer_name, '')
    )
    WHERE is_active;


-- ---------------------------------------------------------------------------
-- Indexes for the read path
-- ---------------------------------------------------------------------------
-- The endpoint's main query: everything this district may see, which is the
-- district's own layers plus the global ones (districtid IS NULL).
CREATE INDEX es_external_layers_district_idx
    ON spatial.es_external_layers (districtid, sort_order)
    WHERE is_active;

CREATE INDEX es_external_layers_user_idx
    ON spatial.es_external_layers (userid)
    WHERE is_active;

-- Supports the ON DELETE RESTRICT check on hlp_layer_types, and "how many
-- layers use this type" questions.
CREATE INDEX es_external_layers_type_idx
    ON spatial.es_external_layers (layer_type);


-- ---------------------------------------------------------------------------
-- Keep updated_at honest, on both tables
-- ---------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION spatial.touch_updated_at()
RETURNS trigger AS $$
BEGIN
    NEW.updated_at := now();
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER es_external_layers_touch_trg
    BEFORE UPDATE ON spatial.es_external_layers
    FOR EACH ROW EXECUTE FUNCTION spatial.touch_updated_at();

CREATE TRIGGER hlp_layer_types_touch_trg
    BEFORE UPDATE ON spatial.hlp_layer_types
    FOR EACH ROW EXECUTE FUNCTION spatial.touch_updated_at();


-- ---------------------------------------------------------------------------
-- Documentation that travels with the schema
-- ---------------------------------------------------------------------------
COMMENT ON TABLE spatial.es_external_layers IS
    'External map layers added by users through the Add External Layer dialog. '
    'One row per layer. Holds no geometry — the data stays on the remote server '
    'and is fetched by the client at display time.';

COMMENT ON COLUMN spatial.es_external_layers.layer_type IS
    'References spatial.hlp_layer_types.code. Determines how the client builds '
    'the request and which other columns apply.';
COMMENT ON COLUMN spatial.es_external_layers.url IS
    'WMS/WFS service endpoint, XYZ template with {z}/{x}/{y}, or the URL of a '
    'Cloud-Optimized GeoTIFF. Must be https — see the mixed-content constraint.';
COMMENT ON COLUMN spatial.es_external_layers.layer_name IS
    'WMS LAYERS parameter or WFS typename, e.g. workspace:layer. Required only '
    'for types whose hlp_layer_types.requires_layer_name is true.';
COMMENT ON COLUMN spatial.es_external_layers.districtid IS
    'NULL means the layer is offered in every district. A value restricts it '
    'to that district.';
COMMENT ON COLUMN spatial.es_external_layers.is_shared IS
    'false: visible only to the user who created it. true: visible to everyone '
    'within its district scope.';
COMMENT ON COLUMN spatial.es_external_layers.is_active IS
    'Soft delete. The dialog marks user-added layers as removable, and a hard '
    'delete would silently break the map for colleagues who rely on a shared '
    'layer, with no way to tell what disappeared.';
COMMENT ON COLUMN spatial.es_external_layers.online_only IS
    'The layer needs connectivity. The client uses this to explain, rather '
    'than show an empty layer, when it is switched on in the field.';
COMMENT ON COLUMN spatial.es_external_layers.z_index IS
    'Render order. Negative values push a layer behind the standard overlays — '
    'used for background imagery such as slope or hillshade.';


-- ---------------------------------------------------------------------------
-- Examples
--
-- The first is a layer the application currently hard-codes. It is the reason
-- for the [parity] columns: without style_name, opacity, z_index, attribution,
-- legend_url and online_only, this layer could not be represented here.
-- ---------------------------------------------------------------------------
INSERT INTO spatial.es_external_layers
    (layer_type, url, layer_name, title, districtid, userid, is_shared,
     style_name, server_type, opacity, z_index, visible_by_default,
     online_only, attribution, legend_url, description)
VALUES
    ('wms',
     'https://ows.digitalearth.africa/wms',
     'srtm_deriv',
     'DEAfrica Slope (SRTM 30m)',
     NULL,          -- every district
     1,
     true,
     'style_slope',
     NULL,          -- not a GeoServer; serverType must stay unset
     0.50,
     -50,           -- behind the standard overlays
     false,
     true,
     '&copy; <a href="https://www.digitalearthafrica.org/">Digital Earth Africa</a> — SRTM-derived Slope',
     'https://ows.digitalearth.africa/legend/srtm_deriv/style_slope/legend.png',
     'Terrain slope derived from SRTM, served by Digital Earth Africa.');

-- A raster published to the LUSPA object store: no layer name, single file.
INSERT INTO spatial.es_external_layers
    (layer_type, url, title, districtid, userid, is_shared, description)
VALUES
    ('cog',
     'https://minioapi.lupmis4luspa.org/raster-objects/dem/koforidua_dem.tif',
     'Koforidua DEM',
     1,
     1,
     true,
     'Digital elevation model for the district, published as a Cloud-Optimized GeoTIFF.');


-- ============================================================================
-- Adding a layer type later
--
-- This is the whole point of the helper table. No schema change, no migration:
--
--   INSERT INTO spatial.hlp_layer_types
--       (code, label, description, requires_layer_name, layer_name_label,
--        url_placeholder, sort_order)
--   VALUES
--       ('wmts', 'WMTS', 'OGC Web Map Tile Service — pre-rendered tiles.',
--        true, 'WMTS layer identifier',
--        'https://example.com/wmts', 50);
--
-- Retiring one, without invalidating the layers already using it:
--
--   UPDATE spatial.hlp_layer_types SET is_active = false WHERE code = 'wfs';
--
-- Deleting a type that is still in use is refused by the foreign key, which is
-- the intended behaviour — retire it instead.
-- ============================================================================


-- ============================================================================
-- Notes for the database team
--
-- 1. Schema choice. `spatial` matches the rest of LUPMIS, but neither table
--    holds geometry — they are configuration. If there is a schema for
--    application settings they belong there instead; only the qualifier changes.
--
-- 2. Prefixes. `hlp_` marks a helper/lookup table, as requested. `es_` follows
--    the existing convention of naming a table after its layer group (`be_`
--    biophysical, `lu_` land use, `pi_` physical infrastructure); group 8 is
--    "External Sources".
--
-- 3. The client still hard-codes the four types. Until the endpoint below
--    exists, the dialog in src/components/MapView.js and the rows in
--    hlp_layer_types must be changed together. Adding a row here alone will not
--    surface the type in the application; adding a radio button there alone
--    will fail the foreign key on save.
--
-- 4. Suggested endpoints, in the shape of the existing spatial_planning ones
--    ({ api_token, district_id } in, { success, data: [...] } out):
--
--      get_layer_types.php         rows where is_active, ordered by sort_order
--                                  — this is what lets the dialog stop
--                                  hard-coding the list
--      get_external_layers.php     rows where is_active
--                                  and (districtid = :district OR districtid IS NULL)
--                                  and (is_shared OR userid = :user)
--                                  ordered by sort_order, title
--      save_external_layer.php     insert or update one row
--      delete_external_layer.php   sets is_active = false, never DELETE
--
-- 5. Authorisation is the part worth deciding before this ships. The read query
--    above assumes a user may see their own layers plus shared ones in their
--    district. Who may edit or deactivate a *shared* layer — only its creator,
--    or any user in the district — is a policy question this table can support
--    either way, through userid.
--
-- 6. The url column holds a value the browser will fetch. Two consequences:
--    the https constraint is functional, not cosmetic; and the endpoint should
--    validate the URL server-side as well, since a stored URL is effectively an
--    instruction to every client that loads the layer.
-- ============================================================================
