-- complain if script is sourced in psql, rather than via CREATE EXTENSION
\echo Use "CREATE EXTENSION pg_policy" to load this file. \quit

COMMENT ON SCHEMA pg_policy IS
  'Agentic policy language: guardrails, guidance, and session-aware controls';

--------------------------------------------------------------------------------
-- Catalog
--------------------------------------------------------------------------------

CREATE TABLE policies (
    policy_id       bigserial PRIMARY KEY,
    name            text        NOT NULL UNIQUE,
    description     text,
    enabled         boolean     NOT NULL DEFAULT true,
    priority        integer     NOT NULL DEFAULT 100,
    effect          text        NOT NULL CHECK (effect IN ('permit', 'forbid', 'guide')),
    principal_type  text        NOT NULL DEFAULT 'agent',
    principal_id    text        NOT NULL DEFAULT '*',
    action_type     text        NOT NULL DEFAULT 'tool',
    action_id       text        NOT NULL DEFAULT '*',
    resource_type   text        NOT NULL DEFAULT '*',
    resource_id     text        NOT NULL DEFAULT '*',
    condition       jsonb       NOT NULL DEFAULT '{}'::jsonb,
    temporal        jsonb,
    obligations     jsonb       NOT NULL DEFAULT '[]'::jsonb,
    reason          text,
    source_apl      text,
    mode_override   text        CHECK (mode_override IS NULL OR mode_override IN ('enforce', 'log_only', 'guide')),
    created_at      timestamptz NOT NULL DEFAULT now(),
    updated_at      timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX policies_match_idx
  ON policies (principal_type, principal_id, action_type, action_id);

COMMENT ON TABLE policies IS
  'Compiled agent policies. Prefer upsert_policy() over direct inserts.';

CREATE TABLE sessions (
    session_id      text        PRIMARY KEY,
    principal_type  text        NOT NULL,
    principal_id    text        NOT NULL,
    attributes      jsonb       NOT NULL DEFAULT '{}'::jsonb,
    opened_at       timestamptz NOT NULL DEFAULT now(),
    last_seen_at    timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE events (
    event_id        bigserial PRIMARY KEY,
    session_id      text        NOT NULL REFERENCES sessions(session_id) ON DELETE CASCADE,
    at              timestamptz NOT NULL DEFAULT now(),
    action_type     text        NOT NULL,
    action_id       text        NOT NULL,
    resource_type   text,
    resource_id     text,
    attributes      jsonb       NOT NULL DEFAULT '{}'::jsonb,
    decision        text
);

CREATE INDEX events_session_at_idx ON events (session_id, at DESC);

CREATE TABLE decision_log (
    log_id          bigserial PRIMARY KEY,
    at              timestamptz NOT NULL DEFAULT now(),
    session_id      text,
    principal_type  text        NOT NULL,
    principal_id    text        NOT NULL,
    action_type     text        NOT NULL,
    action_id       text        NOT NULL,
    resource_type   text,
    resource_id     text,
    context         jsonb       NOT NULL DEFAULT '{}'::jsonb,
    decision        text        NOT NULL,
    matched_policies text[],
    obligations     jsonb       NOT NULL DEFAULT '[]'::jsonb,
    reasons         text[],
    mode            text        NOT NULL
);

CREATE INDEX decision_log_at_idx ON decision_log (at DESC);

--------------------------------------------------------------------------------
-- Configuration
--------------------------------------------------------------------------------

CREATE TABLE settings (
    key   text PRIMARY KEY,
    value text NOT NULL
);

INSERT INTO settings(key, value) VALUES
  ('enforcement_mode', 'log_only'),
  ('default_decision', 'permit');

CREATE OR REPLACE FUNCTION get_setting(p_key text)
RETURNS text
LANGUAGE sql
STABLE
AS $$
  SELECT value FROM settings WHERE key = p_key;
$$;

CREATE OR REPLACE FUNCTION set_setting(p_key text, p_value text)
RETURNS void
LANGUAGE plpgsql
AS $$
BEGIN
  INSERT INTO settings(key, value) VALUES (p_key, p_value)
  ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
END;
$$;

--------------------------------------------------------------------------------
-- APL parser (intentionally small for v0.1)
--------------------------------------------------------------------------------

CREATE OR REPLACE FUNCTION _apl_extract(p_src text, p_pattern text)
RETURNS text
LANGUAGE plpgsql
IMMUTABLE
AS $$
DECLARE
  m text[];
BEGIN
  m := regexp_match(p_src, p_pattern, 'i');
  IF m IS NULL THEN
    RETURN NULL;
  END IF;
  RETURN m[1];
END;
$$;

CREATE OR REPLACE FUNCTION parse_apl(p_apl text)
RETURNS jsonb
LANGUAGE plpgsql
IMMUTABLE
AS $$
DECLARE
  v_effect text;
  v_principal_type text;
  v_principal_id text;
  v_action_type text;
  v_action_id text;
  v_resource_type text;
  v_resource_id text;
  v_reason text;
  v_when text;
  v_temporal text;
  v_condition jsonb := '{}'::jsonb;
  v_temporal_json jsonb := NULL;
  v_obligations jsonb := '[]'::jsonb;
  v_advice text;
  v_pref text;
  parts text[];
  pair text;
  kv text[];
BEGIN
  IF p_apl IS NULL OR btrim(p_apl) = '' THEN
    RAISE EXCEPTION 'APL document is empty';
  END IF;

  v_effect := lower(_apl_extract(p_apl, '^\s*(permit|forbid|guide)\b'));
  IF v_effect IS NULL THEN
    RAISE EXCEPTION 'APL must start with permit, forbid, or guide';
  END IF;

  v_principal_type := coalesce(
    _apl_extract(p_apl, 'principal\s+(agent|user|role|service)\s+"([^"]+)"'),
    'agent'
  );
  -- regexp_match with two groups: re-extract properly
  parts := regexp_match(p_apl, 'principal\s+(agent|user|role|service)\s+"([^"]+)"', 'i');
  IF parts IS NOT NULL THEN
    v_principal_type := lower(parts[1]);
    v_principal_id := parts[2];
  ELSE
    parts := regexp_match(p_apl, 'principal\s+"([^"]+)"', 'i');
    IF parts IS NOT NULL THEN
      v_principal_type := 'agent';
      v_principal_id := parts[1];
    ELSE
      v_principal_id := '*';
    END IF;
  END IF;

  parts := regexp_match(p_apl, 'action\s+(tool|sql|data|admin)\s+"([^"]+)"', 'i');
  IF parts IS NOT NULL THEN
    v_action_type := lower(parts[1]);
    v_action_id := parts[2];
  ELSE
    parts := regexp_match(p_apl, 'action\s+"([^"]+)"', 'i');
    IF parts IS NOT NULL THEN
      v_action_type := 'tool';
      v_action_id := parts[1];
    ELSE
      v_action_type := '*';
      v_action_id := '*';
    END IF;
  END IF;

  parts := regexp_match(p_apl, 'resource\s+(table|schema|database|object)\s+"([^"]+)"', 'i');
  IF parts IS NOT NULL THEN
    v_resource_type := lower(parts[1]);
    v_resource_id := parts[2];
  ELSE
    parts := regexp_match(p_apl, 'resource\s+"([^"]+)"', 'i');
    IF parts IS NOT NULL THEN
      v_resource_type := 'object';
      v_resource_id := parts[1];
    ELSE
      v_resource_type := '*';
      v_resource_id := '*';
    END IF;
  END IF;

  v_reason := _apl_extract(p_apl, 'reason\s+"([^"]*)"');

  v_when := _apl_extract(p_apl, 'when\s*\{([^}]*)\}');
  IF v_when IS NOT NULL THEN
    -- support: context.foo == "bar"  AND  context.foo in ["A","B"]
    FOREACH pair IN ARRAY regexp_split_to_array(v_when, '\s+and\s+', 'i')
    LOOP
      pair := btrim(pair);
      IF pair = '' THEN
        CONTINUE;
      END IF;
      kv := regexp_match(pair, 'context\.([A-Za-z0-9_]+)\s+in\s*\[([^\]]*)\]', 'i');
      IF kv IS NOT NULL THEN
        v_condition := v_condition || jsonb_build_object(
          kv[1],
          jsonb_build_object(
            'op', 'in',
            'value', to_jsonb(ARRAY(SELECT btrim(x, ' "') FROM unnest(string_to_array(kv[2], ',')) AS x WHERE btrim(x) <> ''))
          )
        );
        CONTINUE;
      END IF;
      kv := regexp_match(pair, 'context\.([A-Za-z0-9_]+)\s*==\s*"([^"]*)"', 'i');
      IF kv IS NOT NULL THEN
        v_condition := v_condition || jsonb_build_object(
          kv[1],
          jsonb_build_object('op', 'eq', 'value', to_jsonb(kv[2]))
        );
        CONTINUE;
      END IF;
      kv := regexp_match(pair, 'context\.([A-Za-z0-9_]+)\s*==\s*([0-9.]+)', 'i');
      IF kv IS NOT NULL THEN
        v_condition := v_condition || jsonb_build_object(
          kv[1],
          jsonb_build_object('op', 'eq', 'value', to_jsonb(kv[2]::numeric))
        );
      END IF;
    END LOOP;
  END IF;

  v_temporal := _apl_extract(p_apl, 'when\s+temporal\s*\{([^}]*)\}');
  IF v_temporal IS NOT NULL THEN
    kv := regexp_match(v_temporal,
      'count\s*\(\s*action\s*==\s*"([^"]+)"\s*\)\s*within\s+interval\s+''([^'']+)''\s*(>=|>)\s*([0-9]+)',
      'i');
    IF kv IS NOT NULL THEN
      v_temporal_json := jsonb_build_object(
        'op', 'count',
        'action_id', kv[1],
        'within', kv[2],
        'cmp', kv[3],
        'value', kv[4]::int
      );
    END IF;
  END IF;

  v_advice := _apl_extract(p_apl, 'advice\s+"([^"]*)"');
  IF v_advice IS NOT NULL THEN
    v_obligations := v_obligations || jsonb_build_array(
      jsonb_build_object('type', 'advice', 'value', v_advice)
    );
  END IF;

  v_pref := _apl_extract(p_apl, 'prefer_tool\s+"([^"]*)"');
  IF v_pref IS NOT NULL THEN
    v_obligations := v_obligations || jsonb_build_array(
      jsonb_build_object('type', 'prefer_tool', 'value', v_pref)
    );
  END IF;

  kv := regexp_match(p_apl, 'max_rows\s+([0-9]+)', 'i');
  IF kv IS NOT NULL THEN
    v_obligations := v_obligations || jsonb_build_array(
      jsonb_build_object('type', 'max_rows', 'value', kv[1]::int)
    );
  END IF;

  RETURN jsonb_build_object(
    'effect', v_effect,
    'principal_type', v_principal_type,
    'principal_id', v_principal_id,
    'action_type', v_action_type,
    'action_id', v_action_id,
    'resource_type', v_resource_type,
    'resource_id', v_resource_id,
    'condition', v_condition,
    'temporal', v_temporal_json,
    'obligations', v_obligations,
    'reason', v_reason,
    'source_apl', p_apl
  );
END;
$$;

COMMENT ON FUNCTION parse_apl(text) IS
  'Parse a v0.1 APL document into JSON IR';

--------------------------------------------------------------------------------
-- Policy management
--------------------------------------------------------------------------------

CREATE OR REPLACE FUNCTION upsert_policy(
  p_name text,
  p_apl text,
  p_description text DEFAULT NULL,
  p_priority integer DEFAULT 100,
  p_enabled boolean DEFAULT true
)
RETURNS bigint
LANGUAGE plpgsql
AS $$
DECLARE
  ir jsonb;
  id bigint;
BEGIN
  ir := parse_apl(p_apl);
  INSERT INTO policies AS p (
    name, description, enabled, priority, effect,
    principal_type, principal_id, action_type, action_id,
    resource_type, resource_id, condition, temporal,
    obligations, reason, source_apl, updated_at
  ) VALUES (
    p_name, p_description, p_enabled, p_priority, ir->>'effect',
    ir->>'principal_type', ir->>'principal_id', ir->>'action_type', ir->>'action_id',
    ir->>'resource_type', ir->>'resource_id',
    coalesce(ir->'condition', '{}'::jsonb),
    ir->'temporal',
    coalesce(ir->'obligations', '[]'::jsonb),
    ir->>'reason',
    p_apl,
    now()
  )
  ON CONFLICT (name) DO UPDATE SET
    description = EXCLUDED.description,
    enabled = EXCLUDED.enabled,
    priority = EXCLUDED.priority,
    effect = EXCLUDED.effect,
    principal_type = EXCLUDED.principal_type,
    principal_id = EXCLUDED.principal_id,
    action_type = EXCLUDED.action_type,
    action_id = EXCLUDED.action_id,
    resource_type = EXCLUDED.resource_type,
    resource_id = EXCLUDED.resource_id,
    condition = EXCLUDED.condition,
    temporal = EXCLUDED.temporal,
    obligations = EXCLUDED.obligations,
    reason = EXCLUDED.reason,
    source_apl = EXCLUDED.source_apl,
    updated_at = now()
  RETURNING policy_id INTO id;
  RETURN id;
END;
$$;

CREATE OR REPLACE FUNCTION drop_policy(p_name text)
RETURNS boolean
LANGUAGE plpgsql
AS $$
BEGIN
  DELETE FROM policies WHERE name = p_name;
  RETURN FOUND;
END;
$$;

--------------------------------------------------------------------------------
-- Session / events
--------------------------------------------------------------------------------

CREATE OR REPLACE FUNCTION open_session(
  p_session_id text,
  p_principal_type text,
  p_principal_id text,
  p_attributes jsonb DEFAULT '{}'::jsonb
)
RETURNS text
LANGUAGE plpgsql
AS $$
BEGIN
  INSERT INTO sessions(session_id, principal_type, principal_id, attributes)
  VALUES (p_session_id, p_principal_type, p_principal_id, coalesce(p_attributes, '{}'::jsonb))
  ON CONFLICT (session_id) DO UPDATE SET
    last_seen_at = now(),
    attributes = EXCLUDED.attributes;
  RETURN p_session_id;
END;
$$;

CREATE OR REPLACE FUNCTION record_event(
  p_session_id text,
  p_action_type text,
  p_action_id text,
  p_resource_type text DEFAULT NULL,
  p_resource_id text DEFAULT NULL,
  p_attributes jsonb DEFAULT '{}'::jsonb,
  p_decision text DEFAULT NULL
)
RETURNS bigint
LANGUAGE plpgsql
AS $$
DECLARE
  id bigint;
BEGIN
  UPDATE sessions SET last_seen_at = now() WHERE session_id = p_session_id;
  INSERT INTO events(
    session_id, action_type, action_id, resource_type, resource_id, attributes, decision
  ) VALUES (
    p_session_id, p_action_type, p_action_id, p_resource_type, p_resource_id,
    coalesce(p_attributes, '{}'::jsonb), p_decision
  ) RETURNING event_id INTO id;
  RETURN id;
END;
$$;

--------------------------------------------------------------------------------
-- Matching helpers
--------------------------------------------------------------------------------

CREATE OR REPLACE FUNCTION _glob_match(p_pattern text, p_value text)
RETURNS boolean
LANGUAGE sql
IMMUTABLE
AS $$
  SELECT p_pattern = '*' OR p_pattern = p_value;
$$;

CREATE OR REPLACE FUNCTION _condition_holds(p_condition jsonb, p_context jsonb)
RETURNS boolean
LANGUAGE plpgsql
IMMUTABLE
AS $$
DECLARE
  r record;
  ctx_val jsonb;
  op text;
  expected jsonb;
BEGIN
  IF p_condition IS NULL OR p_condition = '{}'::jsonb THEN
    RETURN true;
  END IF;

  FOR r IN SELECT * FROM jsonb_each(p_condition)
  LOOP
    op := r.value->>'op';
    expected := r.value->'value';
    ctx_val := p_context -> r.key;

    IF op = 'eq' THEN
      IF ctx_val IS DISTINCT FROM expected
         AND ctx_val IS DISTINCT FROM to_jsonb(expected #>> '{}') THEN
        -- also allow scalar string compare
        IF (ctx_val #>> '{}') IS DISTINCT FROM (expected #>> '{}') THEN
          RETURN false;
        END IF;
      END IF;
    ELSIF op = 'in' THEN
      IF NOT EXISTS (
        SELECT 1
        FROM jsonb_array_elements_text(expected) AS e(val)
        WHERE e.val = coalesce(ctx_val #>> '{}', ctx_val::text)
      ) THEN
        RETURN false;
      END IF;
    ELSE
      RETURN false;
    END IF;
  END LOOP;
  RETURN true;
END;
$$;

CREATE OR REPLACE FUNCTION _temporal_holds(
  p_session_id text,
  p_temporal jsonb
)
RETURNS boolean
LANGUAGE plpgsql
STABLE
AS $$
DECLARE
  cnt bigint;
  cmp text;
  need int;
  action text;
  within interval;
BEGIN
  IF p_temporal IS NULL OR p_temporal = 'null'::jsonb THEN
    RETURN true;
  END IF;

  IF p_session_id IS NULL THEN
    RETURN false;
  END IF;

  IF p_temporal->>'op' = 'count' THEN
    action := p_temporal->>'action_id';
    within := (p_temporal->>'within')::interval;
    cmp := p_temporal->>'cmp';
    need := (p_temporal->>'value')::int;
    SELECT count(*) INTO cnt
    FROM events e
    WHERE e.session_id = p_session_id
      AND e.action_id = action
      AND e.at >= now() - within;
    IF cmp = '>=' THEN
      RETURN cnt >= need;
    ELSIF cmp = '>' THEN
      RETURN cnt > need;
    END IF;
  END IF;
  RETURN false;
END;
$$;

--------------------------------------------------------------------------------
-- Evaluate
--------------------------------------------------------------------------------

CREATE OR REPLACE FUNCTION evaluate(
  p_principal_type text,
  p_principal_id text,
  p_action_type text,
  p_action_id text,
  p_resource_type text DEFAULT '*',
  p_resource_id text DEFAULT '*',
  p_context jsonb DEFAULT '{}'::jsonb,
  p_session_id text DEFAULT NULL,
  p_raise_on_deny boolean DEFAULT false
)
RETURNS jsonb
LANGUAGE plpgsql
AS $$
DECLARE
  mode text;
  default_decision text;
  rec record;
  matched text[] := ARRAY[]::text[];
  reasons text[] := ARRAY[]::text[];
  obligations jsonb := '[]'::jsonb;
  decision text;
  effective_mode text;
  has_forbid boolean := false;
  has_permit boolean := false;
  has_guide boolean := false;
BEGIN
  mode := coalesce(get_setting('enforcement_mode'), 'log_only');
  default_decision := coalesce(get_setting('default_decision'), 'permit');
  decision := default_decision;

  FOR rec IN
    SELECT *
    FROM policies p
    WHERE p.enabled
      AND _glob_match(p.principal_type, p_principal_type)
      AND _glob_match(p.principal_id, p_principal_id)
      AND _glob_match(p.action_type, p_action_type)
      AND _glob_match(p.action_id, p_action_id)
      AND _glob_match(p.resource_type, coalesce(p_resource_type, '*'))
      AND _glob_match(p.resource_id, coalesce(p_resource_id, '*'))
      AND _condition_holds(p.condition, coalesce(p_context, '{}'::jsonb))
      AND _temporal_holds(p_session_id, p.temporal)
    ORDER BY p.priority ASC, p.policy_id ASC
  LOOP
    matched := matched || rec.name;
    IF rec.reason IS NOT NULL THEN
      reasons := reasons || rec.reason;
    END IF;
    obligations := obligations || coalesce(rec.obligations, '[]'::jsonb);

    IF rec.effect = 'forbid' THEN
      has_forbid := true;
    ELSIF rec.effect = 'permit' THEN
      has_permit := true;
    ELSIF rec.effect = 'guide' THEN
      has_guide := true;
    END IF;
  END LOOP;

  -- Deny overrides (Cedar-like): any matching forbid wins over permit
  IF has_forbid THEN
    decision := 'deny';
  ELSIF has_permit THEN
    decision := 'allow';
  ELSIF has_guide THEN
    decision := 'allow';
  END IF;

  effective_mode := mode;

  IF decision = 'deny' THEN
    IF mode = 'log_only' THEN
      decision := 'allow';
      obligations := obligations || jsonb_build_array(
        jsonb_build_object('type', 'shadow_deny', 'value', true)
      );
    ELSIF mode = 'guide' THEN
      decision := 'allow';
      obligations := obligations || jsonb_build_array(
        jsonb_build_object('type', 'would_deny', 'value', true)
      );
    END IF;
  END IF;

  INSERT INTO decision_log(
    session_id, principal_type, principal_id, action_type, action_id,
    resource_type, resource_id, context, decision, matched_policies,
    obligations, reasons, mode
  ) VALUES (
    p_session_id, p_principal_type, p_principal_id, p_action_type, p_action_id,
    p_resource_type, p_resource_id, coalesce(p_context, '{}'::jsonb),
    decision, matched, obligations, reasons, effective_mode
  );

  IF p_session_id IS NOT NULL THEN
    PERFORM record_event(
      p_session_id, p_action_type, p_action_id,
      p_resource_type, p_resource_id, p_context, decision
    );
  END IF;

  IF p_raise_on_deny AND decision = 'deny' AND mode = 'enforce' THEN
    RAISE EXCEPTION 'pg_policy deny: %', coalesce(array_to_string(reasons, '; '), 'forbidden')
      USING ERRCODE = '42501';
  END IF;

  RETURN jsonb_build_object(
    'decision', decision,
    'allowed', decision = 'allow',
    'matched_policies', to_jsonb(matched),
    'obligations', obligations,
    'reasons', to_jsonb(reasons),
    'mode', effective_mode
  );
END;
$$;

COMMENT ON FUNCTION evaluate IS
  'Evaluate agent policy. Returns decision, obligations, reasons, and matched policy names.';

--------------------------------------------------------------------------------
-- Convenience wrappers
--------------------------------------------------------------------------------

CREATE OR REPLACE FUNCTION check(
  p_agent text,
  p_tool text,
  p_context jsonb DEFAULT '{}'::jsonb,
  p_session_id text DEFAULT NULL
)
RETURNS boolean
LANGUAGE sql
AS $$
  SELECT (evaluate(
    'agent', p_agent, 'tool', p_tool, '*', '*', p_context, p_session_id, false
  )->>'allowed')::boolean;
$$;

CREATE OR REPLACE FUNCTION enforce(
  p_agent text,
  p_tool text,
  p_context jsonb DEFAULT '{}'::jsonb,
  p_session_id text DEFAULT NULL
)
RETURNS jsonb
LANGUAGE sql
AS $$
  SELECT evaluate(
    'agent', p_agent, 'tool', p_tool, '*', '*', p_context, p_session_id, true
  );
$$;
