-- =============================================================================
-- AgriFlow — Supabase / Postgres Schema
-- Apply with: psql $SUPABASE_DB_URL -f db/schema.sql
-- =============================================================================
--
-- TABLE INVENTORY
--   1. kabupaten             — mirror of sample_data/kabupaten_jatim.csv
--   2. commodity             — mirror of sample_data/komoditas_constraints.csv
--   3. surplus_deficit       — mirror of sample_data/surplus_deficit.csv
--   4. weather_forecast      — mirror of sample_data/weather_forecast.csv
--   5. historical_prices     — mirror of sample_data/historical_price_stats.csv
--   6. commodity_code_map    — Bapanas integer ID ↔ canonical code mapping
--   7. policy_docs           — RAG document store (pgvector embeddings)
--   8. price_history         — TimesFM INPUT  (daily price time series per city)
--   9. forecasts             — TimesFM OUTPUT (per-commodity per-city forecasts)
--  10. subscriber            — WhatsApp identity (hashed) + plan state
--  11. wa_usage_daily        — per-day metered query counter (free-tier quota)
--  12. payment_order         — upgrade orders + settlement status
-- =============================================================================

-- Required extension for pgvector (RAG embeddings)
CREATE EXTENSION IF NOT EXISTS vector;


-- =============================================================================
-- 1. KABUPATEN
-- =============================================================================
CREATE TABLE IF NOT EXISTS kabupaten (
    kab_id          VARCHAR(10)     PRIMARY KEY,    -- BPS wilayah code, e.g. "3578"
    nama            VARCHAR(100)    NOT NULL,        -- e.g. "Kota Surabaya"
    latitude        DOUBLE PRECISION NOT NULL,
    longitude       DOUBLE PRECISION NOT NULL,
    ipm_2024        DOUBLE PRECISION NOT NULL,       -- IPM BPS 2024
    population_2024 INTEGER         NOT NULL DEFAULT 0,
    tier            VARCHAR(20)     NOT NULL         -- 'TIER_1_HIGH' | 'TIER_2_MEDIUM'
                    CHECK (tier IN ('TIER_1_HIGH', 'TIER_2_MEDIUM')),
    created_at      TIMESTAMPTZ     NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ     NOT NULL DEFAULT NOW()
);

CREATE INDEX IF NOT EXISTS idx_kabupaten_tier ON kabupaten(tier);


-- =============================================================================
-- 2. COMMODITY
-- =============================================================================
CREATE TABLE IF NOT EXISTS commodity (
    code                VARCHAR(50)     PRIMARY KEY,    -- canonical code, e.g. "cabai_merah"
    nama                VARCHAR(100)    NOT NULL,
    max_distance_km     DOUBLE PRECISION NOT NULL,
    min_viable_tons     DOUBLE PRECISION NOT NULL,
    max_fresh_age_days  INTEGER         NOT NULL,
    bulog_priority      BOOLEAN         NOT NULL DEFAULT FALSE,
    is_imported         BOOLEAN         NOT NULL DEFAULT FALSE,
    created_at          TIMESTAMPTZ     NOT NULL DEFAULT NOW(),
    updated_at          TIMESTAMPTZ     NOT NULL DEFAULT NOW()
);


-- =============================================================================
-- 3. SURPLUS_DEFICIT
-- =============================================================================
CREATE TABLE IF NOT EXISTS surplus_deficit (
    id                  BIGSERIAL       PRIMARY KEY,
    kab_id              VARCHAR(10)     NOT NULL REFERENCES kabupaten(kab_id),
    commodity_code      VARCHAR(50)     NOT NULL REFERENCES commodity(code),
    role                VARCHAR(10)     NOT NULL
                        CHECK (role IN ('SURPLUS', 'DEFICIT')),
    volume_tons         DOUBLE PRECISION NOT NULL,
    price_idr_per_kg    DOUBLE PRECISION NOT NULL,
    harvest_age_days    INTEGER         NOT NULL DEFAULT 0,
    data_source         VARCHAR(20)     NOT NULL DEFAULT 'PIHPS',
    recorded_at         TIMESTAMPTZ     NOT NULL DEFAULT NOW(),
    created_at          TIMESTAMPTZ     NOT NULL DEFAULT NOW()
);

CREATE INDEX IF NOT EXISTS idx_surdef_kab         ON surplus_deficit(kab_id);
CREATE INDEX IF NOT EXISTS idx_surdef_commodity   ON surplus_deficit(commodity_code);
CREATE INDEX IF NOT EXISTS idx_surdef_role        ON surplus_deficit(role);
CREATE INDEX IF NOT EXISTS idx_surdef_recorded_at ON surplus_deficit(recorded_at DESC);


-- =============================================================================
-- 4. WEATHER_FORECAST
-- =============================================================================
CREATE TABLE IF NOT EXISTS weather_forecast (
    id                  BIGSERIAL       PRIMARY KEY,
    origin_kab_id       VARCHAR(10)     NOT NULL REFERENCES kabupaten(kab_id),
    dest_kab_id         VARCHAR(10)     NOT NULL REFERENCES kabupaten(kab_id),
    max_rain_mm         DOUBLE PRECISION NOT NULL,
    transit_window_days INTEGER         NOT NULL DEFAULT 1,
    source              VARCHAR(20)     NOT NULL DEFAULT 'BMKG',
    valid_at            TIMESTAMPTZ     NOT NULL DEFAULT NOW(),
    created_at          TIMESTAMPTZ     NOT NULL DEFAULT NOW(),
    UNIQUE (origin_kab_id, dest_kab_id, valid_at)
);

CREATE INDEX IF NOT EXISTS idx_weather_origin ON weather_forecast(origin_kab_id);
CREATE INDEX IF NOT EXISTS idx_weather_dest   ON weather_forecast(dest_kab_id);


-- =============================================================================
-- 5. HISTORICAL_PRICES
-- (aggregate stats per commodity — used by engine for fairness scoring)
-- =============================================================================
CREATE TABLE IF NOT EXISTS historical_prices (
    commodity_code      VARCHAR(50)     PRIMARY KEY REFERENCES commodity(code),
    median_idr_per_kg   DOUBLE PRECISION NOT NULL,
    std_idr_per_kg      DOUBLE PRECISION NOT NULL,
    sample_size         INTEGER         NOT NULL DEFAULT 0,
    period_start        DATE,
    period_end          DATE,
    updated_at          TIMESTAMPTZ     NOT NULL DEFAULT NOW()
);


-- =============================================================================
-- 6. COMMODITY_CODE_MAP
-- Maps Bapanas integer IDs ↔ AgriFlow canonical codes.
-- Explicit in DB — NOT hardcoded in application logic.
-- Mitigates risk if Bapanas API changes its numbering scheme.
-- =============================================================================
CREATE TABLE IF NOT EXISTS commodity_code_map (
    mapping_id          INTEGER         PRIMARY KEY,    -- Bapanas integer commodity ID
    canonical_code      VARCHAR(50)     NOT NULL REFERENCES commodity(code),
    bapanas_name        VARCHAR(100)    NOT NULL,       -- name as returned by Bapanas API
    pihps_code          VARCHAR(50),                    -- PIHPS code if different
    notes               TEXT,
    is_active           BOOLEAN         NOT NULL DEFAULT TRUE,
    created_at          TIMESTAMPTZ     NOT NULL DEFAULT NOW(),
    updated_at          TIMESTAMPTZ     NOT NULL DEFAULT NOW()
);

CREATE INDEX IF NOT EXISTS idx_ccmap_canonical ON commodity_code_map(canonical_code);
CREATE INDEX IF NOT EXISTS idx_ccmap_active    ON commodity_code_map(is_active);

-- Seed: known Bapanas commodity IDs at time of AgriFlow v9 build (2026-05)
-- Update this table when Bapanas changes their numbering — never change canonical_code.
--
-- SELECT ... WHERE EXISTS, not a plain VALUES insert. canonical_code is a
-- foreign key into commodity, and commodity is populated by the application
-- loader, NOT by this file. A plain INSERT therefore aborts on a fresh
-- database with:
--     violates foreign key constraint "commodity_code_map_canonical_code_fkey"
-- which kills the rest of the script -- including the RLS section at the end,
-- silently leaving every table world-readable through PostgREST.
--
-- Filtering on EXISTS makes this a no-op on an empty database instead. Because
-- the whole file is IF NOT EXISTS / ON CONFLICT idempotent, re-running it after
-- the loader has populated commodity backfills these mappings.
INSERT INTO commodity_code_map (mapping_id, canonical_code, bapanas_name)
SELECT v.mapping_id, v.canonical_code, v.bapanas_name
FROM (VALUES
    (1,  'beras_premium',  'Beras Premium'),
    (2,  'beras_medium',   'Beras Medium'),
    (3,  'beras_ir64',     'Beras IR 64'),
    (4,  'jagung',         'Jagung Pipilan Kering'),
    (5,  'kedelai',        'Kedelai Biji Kering (Impor)'),
    (6,  'cabai_merah',    'Cabai Merah Besar'),
    (7,  'cabai_rawit',    'Cabai Rawit Merah'),
    (8,  'bawang_merah',   'Bawang Merah'),
    (9,  'bawang_putih',   'Bawang Putih (Bonggol)'),
    (10, 'daging_sapi',    'Daging Sapi Murni'),
    (11, 'daging_ayam',    'Daging Ayam Ras'),
    (12, 'telur_ayam',     'Telur Ayam Ras'),
    (13, 'minyak_goreng',  'Minyak Goreng Curah'),
    (14, 'gula_pasir',     'Gula Pasir Lokal'),
    (15, 'tepung_terigu',  'Tepung Terigu (Curah)'),
    (16, 'tomat',          'Tomat Sayur'),
    (17, 'kentang',        'Kentang'),
    (18, 'wortel',         'Wortel'),
    (19, 'kacang_tanah',   'Kacang Tanah Kupas')
) AS v(mapping_id, canonical_code, bapanas_name)
WHERE EXISTS (SELECT 1 FROM commodity c WHERE c.code = v.canonical_code)
ON CONFLICT (mapping_id) DO NOTHING;


-- =============================================================================
-- 7. POLICY_DOCS
-- Supabase pgvector store for RAG (Gemini embeddings, 768 dims).
-- =============================================================================
CREATE TABLE IF NOT EXISTS policy_docs (
    id              BIGSERIAL       PRIMARY KEY,
    title           TEXT            NOT NULL,
    source          VARCHAR(100),                   -- e.g. 'Permendag 2024', 'BPOM No.5/2024'
    content         TEXT            NOT NULL,       -- full text chunk
    embedding       vector(768),                    -- text-embedding-004 output
    metadata        JSONB           NOT NULL DEFAULT '{}',
    created_at      TIMESTAMPTZ     NOT NULL DEFAULT NOW()
);

-- IVFFlat index — good enough for < 1M rows; tune lists param at scale.
-- Build AFTER initial bulk insert for speed.
CREATE INDEX IF NOT EXISTS idx_policy_docs_embedding
    ON policy_docs
    USING ivfflat (embedding vector_cosine_ops)
    WITH (lists = 100);

CREATE INDEX IF NOT EXISTS idx_policy_docs_source ON policy_docs(source);


-- =============================================================================
-- 8. PRICE_HISTORY  —  TimesFM INPUT
--
-- One row per (date, city_id, commodity_code).
-- Fed into google/timesfm-2.0-500m-pytorch for forecasting.
--
-- DATA CONTRACT (shared with TimesFM repo):
--   date            DATE        — observation date (YYYY-MM-DD), no time zone
--   city_id         VARCHAR(10) — matches kabupaten.kab_id (BPS wilayah code)
--   commodity_code  VARCHAR(50) — matches commodity.code (AgriFlow canonical)
--   price_per_kg    NUMERIC(12,2) — observed price in IDR per kg
-- =============================================================================
CREATE TABLE IF NOT EXISTS price_history (
    id              BIGSERIAL       PRIMARY KEY,
    date            DATE            NOT NULL,
    city_id         VARCHAR(10)     NOT NULL REFERENCES kabupaten(kab_id),
    commodity_code  VARCHAR(50)     NOT NULL REFERENCES commodity(code),
    price_per_kg    NUMERIC(12, 2)  NOT NULL,
    data_source     VARCHAR(30)     NOT NULL DEFAULT 'PIHPS',  -- 'PIHPS'|'BAPANAS'|'MANUAL'
    created_at      TIMESTAMPTZ     NOT NULL DEFAULT NOW(),
    UNIQUE (date, city_id, commodity_code)
);

CREATE INDEX IF NOT EXISTS idx_ph_date          ON price_history(date DESC);
CREATE INDEX IF NOT EXISTS idx_ph_city          ON price_history(city_id);
CREATE INDEX IF NOT EXISTS idx_ph_commodity     ON price_history(commodity_code);
-- Composite for the primary TimesFM query pattern: time series per city+commodity
CREATE INDEX IF NOT EXISTS idx_ph_city_commodity_date
    ON price_history(city_id, commodity_code, date DESC);


-- =============================================================================
-- 9. FORECASTS  —  TimesFM OUTPUT
--
-- One row per (commodity, city_id, date) per model run.
-- Written by the TimesFM inference pipeline; read by the AgriFlow dashboard.
--
-- DATA CONTRACT (shared with TimesFM repo):
--   commodity       VARCHAR(50)    — matches commodity.code
--   city_id         VARCHAR(10)    — matches kabupaten.kab_id
--   city_name       VARCHAR(100)   — denormalized for dashboard convenience
--   date            DATE           — forecast date (YYYY-MM-DD)
--   price_forecast  NUMERIC(12,2)  — point forecast, IDR per kg
--   price_lower     NUMERIC(12,2)  — lower bound (e.g. 10th percentile)
--   price_upper     NUMERIC(12,2)  — upper bound (e.g. 90th percentile)
--   model           VARCHAR(100)   — model identifier, e.g. 'google/timesfm-2.0-500m-pytorch'
--   generated_at    TIMESTAMPTZ    — when this forecast was produced
-- =============================================================================
CREATE TABLE IF NOT EXISTS forecasts (
    id              BIGSERIAL       PRIMARY KEY,
    commodity       VARCHAR(50)     NOT NULL REFERENCES commodity(code),
    city_id         VARCHAR(10)     NOT NULL REFERENCES kabupaten(kab_id),
    city_name       VARCHAR(100)    NOT NULL,
    date            DATE            NOT NULL,
    price_forecast  NUMERIC(12, 2)  NOT NULL,
    price_lower     NUMERIC(12, 2),
    price_upper     NUMERIC(12, 2),
    model           VARCHAR(100)    NOT NULL DEFAULT 'google/timesfm-2.0-500m-pytorch',
    generated_at    TIMESTAMPTZ     NOT NULL DEFAULT NOW(),
    UNIQUE (commodity, city_id, date, model)
);

CREATE INDEX IF NOT EXISTS idx_forecasts_commodity    ON forecasts(commodity);
CREATE INDEX IF NOT EXISTS idx_forecasts_city         ON forecasts(city_id);
CREATE INDEX IF NOT EXISTS idx_forecasts_date         ON forecasts(date DESC);
CREATE INDEX IF NOT EXISTS idx_forecasts_generated_at ON forecasts(generated_at DESC);
-- Composite for the primary dashboard query: latest forecast per city+commodity
CREATE INDEX IF NOT EXISTS idx_forecasts_city_commodity_date
    ON forecasts(city_id, commodity, date DESC);


-- =============================================================================
-- 10. SUBSCRIBER  —  WhatsApp identity + plan
--
-- PRIVACY CONTRACT: phone_hash is a salted SHA-256 digest of the normalized
-- number (see whatsapp_bot/subscription.py::hash_phone).  The raw number is
-- NEVER written to this database.  The salt lives in PHONE_HASH_SALT, outside
-- the database, so a dump of this table alone cannot be reversed into a
-- contact list even by brute force.
--
-- dashboard_user_id links a WhatsApp identity to a Supabase Auth user once the
-- two channels are tied together.  Nullable: most WhatsApp users never sign in
-- to the dashboard, and most dashboard users are government staff with no
-- WhatsApp subscription.
-- =============================================================================
CREATE TABLE IF NOT EXISTS subscriber (
    phone_hash        CHAR(64)      PRIMARY KEY,   -- hex SHA-256, salted
    plan              VARCHAR(10)   NOT NULL DEFAULT 'FREE'
                      CHECK (plan IN ('FREE', 'PRO')),
    plan_expires_at   TIMESTAMPTZ,                 -- NULL = perpetual (or FREE)
    dashboard_user_id UUID,                        -- Supabase auth.users.id
    created_at        TIMESTAMPTZ   NOT NULL DEFAULT NOW(),
    updated_at        TIMESTAMPTZ   NOT NULL DEFAULT NOW()
);

CREATE INDEX IF NOT EXISTS idx_subscriber_plan      ON subscriber(plan);
CREATE INDEX IF NOT EXISTS idx_subscriber_expires   ON subscriber(plan_expires_at);
CREATE INDEX IF NOT EXISTS idx_subscriber_dash_user ON subscriber(dashboard_user_id);


-- =============================================================================
-- 11. WA_USAGE_DAILY  —  free-tier counter
--
-- One row per (phone_hash, day).  usage_date is a WIB (UTC+7) calendar date,
-- computed by the application, NOT by the database — the server may run in UTC
-- and CURRENT_DATE would then roll the quota over at 07:00 local time.
--
-- Incremented via INSERT .. ON CONFLICT DO UPDATE so concurrent workers cannot
-- lose a count and hand out queries beyond the limit.
-- =============================================================================
CREATE TABLE IF NOT EXISTS wa_usage_daily (
    phone_hash   CHAR(64)  NOT NULL,
    usage_date   DATE      NOT NULL,
    query_count  INTEGER   NOT NULL DEFAULT 0,
    PRIMARY KEY (phone_hash, usage_date)
);

CREATE INDEX IF NOT EXISTS idx_wa_usage_date ON wa_usage_daily(usage_date DESC);


-- =============================================================================
-- 12. PAYMENT_ORDER  —  upgrade orders
--
-- No foreign key to subscriber: an order is created the moment a user asks to
-- upgrade, which may be before any subscriber row exists for them.
-- =============================================================================
CREATE TABLE IF NOT EXISTS payment_order (
    order_id    VARCHAR(20)   PRIMARY KEY,          -- e.g. 'AF-1A2B3C4D'
    phone_hash  CHAR(64)      NOT NULL,
    plan        VARCHAR(10)   NOT NULL DEFAULT 'PRO'
                CHECK (plan IN ('FREE', 'PRO')),
    amount_idr  INTEGER       NOT NULL,
    status      VARCHAR(10)   NOT NULL DEFAULT 'PENDING'
                CHECK (status IN ('PENDING', 'PAID', 'EXPIRED')),
    provider    VARCHAR(30)   NOT NULL DEFAULT 'MOCK',
    created_at  TIMESTAMPTZ   NOT NULL DEFAULT NOW(),
    paid_at     TIMESTAMPTZ
);

CREATE INDEX IF NOT EXISTS idx_order_phone  ON payment_order(phone_hash);
CREATE INDEX IF NOT EXISTS idx_order_status ON payment_order(status);


-- =============================================================================
-- 13. ROW LEVEL SECURITY  —  lock the PostgREST surface
-- =============================================================================
--
-- WHY THIS SECTION EXISTS
-- -----------------------
-- Supabase automatically exposes every table in the `public` schema over
-- PostgREST, and the `anon` API key is PUBLIC BY DESIGN: it ships to every
-- visitor's browser inside NEXT_PUBLIC_SUPABASE_ANON_KEY. Without RLS, anyone
-- who opens devtools can lift that key and talk to this database directly:
--
--     GET   /rest/v1/subscriber      -> read every subscriber row
--     PATCH /rest/v1/subscriber      -> set their own plan to 'PRO'
--
-- That would bypass the JWT verification in whatsapp_bot/auth.py entirely.
-- Locking the API layer while leaving PostgREST open is a locked front door
-- next to an open back one.
--
-- THE MODEL: DENY BY DEFAULT
-- --------------------------
-- Enabling RLS with NO policies denies all access to non-owner roles. Since
-- AgriFlow's dashboard never queries Supabase tables directly (it reads
-- everything through the FastAPI service, and uses Supabase only to sign users
-- in), nothing legitimate needs the PostgREST path. So we grant nothing.
--
-- The FastAPI backend is unaffected: it connects over SUPABASE_DB_URL as the
-- table owner, and in Postgres the owner bypasses RLS. PostgREST connects as
-- `anon` or `authenticated`, which do not.
--
-- ⚠ DO NOT ADD `FORCE ROW LEVEL SECURITY`.
--   FORCE additionally subjects the table OWNER to RLS. With no policies
--   defined, an owner that lacks the BYPASSRLS attribute then sees zero rows
--   and cannot insert -- an outage that looks exactly like "the database is
--   empty". Enabling RLS is correct here; forcing it buys nothing.
--
--   Measured on PostgreSQL 17.2 against a NOSUPERUSER NOBYPASSRLS owner:
--       RLS enabled, no FORCE  -> owner SELECT returns its row
--       RLS enabled + FORCE    -> owner SELECT returns 0 rows,
--                                 INSERT fails "violates row-level security"
--   A superuser or BYPASSRLS role is immune and will not reproduce this, which
--   is exactly why the mistake survives a casual test and bites in production.
--
-- IF YOU LATER WANT BROWSER-DIRECT READS
-- --------------------------------------
-- Add a narrow policy per table rather than disabling RLS, e.g. to let any
-- signed-in user read reference data:
--
--     CREATE POLICY kabupaten_read_authenticated ON kabupaten
--         FOR SELECT TO authenticated USING (true);
--
-- Never write a policy `USING (true)` for the `anon` role on subscriber,
-- wa_usage_daily, or payment_order. Those are per-person records.
-- =============================================================================

ALTER TABLE kabupaten          ENABLE ROW LEVEL SECURITY;
ALTER TABLE commodity          ENABLE ROW LEVEL SECURITY;
ALTER TABLE surplus_deficit    ENABLE ROW LEVEL SECURITY;
ALTER TABLE weather_forecast   ENABLE ROW LEVEL SECURITY;
ALTER TABLE historical_prices  ENABLE ROW LEVEL SECURITY;
ALTER TABLE commodity_code_map ENABLE ROW LEVEL SECURITY;
ALTER TABLE policy_docs        ENABLE ROW LEVEL SECURITY;
ALTER TABLE price_history      ENABLE ROW LEVEL SECURITY;
ALTER TABLE forecasts          ENABLE ROW LEVEL SECURITY;
ALTER TABLE subscriber         ENABLE ROW LEVEL SECURITY;
ALTER TABLE wa_usage_daily     ENABLE ROW LEVEL SECURITY;
ALTER TABLE payment_order      ENABLE ROW LEVEL SECURITY;

-- Defence in depth: revoke table privileges from the PostgREST roles outright,
-- so a carelessly-added policy later cannot by itself open a table up.
--
-- Wrapped in a guard because `anon` and `authenticated` are Supabase-specific
-- roles. This file should stay runnable against a plain Postgres instance (a
-- local test database, CI), where those roles do not exist and an unguarded
-- REVOKE would abort the script.
DO $$
BEGIN
    IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon') THEN
        REVOKE ALL ON ALL TABLES    IN SCHEMA public FROM anon;
        REVOKE ALL ON ALL SEQUENCES IN SCHEMA public FROM anon;
    END IF;

    IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated') THEN
        REVOKE ALL ON ALL TABLES    IN SCHEMA public FROM authenticated;
        REVOKE ALL ON ALL SEQUENCES IN SCHEMA public FROM authenticated;
    END IF;
END
$$;

-- Verification — run after applying. Every row must show rowsecurity = true.
-- Any 'f' is a table still readable with the public anon key.
--
--     SELECT tablename, rowsecurity
--       FROM pg_tables
--      WHERE schemaname = 'public'
--      ORDER BY rowsecurity, tablename;
