-- rrule_plpgsql: Pure PL/pgSQL RRULE (RFC 5545) implementation for PostgreSQL.
-- Source: https://github.com/sirrodgepodge/rrule_plpgsql
-- License: MIT
-- Vendored for plone.pgcatalog DateRecurringIndex support.
-- This file is auto-generated — do not edit manually.

CREATE SCHEMA IF NOT EXISTS rrule;

/**
* PostgreSQL Functions for RRULE handling
*
* @license MIT License
*
* COMPREHENSIVE RFC 5545 RRULE IMPLEMENTATION
* ==========================================
*
* ✅ FULLY SUPPORTED FREQUENCIES:
*  - DAILY frequency, including:
*    BYDAY, BYMONTH, BYMONTHDAY, BYWEEKNO, BYYEARDAY, BYHOUR, BYMINUTE, BYSECOND, BYSETPOS
*  - WEEKLY frequency, including:
*    BYDAY, BYMONTH, BYMONTHDAY, BYWEEKNO, BYYEARDAY, BYSETPOS
*  - MONTHLY frequency, including:
*    BYDAY, BYMONTH, BYMONTHDAY, BYWEEKNO, BYYEARDAY, BYSETPOS
*  - YEARLY frequency, including:
*    BYMONTH, BYMONTHDAY, BYYEARDAY (positive & negative indices), BYWEEKNO, BYDAY, BYSETPOS
*
* ✅ UNIVERSAL MODIFIERS:
*  - COUNT & UNTIL limits
*  - INTERVAL (every N days/weeks/months/years)
*
* ⚠️ SUB-DAY FREQUENCIES (Implemented but Commented Out by Default):
*  - HOURLY   - Fully implemented with safety limits (see line ~697)
*  - MINUTELY - Fully implemented with safety limits (see line ~742)
*  - SECONDLY - Fully implemented with safety limits (see line ~787)
*
* WHY COMMENTED OUT?
*  Sub-day frequencies can generate millions of occurrences, causing denial-of-service
*  in multi-tenant environments. They are production-ready but disabled by default.
*  See main event loop (line ~928) for detailed security documentation and instructions
*  on how to enable them safely with proper validation and limits.
*
* COMPLETE RFC 5545 COMPLIANCE:
*  This implementation now supports ALL major RRULE features from RFC 5545.
*  The only unsupported combination is YEARLY + BYMONTH + BYYEARDAY (semantically
*  contradictory - raises a descriptive exception). All other combinations work!
*
*/

-- Set search path to rrule schema so all functions are created there
SET search_path = rrule, public;

-- Create a composite type for the parts of the RRULE.
-- Note: This file is designed to be loaded via install.sql which drops/recreates
-- the entire schema. For updates, reinstall using install.sql.
DO $__dri_type__$ BEGIN
CREATE TYPE rrule_parts AS (
  base TIMESTAMP WITH TIME ZONE,
  until TIMESTAMP WITH TIME ZONE,
  freq TEXT,
  count INT,
  interval INT,
  bysecond INT[],
  byminute INT[],
  byhour INT[],
  bymonthday INT[],
  byyearday INT[],
  byweekno INT[],
  byday TEXT[],
  bymonth INT[],
  bysetpos INT[],
  wkst TEXT,
  tzid TEXT,
  rscale TEXT,
  skip TEXT
);
EXCEPTION WHEN duplicate_object THEN NULL;
END $__dri_type__$;


-- Create a function to parse the RRULE into its composite type
CREATE OR REPLACE FUNCTION parse_rrule_parts(
  basedate TIMESTAMP WITH TIME ZONE,
  repeatrule TEXT
) RETURNS rrule.rrule_parts AS $$
DECLARE
  result rrule.rrule_parts%ROWTYPE;
  until_str TEXT;
BEGIN
  result.base       := basedate;
  until_str         := substring(repeatrule from 'UNTIL=([0-9TZ]+)(;|$)');

  -- Validate and assign UNTIL with helpful error message
  IF until_str IS NOT NULL THEN
    BEGIN
      result.until := until_str::TIMESTAMPTZ;
    EXCEPTION
      WHEN OTHERS THEN
        RAISE EXCEPTION 'Invalid RRULE: UNTIL=% is not a valid timestamp. RFC 5545 requires format YYYYMMDDTHHMMSSZ (e.g., UNTIL=20251231T235959Z) or YYYYMMDD (e.g., UNTIL=20251231). Error: %',
          until_str,
          SQLERRM;
    END;
  END IF;

  result.freq       := substring(repeatrule from 'FREQ=([A-Z]+)(;|$)');
  result.count      := substring(repeatrule from 'COUNT=([0-9]+)(;|$)');
  result.interval   := COALESCE(substring(repeatrule from 'INTERVAL=([0-9]+)(;|$)')::int, 1);
  result.wkst       := substring(repeatrule from 'WKST=(MO|TU|WE|TH|FR|SA|SU)(;|$)');
  result.tzid       := substring(repeatrule from 'TZID=([^;]+)(;|$)');

  -- RFC 7529: RSCALE parameter (calendar system)
  result.rscale     := UPPER(substring(repeatrule from 'RSCALE=([A-Z]+)(;|$)'));

  -- RFC 7529: SKIP parameter
  result.skip       := COALESCE(UPPER(substring(repeatrule from 'SKIP=(OMIT|BACKWARD|FORWARD)(;|$)')), 'OMIT');

  -- RFC 7529 Compliance: SKIP requires RSCALE
  -- If SKIP is specified (and not default OMIT) but RSCALE is missing,
  -- auto-add RSCALE=GREGORIAN for RFC 7529 compliance
  IF result.skip IS NOT NULL AND result.skip != 'OMIT' AND result.rscale IS NULL THEN
    result.rscale := 'GREGORIAN';
  END IF;

  -- Validate RSCALE if present (only GREGORIAN supported)
  IF result.rscale IS NOT NULL AND result.rscale != 'GREGORIAN' THEN
    RAISE EXCEPTION 'Unsupported RSCALE value: "%". Only GREGORIAN calendar is currently supported.  RFC 7529 defines other calendar systems (HEBREW, ISLAMIC, CHINESE, etc.),  but this implementation only supports the Gregorian calendar.', result.rscale;
  END IF;

  result.byday      := string_to_array( substring(repeatrule from 'BYDAY=(([+-]?[0-9]{0,2}(MO|TU|WE|TH|FR|SA|SU),?)+)(;|$)'), ',');

  result.byyearday  := string_to_array(substring(repeatrule from 'BYYEARDAY=([0-9,+-]+)(;|$)'), ',');
  result.byweekno   := string_to_array(substring(repeatrule from 'BYWEEKNO=([0-9,+-]+)(;|$)'), ',');
  result.bymonthday := string_to_array(substring(repeatrule from 'BYMONTHDAY=([0-9,+-]+)(;|$)'), ',');
  result.bymonth    := string_to_array(substring(repeatrule from 'BYMONTH=(([+-]?[0-1]?[0-9],?)+)(;|$)'), ',');
  result.bysetpos   := string_to_array(substring(repeatrule from 'BYSETPOS=(([+-]?[0-9]{1,3},?)+)(;|$)'), ',');

  result.bysecond   := string_to_array(substring(repeatrule from 'BYSECOND=([0-9,+-]+)(;|$)'), ',');
  result.byminute   := string_to_array(substring(repeatrule from 'BYMINUTE=([0-9,+-]+)(;|$)'), ',');
  result.byhour     := string_to_array(substring(repeatrule from 'BYHOUR=([0-9,+-]+)(;|$)'), ',');

  -- ========================================================================
  -- RFC 5545 CONSTRAINT VALIDATIONS
  -- ========================================================================
  -- The following validations enforce RFC 5545 Section 3.3.10 requirements
  -- to ensure only valid RRULEs are accepted.

  -- Validation 1: FREQ is REQUIRED
  IF result.freq IS NULL THEN
    RAISE EXCEPTION 'Invalid RRULE: FREQ parameter is required. Specify one of: SECONDLY, MINUTELY, HOURLY, DAILY, WEEKLY, MONTHLY, or YEARLY.  RFC 5545 Section 3.3.10: "FREQ rule part is REQUIRED"';
  END IF;

  -- Validation 2: COUNT and UNTIL are mutually exclusive
  IF result.count IS NOT NULL AND result.until IS NOT NULL THEN
    RAISE EXCEPTION 'Invalid RRULE: COUNT and UNTIL are mutually exclusive. Specify either COUNT (number of occurrences) OR UNTIL (end date), not both. Current RRULE has COUNT=% and UNTIL=%.  RFC 5545 Section 3.3.10: "they MUST NOT occur in the same recur"', result.count, result.until;
  END IF;

  -- Validation 3: BYWEEKNO only valid with YEARLY frequency
  IF result.byweekno IS NOT NULL AND result.freq != 'YEARLY' THEN
    RAISE EXCEPTION 'Invalid RRULE: BYWEEKNO can only be used with FREQ=YEARLY. Current FREQ=%. BYWEEKNO specifies ISO 8601 week numbers within a year. Use FREQ=YEARLY or remove BYWEEKNO.  RFC 5545 Section 3.3.10: "BYWEEKNO MUST NOT be used when FREQ is not YEARLY"', result.freq;
  END IF;

  -- Validation 4: BYYEARDAY not valid with DAILY, WEEKLY, or MONTHLY
  IF result.byyearday IS NOT NULL AND
     result.freq IN ('DAILY', 'WEEKLY', 'MONTHLY') THEN
    RAISE EXCEPTION 'Invalid RRULE: BYYEARDAY cannot be used with FREQ=%. BYYEARDAY is only valid with FREQ=YEARLY (and sub-day frequencies). Use FREQ=YEARLY or use BYMONTHDAY instead.  RFC 5545 Section 3.3.10: "BYYEARDAY MUST NOT be specified when FREQ is DAILY, WEEKLY, or MONTHLY"', result.freq;
  END IF;

  -- Validation 5: BYMONTHDAY not valid with WEEKLY frequency
  IF result.bymonthday IS NOT NULL AND result.freq = 'WEEKLY' THEN
    RAISE EXCEPTION 'Invalid RRULE: BYMONTHDAY cannot be used with FREQ=WEEKLY. BYMONTHDAY specifies day-of-month filters which are not applicable to weekly recurrence. Use FREQ=DAILY with BYDAY filter instead. Example: FREQ=DAILY;BYDAY=MO,WE,FR for specific weekdays.  RFC 5545 Section 3.3.10: "BYMONTHDAY MUST NOT be specified when the FREQ rule part is set to WEEKLY"';
  END IF;

  -- Validation 6: BYDAY with ordinals only valid with MONTHLY or YEARLY
  IF result.byday IS NOT NULL AND result.freq NOT IN ('MONTHLY', 'YEARLY') THEN
    FOR i IN 1..array_length(result.byday, 1) LOOP
      EXIT WHEN result.byday[i] IS NULL;
      -- Check if BYDAY has numeric prefix (ordinal like "2MO" or "-1FR")
      IF result.byday[i] ~ '^[+-]?[0-9]+' THEN
        RAISE EXCEPTION 'Invalid RRULE: BYDAY with ordinal (%) can only be used with FREQ=MONTHLY or FREQ=YEARLY. Current FREQ=%. Ordinals (like 2MO for "2nd Monday" or -1FR for "last Friday") are only meaningful within a month or year. Either change FREQ to MONTHLY/YEARLY or remove the ordinal prefix (use MO instead of 2MO).  RFC 5545 Section 3.3.10: "BYDAY MUST NOT be specified with numeric value when FREQ is not MONTHLY/YEARLY"', result.byday[i], result.freq;
      END IF;
    END LOOP;
  END IF;

  -- Validation 7: BYDAY with ordinals cannot be used with YEARLY + BYWEEKNO
  IF result.freq = 'YEARLY' AND result.byweekno IS NOT NULL AND result.byday IS NOT NULL THEN
    FOR i IN 1..array_length(result.byday, 1) LOOP
      EXIT WHEN result.byday[i] IS NULL;
      -- Check if BYDAY has numeric prefix (ordinal like "2MO" or "-1FR")
      IF result.byday[i] ~ '^[+-]?[0-9]+' THEN
        RAISE EXCEPTION 'Invalid RRULE: BYDAY with ordinal (%) cannot be used when FREQ=YEARLY and BYWEEKNO is specified. Ordinals are ambiguous when combined with week numbers. Use BYDAY without ordinals (e.g., MO instead of 2MO) or remove BYWEEKNO. Example valid: FREQ=YEARLY;BYWEEKNO=10;BYDAY=MO.  RFC 5545 Section 3.3.10: "BYDAY MUST NOT be specified with a numeric value with the FREQ rule part set to YEARLY when the BYWEEKNO rule part is specified"', result.byday[i];
      END IF;
    END LOOP;
  END IF;

  -- Validation 7b: BYDAY ordinals cannot be zero
  IF result.byday IS NOT NULL THEN
    FOR i IN 1..array_length(result.byday, 1) LOOP
      EXIT WHEN result.byday[i] IS NULL;
      -- Check if BYDAY has zero ordinal (0MO, +0MO, -0MO, 00MO, etc.)
      IF result.byday[i] ~ '^[+-]?0+(MO|TU|WE|TH|FR|SA|SU)$' THEN
        RAISE EXCEPTION 'Invalid RRULE: BYDAY ordinal cannot be zero (%). Valid ordinals are 1-53 or -1 to -53. Use BYDAY=% instead of BYDAY=%.  RFC 5545 Section 3.3.10: "ordwk = 1*2DIGIT ;1 to 53"',
          result.byday[i],
          substring(result.byday[i] from '(MO|TU|WE|TH|FR|SA|SU)$'),
          result.byday[i];
      END IF;
    END LOOP;
  END IF;

  -- Validation 8: BYSETPOS requires at least one other BYxxx parameter
  IF result.bysetpos IS NOT NULL THEN
    IF result.bysecond IS NULL AND
       result.byminute IS NULL AND
       result.byhour IS NULL AND
       result.byday IS NULL AND
       result.bymonthday IS NULL AND
       result.bymonth IS NULL AND
       result.byyearday IS NULL AND
       result.byweekno IS NULL THEN
      RAISE EXCEPTION 'Invalid RRULE: BYSETPOS requires at least one other BYxxx parameter. BYSETPOS selects specific positions from a set of occurrences, but you must specify which set using BYDAY, BYMONTHDAY, BYHOUR, etc. Example: FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1 (last workday of month).  RFC 5545 Section 3.3.10: "BYSETPOS MUST only be used in conjunction with another BYxxx rule part"';
    END IF;
  END IF;

  -- Validation 9-12: Parameter range validations
  -- BYSECOND: 0-60 (60 for leap seconds)
  IF result.bysecond IS NOT NULL THEN
    FOR i IN 1..array_length(result.bysecond, 1) LOOP
      EXIT WHEN result.bysecond[i] IS NULL;
      IF result.bysecond[i] < 0 OR result.bysecond[i] > 60 THEN
        RAISE EXCEPTION 'Invalid RRULE: BYSECOND=% is out of valid range. Valid values are 0-60 (60 for leap seconds).  RFC 5545 Section 3.3.10: "Valid values are 0 to 60"', result.bysecond[i];
      END IF;
    END LOOP;
  END IF;

  -- BYMINUTE: 0-59
  IF result.byminute IS NOT NULL THEN
    FOR i IN 1..array_length(result.byminute, 1) LOOP
      EXIT WHEN result.byminute[i] IS NULL;
      IF result.byminute[i] < 0 OR result.byminute[i] > 59 THEN
        RAISE EXCEPTION 'Invalid RRULE: BYMINUTE=% is out of valid range. Valid values are 0-59.  RFC 5545 Section 3.3.10: "Valid values are 0 to 59"', result.byminute[i];
      END IF;
    END LOOP;
  END IF;

  -- BYHOUR: 0-23
  IF result.byhour IS NOT NULL THEN
    FOR i IN 1..array_length(result.byhour, 1) LOOP
      EXIT WHEN result.byhour[i] IS NULL;
      IF result.byhour[i] < 0 OR result.byhour[i] > 23 THEN
        RAISE EXCEPTION 'Invalid RRULE: BYHOUR=% is out of valid range. Valid values are 0-23 (0 = midnight, 23 = 11 PM).  RFC 5545 Section 3.3.10: "Valid values are 0 to 23"', result.byhour[i];
      END IF;
    END LOOP;
  END IF;

  -- BYMONTH: 1-12 (for Gregorian calendar)
  IF result.bymonth IS NOT NULL THEN
    FOR i IN 1..array_length(result.bymonth, 1) LOOP
      EXIT WHEN result.bymonth[i] IS NULL;
      IF result.bymonth[i] < 1 OR result.bymonth[i] > 12 THEN
        RAISE EXCEPTION 'Invalid RRULE: BYMONTH=% is out of valid range. Valid values are 1-12 for Gregorian calendar (1=January, 12=December).  RFC 5545 Section 3.3.10: Valid month numbers are 1-12', result.bymonth[i];
      END IF;
    END LOOP;
  END IF;

  -- BYMONTHDAY: Must not be zero
  IF result.bymonthday IS NOT NULL THEN
    FOR i IN 1..array_length(result.bymonthday, 1) LOOP
      EXIT WHEN result.bymonthday[i] IS NULL;
      IF result.bymonthday[i] = 0 THEN
        RAISE EXCEPTION 'Invalid RRULE: BYMONTHDAY=0 is not valid. Valid values are 1-31 or -31 to -1 (negative values count from month end). Use BYMONTHDAY=1 for first day or BYMONTHDAY=-1 for last day.  RFC 5545 Section 3.3.10: Zero is not a valid BYMONTHDAY value';
      END IF;
      IF result.bymonthday[i] > 31 OR result.bymonthday[i] < -31 THEN
        RAISE EXCEPTION 'Invalid RRULE: BYMONTHDAY=% is out of valid range. Valid values are 1-31 or -31 to -1.  RFC 5545 Section 3.3.10: Valid range is ±1-31', result.bymonthday[i];
      END IF;
    END LOOP;
  END IF;

  -- BYYEARDAY: Must not be zero
  IF result.byyearday IS NOT NULL THEN
    FOR i IN 1..array_length(result.byyearday, 1) LOOP
      EXIT WHEN result.byyearday[i] IS NULL;
      IF result.byyearday[i] = 0 THEN
        RAISE EXCEPTION 'Invalid RRULE: BYYEARDAY=0 is not valid. Valid values are 1-366 or -366 to -1 (negative values count from year end). Use BYYEARDAY=1 for January 1st or BYYEARDAY=-1 for December 31st.  RFC 5545 Section 3.3.10: Zero is not a valid BYYEARDAY value';
      END IF;
      IF result.byyearday[i] > 366 OR result.byyearday[i] < -366 THEN
        RAISE EXCEPTION 'Invalid RRULE: BYYEARDAY=% is out of valid range. Valid values are 1-366 or -366 to -1 (366 for leap years).  RFC 5545 Section 3.3.10: Valid range is ±1-366', result.byyearday[i];
      END IF;
    END LOOP;
  END IF;

  -- BYWEEKNO: Valid range ±1-53
  IF result.byweekno IS NOT NULL THEN
    FOR i IN 1..array_length(result.byweekno, 1) LOOP
      EXIT WHEN result.byweekno[i] IS NULL;
      IF result.byweekno[i] = 0 THEN
        RAISE EXCEPTION 'Invalid RRULE: BYWEEKNO=0 is not valid. Valid values are 1-53 or -53 to -1 (ISO 8601 week numbers).  RFC 5545 Section 3.3.10: Zero is not a valid BYWEEKNO value';
      END IF;
      IF result.byweekno[i] > 53 OR result.byweekno[i] < -53 THEN
        RAISE EXCEPTION 'Invalid RRULE: BYWEEKNO=% is out of valid range. Valid values are 1-53 or -53 to -1 (ISO 8601 week numbers).  RFC 5545 Section 3.3.10: Valid range is ±1-53', result.byweekno[i];
      END IF;
    END LOOP;
  END IF;

  -- BYSETPOS: Valid range ±1-366
  IF result.bysetpos IS NOT NULL THEN
    FOR i IN 1..array_length(result.bysetpos, 1) LOOP
      EXIT WHEN result.bysetpos[i] IS NULL;
      IF result.bysetpos[i] = 0 THEN
        RAISE EXCEPTION 'Invalid RRULE: BYSETPOS=0 is not valid. Valid values are 1-366 or -366 to -1 for position selection. Use BYSETPOS=1 for first occurrence or BYSETPOS=-1 for last occurrence.  RFC 5545 Section 3.3.10: Zero is not a valid BYSETPOS value';
      END IF;
      IF result.bysetpos[i] > 366 OR result.bysetpos[i] < -366 THEN
        RAISE EXCEPTION 'Invalid RRULE: BYSETPOS=% is out of valid range. Valid values are 1-366 or -366 to -1.  RFC 5545 Section 3.3.10: Valid range is ±1-366', result.bysetpos[i];
      END IF;
    END LOOP;
  END IF;

  RETURN result;
END;
$$ LANGUAGE plpgsql IMMUTABLE STRICT;


-- Return a SETOF dates within the month of a particular date which match a string of BYDAY rule specifications
CREATE OR REPLACE FUNCTION rrule_month_byday_set(
  in_time TIMESTAMP WITH TIME ZONE,
  byday TEXT[],
  max_results INT DEFAULT NULL  -- NULL = unlimited, otherwise stop after N results
) RETURNS SETOF TIMESTAMP WITH TIME ZONE AS $$
DECLARE
  dayrule TEXT;
  i INT;  -- Still needed for final results array iteration
  dow INT;
  index INT;
  first_dow INT;
  each_day TIMESTAMP WITH TIME ZONE;
  this_month INT;
  results TIMESTAMP WITH TIME ZONE[];
  result_count INT := 0;
BEGIN
  -- Maintain STRICT semantics for required parameters
  IF in_time IS NULL THEN
    RETURN;
  END IF;

  IF byday IS NULL THEN
    -- We still return the single date as a SET
    RETURN NEXT in_time;
    RETURN;
  END IF;

  -- Iterate through each BYDAY rule (e.g., MO, 2TU, -1FR)
  FOREACH dayrule IN ARRAY byday LOOP
    dow := position(substring( dayrule from '..$') in 'SUMOTUWETHFRSA') / 2;
    each_day := date_trunc( 'month', in_time ) + (in_time::time)::interval;
    this_month := date_part( 'month', in_time );
    first_dow := date_part( 'dow', each_day );

    -- Coerce each_day to be the first 'dow' of the month
    each_day := each_day - ( first_dow::text || 'days')::interval
                        + ( dow::text || 'days')::interval
                        + CASE WHEN dow < first_dow THEN '1 week'::interval ELSE '0s'::interval END;

    IF length(dayrule) > 2 THEN
      index := (substring(dayrule from '^[0-9-]+'))::int;

      IF index = 0 THEN
        RAISE NOTICE 'Ignored invalid BYDAY rule part "%".', dayrule;
      ELSIF index > 0 THEN
        -- The simplest case, such as 2MO for the second monday
        each_day := each_day + ((index - 1)::text || ' weeks')::interval;
      ELSE
        each_day := each_day + '5 weeks'::interval;
        WHILE date_part('month', each_day) != this_month LOOP
          each_day := each_day - '1 week'::interval;
        END LOOP;
        -- Note that since index is negative, (-2 + 1) == -1, for example
        index := index + 1;
        IF index < 0 THEN
          each_day := each_day + (index::text || ' weeks')::interval ;
        END IF;
      END IF;

      -- Sometimes (e.g. 5TU or -5WE) there might be no such date in some months
      IF date_part('month', each_day) = this_month THEN
        results[date_part('day',each_day)] := each_day;
      END IF;

    ELSE
      -- Return all such days that are within the given month
      WHILE date_part('month', each_day) = this_month LOOP
        results[date_part('day',each_day)] := each_day;
        each_day := each_day + '1 week'::interval;
      END LOOP;
    END IF;
  END LOOP;

  FOR i IN 1..31 LOOP
    IF results[i] IS NOT NULL THEN
      RETURN NEXT results[i];
      result_count := result_count + 1;

      -- Early exit: stop once we've generated enough results
      EXIT WHEN max_results IS NOT NULL AND result_count >= max_results;
    END IF;
  END LOOP;

  RETURN;

END;
$$ LANGUAGE plpgsql IMMUTABLE;  -- No STRICT (was never STRICT)


------------------------------------------------------------------------------------------------------
-- RFC 7529 SKIP: Generate dates for BYMONTHDAY with SKIP support
-- SKIP=OMIT (default): Skip invalid dates (e.g., Feb 31, Apr 31)
-- SKIP=BACKWARD: Use last valid day of month (e.g., Feb 31 → Feb 28/29)
-- SKIP=FORWARD: Use first day of next month (e.g., Feb 31 → Mar 1)
------------------------------------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION rrule_month_bymonthday_set(
  in_time TIMESTAMP WITH TIME ZONE,
  bymonthday INT[],
  skip_mode TEXT,  -- 'OMIT', 'BACKWARD', 'FORWARD'
  max_results INT DEFAULT NULL  -- NULL = unlimited, otherwise stop after N results
) RETURNS SETOF TIMESTAMP WITH TIME ZONE AS $$
DECLARE
  month_start TIMESTAMP WITH TIME ZONE;
  daysinmonth INT;
  requested_day INT;
  adjusted_date TIMESTAMP WITH TIME ZONE;
  time_component TIME;
  seen_dates TIMESTAMP WITH TIME ZONE[];  -- Track to avoid duplicates
  result_count INT := 0;
BEGIN
  -- Maintain STRICT semantics for required parameters
  IF in_time IS NULL THEN
    RETURN;
  END IF;

  month_start := DATE_TRUNC('month', in_time);
  time_component := in_time::TIME;

  -- Calculate days in this month
  daysinmonth := EXTRACT(DAY FROM (
    month_start + INTERVAL '1 month' - INTERVAL '1 day'
  ))::INT;

  -- Initialize seen_dates array
  seen_dates := ARRAY[]::TIMESTAMP WITH TIME ZONE[];

  -- Iterate through each day in BYMONTHDAY array (e.g., 1, 15, -1)
  FOREACH requested_day IN ARRAY bymonthday LOOP
    -- Handle negative indices (count from end of month)
    -- Negative indices are always valid (RFC 5545), no SKIP needed
    IF requested_day < 0 THEN
      -- Ensure it's within valid range
      CONTINUE WHEN requested_day < (-1 * daysinmonth);
      adjusted_date := month_start +
        ((daysinmonth + requested_day)::TEXT || ' days')::INTERVAL +
        time_component::INTERVAL;

      -- Check for duplicates before returning
      IF NOT (adjusted_date = ANY(seen_dates)) THEN
        seen_dates := array_append(seen_dates, adjusted_date);
        RETURN NEXT adjusted_date;
        result_count := result_count + 1;
        EXIT WHEN max_results IS NOT NULL AND result_count >= max_results;
      END IF;
      CONTINUE;
    END IF;

    -- Skip zero (invalid in RFC 5545)
    IF requested_day = 0 THEN
      RAISE NOTICE 'Ignored invalid BYMONTHDAY part "0".';
      CONTINUE;
    END IF;

    -- Positive indices: Apply RFC 7529 SKIP logic if day doesn't exist
    IF requested_day <= daysinmonth THEN
      -- Day exists in this month, use it
      adjusted_date := month_start +
        ((requested_day - 1)::TEXT || ' days')::INTERVAL +
        time_component::INTERVAL;

      -- Check for duplicates before returning
      IF NOT (adjusted_date = ANY(seen_dates)) THEN
        seen_dates := array_append(seen_dates, adjusted_date);
        RETURN NEXT adjusted_date;
        result_count := result_count + 1;
        EXIT WHEN max_results IS NOT NULL AND result_count >= max_results;
      END IF;
    ELSE
      -- Day doesn't exist (e.g., Feb 31, Apr 31)
      -- Apply RFC 7529 SKIP logic
      CASE skip_mode
        WHEN 'OMIT' THEN
          -- Skip this occurrence (default RFC 7529 behavior)
          CONTINUE;

        WHEN 'BACKWARD' THEN
          -- Use last day of month
          adjusted_date := month_start +
            ((daysinmonth - 1)::TEXT || ' days')::INTERVAL +
            time_component::INTERVAL;

          -- Check for duplicates before returning
          IF NOT (adjusted_date = ANY(seen_dates)) THEN
            seen_dates := array_append(seen_dates, adjusted_date);
            RETURN NEXT adjusted_date;
            result_count := result_count + 1;
            EXIT WHEN max_results IS NOT NULL AND result_count >= max_results;
          END IF;

        WHEN 'FORWARD' THEN
          -- Use first day of next month
          adjusted_date := month_start +
            INTERVAL '1 month' +
            time_component::INTERVAL;

          -- Check for duplicates before returning
          IF NOT (adjusted_date = ANY(seen_dates)) THEN
            seen_dates := array_append(seen_dates, adjusted_date);
            RETURN NEXT adjusted_date;
            result_count := result_count + 1;
            EXIT WHEN max_results IS NOT NULL AND result_count >= max_results;
          END IF;

        ELSE
          -- Unknown SKIP mode, default to OMIT
          CONTINUE;
      END CASE;
    END IF;
  END LOOP;

  RETURN;
END;
$$ LANGUAGE plpgsql IMMUTABLE;  -- STRICT removed to allow NULL max_results


-- Return a SETOF dates within the week of a particular date which match a single BYDAY rule specification
-- Now supports WKST (week start day) parameter
CREATE OR REPLACE FUNCTION rrule_week_byday_set(
  in_time TIMESTAMP WITH TIME ZONE,
  byday TEXT[],
  wkst TEXT,
  max_results INT DEFAULT NULL  -- NULL = unlimited, otherwise stop after N results
) RETURNS SETOF TIMESTAMP WITH TIME ZONE AS $$
DECLARE
  dayrule TEXT;
  dow INT;
  wkst_dow INT;
  day_offset INT;
  our_day TIMESTAMP WITH TIME ZONE;
  i INT;
  result_count INT := 0;
BEGIN
  -- Maintain STRICT semantics for required parameters
  IF in_time IS NULL THEN
    RETURN;
  END IF;

  IF byday IS NULL THEN
    -- We still return the single date as a SET
    RETURN NEXT in_time;
    RETURN;
  END IF;

  -- Get the WKST day number (0=SU, 1=MO, etc.)
  wkst_dow := rrule.weekday_to_number(wkst);

  -- Use WKST-aware week start instead of hardcoded Monday
  our_day := rrule.get_week_start(in_time, wkst) + (in_time::time)::interval;

  i := 1;
  dayrule := byday[i];
  WHILE dayrule IS NOT NULL LOOP
    dow := position(dayrule in 'SUMOTUWETHFRSA') / 2;
    -- Calculate day_offset from week start (WKST day)
    -- Example: if WKST=SU (0) and we want MO (1), day_offset = (1-0+7)%7 = 1
    -- Example: if WKST=MO (1) and we want SU (0), day_offset = (0-1+7)%7 = 6
    day_offset := (dow - wkst_dow + 7) % 7;
    RETURN NEXT our_day + (day_offset::text || ' days')::interval;
    result_count := result_count + 1;

    -- Early exit: stop once we've generated enough results
    EXIT WHEN max_results IS NOT NULL AND result_count >= max_results;

    i := i + 1;
    dayrule := byday[i];
  END LOOP;

  RETURN;

END;
$$ LANGUAGE plpgsql IMMUTABLE;  -- No STRICT (was never STRICT)


------------------------------------------------------------------------------------------------------
-- WKST (Week Start) Support Functions
--
-- These functions implement RFC 5545 WKST parameter support for custom week start days.
-- Default is Monday (ISO 8601), but US convention uses Sunday, and RFC allows any day.
------------------------------------------------------------------------------------------------------

-- Convert weekday abbreviation to number (0=SU, 1=MO, 2=TU, 3=WE, 4=TH, 5=FR, 6=SA)
-- Matches PostgreSQL's date_part('dow', ...) convention
CREATE OR REPLACE FUNCTION weekday_to_number(wkst TEXT) RETURNS INT AS $$
BEGIN
    RETURN CASE COALESCE(wkst, 'MO')
        WHEN 'SU' THEN 0
        WHEN 'MO' THEN 1
        WHEN 'TU' THEN 2
        WHEN 'WE' THEN 3
        WHEN 'TH' THEN 4
        WHEN 'FR' THEN 5
        WHEN 'SA' THEN 6
        ELSE 1  -- Default to Monday if invalid
    END;
END;
$$ LANGUAGE plpgsql IMMUTABLE;

-- Get the start of the week containing the given date, respecting WKST
-- Example: get_week_start('2025-01-15', 'SU') returns '2025-01-12' (Sunday)
--          get_week_start('2025-01-15', 'MO') returns '2025-01-13' (Monday)
CREATE OR REPLACE FUNCTION get_week_start(d TIMESTAMP WITH TIME ZONE, wkst TEXT)
RETURNS TIMESTAMP WITH TIME ZONE AS $$
DECLARE
    wkst_num INT;
    dow INT;
    days_back INT;
BEGIN
    wkst_num := rrule.weekday_to_number(wkst);
    dow := date_part('dow', d);

    -- Calculate how many days back to go to reach WKST
    -- Example: If today is Wednesday (3) and WKST is Sunday (0): (3 - 0) = 3 days back
    -- Example: If today is Monday (1) and WKST is Wednesday (3): (1 - 3 + 7) % 7 = 5 days back
    days_back := (dow - wkst_num + 7) % 7;

    -- Return start of day, N days back
    RETURN date_trunc('day', d) - (days_back::TEXT || ' days')::INTERVAL;
END;
$$ LANGUAGE plpgsql IMMUTABLE;

-- Get week number (1-53) for a date, respecting WKST
-- Algorithm: Simple week counting (not ISO 8601 for non-Monday starts)
--   - Week 1 starts on the first WKST day of the year
--   - Subsequent weeks are 7-day increments
--   - Dates before week 1 belong to the last week of the previous year
--
-- Example with WKST=SU:
--   - If Jan 1 is a Wednesday, first Sunday is Jan 4
--   - Jan 1-3 belong to last week of previous year
--   - Jan 4-10 is Week 1, Jan 11-17 is Week 2, etc.
CREATE OR REPLACE FUNCTION get_week_number(d TIMESTAMP WITH TIME ZONE, wkst TEXT)
RETURNS INT AS $$
DECLARE
    year_start TIMESTAMP WITH TIME ZONE;
    prev_year_start TIMESTAMP WITH TIME ZONE;
    wkst_num INT;
    first_day_dow INT;
    days_to_first_wkst INT;
    first_wkst TIMESTAMP WITH TIME ZONE;
    days_diff NUMERIC;
    week_num INT;

    -- For previous year calculation
    prev_first_day_dow INT;
    prev_days_to_first_wkst INT;
    prev_first_wkst TIMESTAMP WITH TIME ZONE;
    prev_days_diff NUMERIC;
BEGIN
    wkst_num := rrule.weekday_to_number(wkst);
    year_start := date_trunc('year', d);
    first_day_dow := date_part('dow', year_start);

    -- Calculate days from Jan 1 to first WKST of the year
    -- Example: Jan 1 is Wednesday (3), WKST is Sunday (0): (0 - 3 + 7) % 7 = 4 days
    days_to_first_wkst := (wkst_num - first_day_dow + 7) % 7;
    first_wkst := year_start + (days_to_first_wkst::TEXT || ' days')::INTERVAL;

    -- Calculate days from first WKST to target date
    days_diff := EXTRACT(EPOCH FROM (date_trunc('day', d) - first_wkst)) / 86400;

    IF days_diff < 0 THEN
        -- Date is before week 1 of this year - belongs to last week of previous year
        prev_year_start := year_start - INTERVAL '1 year';

        prev_first_day_dow := date_part('dow', prev_year_start);
        prev_days_to_first_wkst := (wkst_num - prev_first_day_dow + 7) % 7;
        prev_first_wkst := prev_year_start + (prev_days_to_first_wkst::TEXT || ' days')::INTERVAL;

        prev_days_diff := EXTRACT(EPOCH FROM (date_trunc('day', d) - prev_first_wkst)) / 86400;
        week_num := (prev_days_diff::INT / 7) + 1;
        RETURN week_num;
    ELSE
        -- Week number = floor(days_diff / 7) + 1
        -- Example: 0-6 days from first WKST = Week 1, 7-13 days = Week 2, etc.
        week_num := (days_diff::INT / 7) + 1;
        RETURN week_num;
    END IF;
END;
$$ LANGUAGE plpgsql IMMUTABLE;


------------------------------------------------------------------------------------------------------
-- Timezone Validation Helper
------------------------------------------------------------------------------------------------------
--
-- Validates that a timezone string is a valid IANA timezone identifier.
-- Raises an exception if the timezone is invalid.
-- Does nothing if timezone is NULL (allowing optional timezone parameters).
--
-- This function centralizes timezone validation logic that was previously duplicated
-- across multiple public API functions.
--
-- Parameters:
--   tz - Timezone string (e.g., 'America/New_York', 'Europe/London', 'UTC')
--
-- Returns: VOID (raises exception on invalid timezone)
--
-- Example:
--   PERFORM rrule.validate_timezone('America/New_York');  -- Success
--   PERFORM rrule.validate_timezone('Invalid/Zone');      -- Raises exception
--   PERFORM rrule.validate_timezone(NULL);                -- Success (NULL is allowed)
--
------------------------------------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION validate_timezone(tz TEXT)
RETURNS VOID AS $$
BEGIN
  -- NULL is allowed (indicates optional timezone parameter)
  IF tz IS NULL THEN
    RETURN;
  END IF;

  -- Check if timezone exists in PostgreSQL's timezone database
  IF tz NOT IN (SELECT name FROM pg_timezone_names) THEN
    RAISE EXCEPTION 'Invalid timezone: "%". Must be a valid IANA timezone identifier (e.g., America/New_York, Europe/London, Asia/Tokyo, UTC). Use: SELECT name FROM pg_timezone_names ORDER BY name; to see all valid timezones.', tz;
  END IF;
END;
$$ LANGUAGE plpgsql IMMUTABLE;


------------------------------------------------------------------------------------------------------
-- Test the weekday of this date against the array of weekdays from the BYDAY rule (FREQ=WEEKLY or less)
------------------------------------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION test_byday_rule(
  testme TIMESTAMP WITH TIME ZONE,
  byday TEXT[]
) RETURNS BOOLEAN AS $$
BEGIN
  -- Note that this doesn't work for MONTHLY/YEARLY BYDAY clauses which might have numbers prepended
  -- so don't call it that way...
  IF byday IS NOT NULL THEN
    RETURN ( substring( to_char( testme, 'DY') for 2 from 1) = ANY (byday) );
  END IF;
  RETURN TRUE;
END;
$$ LANGUAGE plpgsql IMMUTABLE;


------------------------------------------------------------------------------------------------------
-- Test the month of this date against the array of months from the rule
------------------------------------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION test_bymonth_rule(
  testme TIMESTAMP WITH TIME ZONE,
  bymonth INT[]
) RETURNS BOOLEAN AS $$
BEGIN
  IF bymonth IS NOT NULL THEN
    RETURN ( date_part( 'month', testme) = ANY (bymonth) );
  END IF;
  RETURN TRUE;
END;
$$ LANGUAGE plpgsql IMMUTABLE;


------------------------------------------------------------------------------------------------------
-- Test the day in month of this date against the array of monthdays from the rule
------------------------------------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION test_bymonthday_rule(
  testme TIMESTAMP WITH TIME ZONE,
  bymonthday INT[]
) RETURNS BOOLEAN AS $$
BEGIN
  IF bymonthday IS NOT NULL THEN
    RETURN ( date_part( 'day', testme) = ANY (bymonthday) );
  END IF;
  RETURN TRUE;
END;
$$ LANGUAGE plpgsql IMMUTABLE;


------------------------------------------------------------------------------------------------------
-- Test the day in year of this date against the array of yeardays from the rule
------------------------------------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION test_byyearday_rule(
  testme TIMESTAMP WITH TIME ZONE,
  byyearday INT[]
) RETURNS BOOLEAN AS $$
BEGIN
  IF byyearday IS NOT NULL THEN
    RETURN ( date_part( 'doy', testme) = ANY (byyearday) );
  END IF;
  RETURN TRUE;
END;
$$ LANGUAGE plpgsql IMMUTABLE;


------------------------------------------------------------------------------------------------------
-- Calculate safe iteration limit to prevent infinite loops with sparse BYxxx filters
--
-- SECURITY-CRITICAL: These multipliers prevent denial-of-service attacks when RRULEs
-- contain sparse filters like BYDAY=MO (matches 1/7 days) combined with BYSETPOS.
-- Without proper limits, the database could iterate millions of times searching for
-- occurrences that rarely match.
--
-- MULTIPLIER RATIONALE (based on mathematical worst-case analysis):
--
--   DAILY × 20:   FREQ=DAILY;BYDAY=MO;BYSETPOS=-1 requires ~4 weeks (28 days)
--                 to find 1 occurrence (last Monday of month). With monthly-sparse
--                 filters, we need 20x headroom to guarantee finding matches.
--
--   WEEKLY × 10:  FREQ=WEEKLY;BYMONTH=12 requires ~10 weeks to find 1 occurrence
--                 (only weeks in December match). Monthly filters on weekly frequency
--                 create extreme sparsity.
--
--   HOURLY × 2:   FREQ=HOURLY;BYHOUR=9,17 matches 2/24 hours, requiring ~12 hours
--                 per match. Factor of 2x provides adequate headroom.
--
--   MINUTELY cap: Hard limit at 1440 (1 day) regardless of requested_max.
--                 DoS protection: prevents generating millions of minute-level occurrences.
--
--   SECONDLY cap: Hard limit at 3600 (1 hour) regardless of requested_max.
--                 DoS protection: prevents generating tens of millions of second-level occurrences.
--
-- SECURITY: DO NOT increase these multipliers without thorough security review and testing.
-- Increasing limits could enable resource exhaustion attacks in multi-tenant environments.
--
-- PRECEDENCE: If rrule_count is specified (COUNT in RRULE), it takes absolute precedence
-- over all multipliers. The RRULE author's explicit COUNT is always honored.
--
-- Parameters:
--   frequency      - FREQ value from RRULE (DAILY, WEEKLY, MONTHLY, YEARLY, HOURLY, MINUTELY, SECONDLY)
--   rrule_count    - COUNT parameter from RRULE, or NULL if not specified
--   requested_max  - Maximum occurrences requested by API caller
--
-- Returns:
--   Safe iteration limit that balances:
--     1. Finding enough matches even with sparse filters (multipliers)
--     2. Preventing resource exhaustion (DoS caps)
--     3. Respecting RRULE author's explicit COUNT (precedence)
--
-- Examples:
--   calculate_safe_iteration_limit('DAILY', NULL, 100)    → 2000  (100 × 20)
--   calculate_safe_iteration_limit('WEEKLY', NULL, 50)    → 500   (50 × 10)
--   calculate_safe_iteration_limit('MINUTELY', NULL, 5000) → 1440  (DoS cap)
--   calculate_safe_iteration_limit('DAILY', 75, 100)      → 75    (COUNT wins)
------------------------------------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION calculate_safe_iteration_limit(
  frequency TEXT,
  rrule_count INT,
  requested_max INT
) RETURNS INT AS $$
BEGIN
  -- RRULE COUNT parameter always takes absolute precedence
  -- (author's explicit intent overrides all safety multipliers)
  IF rrule_count IS NOT NULL THEN
    RETURN rrule_count;
  END IF;

  -- Apply frequency-specific safety multipliers for sparse filter protection
  RETURN CASE frequency
    WHEN 'DAILY'    THEN requested_max * 20   -- Sparse: weekly-pattern filters
    WHEN 'WEEKLY'   THEN requested_max * 10   -- Sparse: monthly-pattern filters
    WHEN 'HOURLY'   THEN requested_max * 2    -- Moderate: time-of-day filters
    WHEN 'MINUTELY' THEN LEAST(requested_max, 1440)  -- DoS protection: max 1 day
    WHEN 'SECONDLY' THEN LEAST(requested_max, 3600)  -- DoS protection: max 1 hour
    ELSE requested_max                         -- MONTHLY, YEARLY: no multiplier needed
  END;
END;
$$ LANGUAGE plpgsql IMMUTABLE;

-- PostgreSQL metadata: Documents this function's purpose for introspection
COMMENT ON FUNCTION calculate_safe_iteration_limit IS
'Security-critical function: Calculates safe iteration limits for RRULE generation accounting for sparse BYxxx filters and DoS protection. See function header for detailed security rationale and multiplier derivation.';


------------------------------------------------------------------------------------------------------
-- Given a cursor into a set, process the set returning the subset matching the BYSETPOS
--
-- Requires: PostgreSQL 12+ for cursor handling syntax and other modern SQL features.
-- (Cursors with SCROLL support have been stable since PostgreSQL 8.3, but this implementation
-- uses additional features requiring PostgreSQL 12 or later.)
------------------------------------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION rrule_bysetpos_filter(
  curse REFCURSOR,
  bysetpos INT[]
) RETURNS SETOF TIMESTAMP WITH TIME ZONE AS $$
DECLARE
  valid_date TIMESTAMP WITH TIME ZONE;
  i INT;
BEGIN

  IF bysetpos IS NULL THEN
    LOOP
      FETCH curse INTO valid_date;
      EXIT WHEN NOT FOUND;
      RETURN NEXT valid_date;
    END LOOP;
  ELSE
    FOR i IN 1..366 LOOP
      EXIT WHEN bysetpos[i] IS NULL;
      IF bysetpos[i] > 0 THEN
        FETCH ABSOLUTE bysetpos[i] FROM curse INTO valid_date;
      ELSE
        MOVE LAST IN curse;
        FETCH RELATIVE (bysetpos[i] + 1) FROM curse INTO valid_date;
      END IF;
      IF valid_date IS NOT NULL THEN
        RETURN NEXT valid_date;
      END IF;
    END LOOP;
  END IF;
  CLOSE curse;
END;
$$ LANGUAGE plpgsql IMMUTABLE;


------------------------------------------------------------------------------------------------------
-- Helper function: Generate times within a day based on BYHOUR/BYMINUTE/BYSECOND
-- If no time filters specified, returns the input time
-- If time filters specified, generates all matching times within the same day
------------------------------------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION rrule_day_time_set(
  base_time TIMESTAMP WITH TIME ZONE,
  rule rrule.rrule_parts,
  max_results INT DEFAULT NULL  -- NULL = unlimited, otherwise stop after N results
) RETURNS SETOF TIMESTAMP WITH TIME ZONE AS $$
DECLARE
  day_start TIMESTAMP WITH TIME ZONE;
  occurrence TIMESTAMP WITH TIME ZONE;
  hour INT;
  minute INT;
  second INT;
  hour_idx INT;
  minute_idx INT;
  second_idx INT;
  result_count INT := 0;
BEGIN
  -- Maintain STRICT semantics for required parameters
  IF base_time IS NULL OR rule IS NULL THEN
    RETURN;
  END IF;

  -- If no time-based filters, return the input time
  IF rule.byhour IS NULL AND rule.byminute IS NULL AND rule.bysecond IS NULL THEN
    RETURN NEXT base_time;
    RETURN;
  END IF;

  day_start := date_trunc('day', base_time);

  -- Generate all combinations of hour/minute/second
  hour_idx := 1;
  LOOP
    EXIT WHEN rule.byhour IS NULL AND hour_idx > 1;
    EXIT WHEN rule.byhour IS NOT NULL AND rule.byhour[hour_idx] IS NULL;

    hour := COALESCE(rule.byhour[hour_idx], date_part('hour', base_time)::INT);
    IF hour < 0 OR hour > 23 THEN
      hour_idx := hour_idx + 1;
      CONTINUE;
    END IF;

    minute_idx := 1;
    LOOP
      EXIT WHEN rule.byminute IS NULL AND minute_idx > 1;
      EXIT WHEN rule.byminute IS NOT NULL AND rule.byminute[minute_idx] IS NULL;

      minute := COALESCE(rule.byminute[minute_idx], date_part('minute', base_time)::INT);
      IF minute < 0 OR minute > 59 THEN
        minute_idx := minute_idx + 1;
        CONTINUE;
      END IF;

      second_idx := 1;
      LOOP
        EXIT WHEN rule.bysecond IS NULL AND second_idx > 1;
        EXIT WHEN rule.bysecond IS NOT NULL AND rule.bysecond[second_idx] IS NULL;

        second := COALESCE(rule.bysecond[second_idx], date_part('second', base_time)::INT);
        IF second < 0 OR second > 59 THEN
          second_idx := second_idx + 1;
          CONTINUE;
        END IF;

        -- Build occurrence timestamp
        occurrence := day_start + (hour::text || ' hours')::interval
                                + (minute::text || ' minutes')::interval
                                + (second::text || ' seconds')::interval;

        RETURN NEXT occurrence;
        result_count := result_count + 1;

        -- Early exit: stop once we've generated enough results
        -- Critical for performance: 24×60×60 = 86,400 possible time slots per day!
        IF max_results IS NOT NULL AND result_count >= max_results THEN
          RETURN;
        END IF;

        second_idx := second_idx + 1;
        EXIT WHEN rule.bysecond IS NULL;
      END LOOP;

      minute_idx := minute_idx + 1;
      EXIT WHEN rule.byminute IS NULL;
    END LOOP;

    hour_idx := hour_idx + 1;
    EXIT WHEN rule.byhour IS NULL;
  END LOOP;

END;
$$ LANGUAGE plpgsql IMMUTABLE;  -- STRICT removed to allow NULL max_results


------------------------------------------------------------------------------------------------------
-- Return another day's worth of events
-- Now supports BYHOUR, BYMINUTE, BYSECOND, and BYSETPOS for sub-day scheduling
------------------------------------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION daily_set(
  after_ts TIMESTAMP WITH TIME ZONE,
  rule rrule.rrule_parts,
  max_results INT DEFAULT NULL  -- NULL = unlimited, otherwise stop after N results
) RETURNS SETOF TIMESTAMP WITH TIME ZONE AS $$
DECLARE
  curse REFCURSOR;
BEGIN
  -- Maintain STRICT semantics for required parameters
  IF after_ts IS NULL OR rule IS NULL THEN
    RETURN;
  END IF;

  IF rule.bymonth IS NOT NULL AND NOT date_part('month',after_ts) = ANY ( rule.bymonth ) THEN
    RETURN;
  END IF;

  IF rule.byweekno IS NOT NULL AND NOT date_part('week',after_ts) = ANY ( rule.byweekno ) THEN
    RETURN;
  END IF;

  IF rule.byyearday IS NOT NULL AND NOT date_part('doy',after_ts) = ANY ( rule.byyearday ) THEN
    RETURN;
  END IF;

  IF rule.bymonthday IS NOT NULL AND NOT date_part('day',after_ts) = ANY ( rule.bymonthday ) THEN
    RETURN;
  END IF;

  IF rule.byday IS NOT NULL AND NOT substring( to_char( after_ts, 'DY') for 2 from 1) = ANY ( rule.byday ) THEN
    RETURN;
  END IF;

  -- Now handle BYHOUR, BYMINUTE, BYSECOND, and BYSETPOS
  IF rule.byhour IS NOT NULL OR rule.byminute IS NOT NULL OR rule.bysecond IS NOT NULL OR rule.bysetpos IS NOT NULL THEN
    -- Generate times within the day and apply BYSETPOS filter
    -- Pass max_results down (NULL = unlimited, for BYSETPOS which needs full set)
    OPEN curse SCROLL FOR SELECT r FROM rrule.rrule_day_time_set(after_ts, rule, max_results) r ORDER BY 1;
    RETURN QUERY SELECT d FROM rrule.rrule_bysetpos_filter(curse, rule.bysetpos) d;
  ELSE
    -- No sub-day scheduling - return the input time
    RETURN NEXT after_ts;
  END IF;

END;
$$ LANGUAGE plpgsql IMMUTABLE;  -- STRICT removed to allow NULL max_results


------------------------------------------------------------------------------------------------------
-- Return another week's worth of events
--
-- Doesn't handle truly obscure and unlikely stuff like BYWEEKNO=5;BYMONTH=1;BYDAY=WE,TH,FR;BYSETPOS=-2
-- Imagine that.
------------------------------------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION weekly_set(
  after_ts TIMESTAMP WITH TIME ZONE,
  rule rrule.rrule_parts,
  max_results INT DEFAULT NULL  -- NULL = unlimited, otherwise stop after N results
) RETURNS SETOF TIMESTAMP WITH TIME ZONE AS $$
DECLARE
  valid_date TIMESTAMP WITH TIME ZONE;
  curse REFCURSOR;
  weekno INT;
  i INT;
BEGIN
  -- Maintain STRICT semantics for required parameters
  IF after_ts IS NULL OR rule IS NULL THEN
    RETURN;
  END IF;

  IF rule.byweekno IS NOT NULL THEN
    -- Use WKST-aware week numbering instead of PostgreSQL's ISO 8601 default
    weekno := rrule.get_week_number(after_ts, rule.wkst);
    IF NOT weekno = ANY ( rule.byweekno ) THEN
      RETURN;
    END IF;
  END IF;

  -- BYYEARDAY filter: Rare but valid use case
  -- Example: FREQ=WEEKLY;BYYEARDAY=100 = "Every week, but only on day 100 of year"
  IF rule.byyearday IS NOT NULL THEN
    IF NOT date_part('doy', after_ts) = ANY ( rule.byyearday ) THEN
      RETURN;
    END IF;
  END IF;

  -- Pass WKST and max_results to rrule_week_byday_set for proper week boundary calculation
  OPEN curse SCROLL FOR SELECT r FROM rrule.rrule_week_byday_set(after_ts, rule.byday, rule.wkst, max_results) r;
  RETURN QUERY SELECT d FROM rrule.rrule_bysetpos_filter(curse, rule.bysetpos) d;

END;
$$ LANGUAGE plpgsql IMMUTABLE;  -- STRICT removed to allow NULL max_results


------------------------------------------------------------------------------------------------------
-- Return another month's worth of events
------------------------------------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION monthly_set(
  after_ts TIMESTAMP WITH TIME ZONE,
  rule rrule.rrule_parts,
  max_results INT DEFAULT NULL  -- NULL = unlimited, otherwise stop after N results
) RETURNS SETOF TIMESTAMP WITH TIME ZONE AS $$
DECLARE
  valid_date TIMESTAMP WITH TIME ZONE;
  curse REFCURSOR;
  setpos INT;
  i INT;
BEGIN
  -- Maintain STRICT semantics for required parameters
  IF after_ts IS NULL OR rule IS NULL THEN
    RETURN;
  END IF;

  /**
  * Need to investigate whether it is legal to set both of these, and whether
  * we are correct to UNION the results, or whether we should INTERSECT them.
  * So at this point, we refer to the specification, which grants us this
  * wonderfully enlightening vision:
  *
  *     If multiple BYxxx rule parts are specified, then after evaluating the
  *     specified FREQ and INTERVAL rule parts, the BYxxx rule parts are
  *     applied to the current set of evaluated occurrences in the following
  *     order: BYMONTH, BYWEEKNO, BYYEARDAY, BYMONTHDAY, BYDAY, BYHOUR,
  *     BYMINUTE, BYSECOND and BYSETPOS; then COUNT and UNTIL are evaluated.
  *
  * My guess is that this means 'INTERSECT'
  */

  -- BYWEEKNO filter: Rare but valid use case
  -- Example: FREQ=MONTHLY;BYWEEKNO=10 = "Every month, but only in week 10 of year"
  IF rule.byweekno IS NOT NULL THEN
    -- Use WKST-aware week numbering
    IF NOT rrule.get_week_number(after_ts, rule.wkst) = ANY ( rule.byweekno ) THEN
      RETURN;
    END IF;
  END IF;

  -- BYYEARDAY filter: Rare but valid use case
  -- Example: FREQ=MONTHLY;BYYEARDAY=100 = "Every month, but only on day 100 of year"
  IF rule.byyearday IS NOT NULL THEN
    IF NOT date_part('doy', after_ts) = ANY ( rule.byyearday ) THEN
      RETURN;
    END IF;
  END IF;

  -- Pass max_results down to helper functions
  IF rule.byday IS NOT NULL AND rule.bymonthday IS NOT NULL THEN
    OPEN curse SCROLL FOR SELECT r FROM rrule.rrule_month_byday_set(after_ts, rule.byday, max_results) r
                INTERSECT SELECT r FROM rrule.rrule_month_bymonthday_set(after_ts, rule.bymonthday, rule.skip, max_results) r
                    ORDER BY 1;
  ELSIF rule.bymonthday IS NOT NULL THEN
    OPEN curse SCROLL FOR SELECT r FROM rrule.rrule_month_bymonthday_set(after_ts, rule.bymonthday, rule.skip, max_results) r ORDER BY 1;
  ELSE
    OPEN curse SCROLL FOR SELECT r FROM rrule.rrule_month_byday_set(after_ts, rule.byday, max_results) r ORDER BY 1;
  END IF;

  RETURN QUERY SELECT d FROM rrule.rrule_bysetpos_filter(curse, rule.bysetpos) d;

END;
$$ LANGUAGE plpgsql IMMUTABLE;  -- STRICT removed to allow NULL max_results


------------------------------------------------------------------------------------------------------
-- If this is YEARLY;BYMONTH, abuse MONTHLY;BYMONTH for everything except the BYSETPOS
------------------------------------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION rrule_yearly_bymonth_set(
  after_ts TIMESTAMP WITH TIME ZONE,
  rule rrule.rrule_parts,
  max_results INT DEFAULT NULL  -- NULL = unlimited, otherwise stop after N results
) RETURNS SETOF TIMESTAMP WITH TIME ZONE AS $$
DECLARE
  current_base TIMESTAMP WITH TIME ZONE;
  rr rrule.rrule_parts;
  i INT;
BEGIN
  -- Maintain STRICT semantics for required parameters
  IF after_ts IS NULL OR rule IS NULL THEN
    RETURN;
  END IF;

  IF rule.bymonth IS NOT NULL THEN
    -- Ensure we don't pass BYSETPOS down
    rr := rule;
    rr.bysetpos := NULL;
    FOR i IN 1..12 LOOP
      EXIT WHEN rr.bymonth[i] IS NULL;
      current_base := date_trunc( 'year', after_ts ) + ((rr.bymonth[i] - 1)::text || ' months')::interval + ((date_part('day', after_ts) - 1)::text || ' days')::interval + (after_ts::time)::interval;
      RETURN QUERY SELECT r FROM rrule.monthly_set(current_base, rr, max_results) r;
    END LOOP;
  ELSE
    RETURN NEXT after_ts;
  END IF;

END;
$$ LANGUAGE plpgsql IMMUTABLE;  -- STRICT removed to allow NULL max_results


------------------------------------------------------------------------------------------------------
-- Helper function for YEARLY + BYYEARDAY
-- Generates occurrences for specific days of the year
-- Example: FREQ=YEARLY;BYYEARDAY=100 = April 9/10 (day 100 of each year)
-- Supports negative indices: BYYEARDAY=-1 = December 31
------------------------------------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION rrule_yearly_byyearday_set(
  after_ts TIMESTAMP WITH TIME ZONE,
  rule rrule.rrule_parts,
  max_results INT DEFAULT NULL  -- NULL = unlimited, otherwise stop after N results
) RETURNS SETOF TIMESTAMP WITH TIME ZONE AS $$
DECLARE
  year_start TIMESTAMP WITH TIME ZONE;
  year_end TIMESTAMP WITH TIME ZONE;
  occurrence TIMESTAMP WITH TIME ZONE;
  days_in_year INT;
  yearday INT;
  i INT;
  result_count INT := 0;
BEGIN
  -- Maintain STRICT semantics for required parameters
  IF after_ts IS NULL OR rule IS NULL THEN
    RETURN;
  END IF;

  IF rule.byyearday IS NULL THEN
    RETURN NEXT after_ts;
    RETURN;
  END IF;

  year_start := date_trunc('year', after_ts) + (after_ts::time)::interval;
  year_end := year_start + '1 year'::interval - '1 day'::interval;
  days_in_year := date_part('doy', year_end)::INT;

  -- Process each yearday in the array
  FOR i IN 1..366 LOOP
    EXIT WHEN rule.byyearday[i] IS NULL;

    yearday := rule.byyearday[i];

    IF yearday > 0 THEN
      -- Positive index: 1 = Jan 1, 100 = April 9/10, 365/366 = Dec 31
      IF yearday <= days_in_year THEN
        occurrence := year_start + ((yearday - 1)::text || ' days')::interval;
        RETURN NEXT occurrence;
        result_count := result_count + 1;
        EXIT WHEN max_results IS NOT NULL AND result_count >= max_results;
      END IF;
      -- If yearday > days_in_year (e.g., day 366 in non-leap year), skip it

    ELSIF yearday < 0 THEN
      -- Negative index: -1 = Dec 31, -2 = Dec 30, etc.
      -- Convert to positive: -1 in 365-day year = day 365
      IF abs(yearday) <= days_in_year THEN
        occurrence := year_end + ((yearday + 1)::text || ' days')::interval;
        RETURN NEXT occurrence;
        result_count := result_count + 1;
        EXIT WHEN max_results IS NOT NULL AND result_count >= max_results;
      END IF;
      -- If abs(yearday) > days_in_year, skip it

    ELSE
      -- yearday == 0 is invalid per RFC 5545, skip it
      RAISE NOTICE 'Invalid BYYEARDAY value: 0 (must be 1-366 or -1 to -366)';
    END IF;
  END LOOP;

END;
$$ LANGUAGE plpgsql IMMUTABLE;  -- STRICT removed to allow NULL max_results


------------------------------------------------------------------------------------------------------
-- YEARLY frequency helper: Generate dates for specified week numbers
-- Used when BYWEEKNO is the primary generator (not just a filter)
-- Example: FREQ=YEARLY;BYWEEKNO=1,10 generates all dates in weeks 1 and 10
-- Example: FREQ=YEARLY;BYWEEKNO=1;BYDAY=MO,FR generates Mondays and Fridays of week 1
------------------------------------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION rrule_yearly_byweekno_set(
  after_ts TIMESTAMP WITH TIME ZONE,
  rule rrule_parts,
  max_results INT DEFAULT NULL  -- NULL = unlimited, otherwise stop after N results
) RETURNS SETOF TIMESTAMP WITH TIME ZONE AS $$
DECLARE
  year_start TIMESTAMP WITH TIME ZONE;
  week_num INT;
  week_start TIMESTAMP WITH TIME ZONE;
  wkst_num INT;
  first_day_dow INT;
  days_to_first_wkst INT;
  first_wkst TIMESTAMP WITH TIME ZONE;
  occurrence TIMESTAMP WITH TIME ZONE;
  result_count INT := 0;
BEGIN
  -- Maintain STRICT semantics for required parameters
  IF after_ts IS NULL OR rule IS NULL THEN
    RETURN;
  END IF;

  year_start := date_trunc('year', after_ts);
  wkst_num := rrule.weekday_to_number(rule.wkst);
  first_day_dow := date_part('dow', year_start);

  -- Calculate first WKST of the year (this is the start of week 1)
  days_to_first_wkst := (wkst_num - first_day_dow + 7) % 7;
  first_wkst := year_start + (days_to_first_wkst::TEXT || ' days')::INTERVAL;

  -- For each specified week number
  FOREACH week_num IN ARRAY rule.byweekno LOOP
    -- Skip invalid week numbers (must be 1-53)
    IF week_num < 1 OR week_num > 53 THEN
      CONTINUE;
    END IF;

    -- Calculate start of this week
    -- Week 1 starts at first_wkst, week 2 starts 7 days later, etc.
    week_start := first_wkst + (INTERVAL '1 day' * ((week_num - 1) * 7));

    -- Add time component from 'after' to maintain time-of-day
    week_start := date_trunc('day', week_start) + (after_ts::time)::INTERVAL;

    -- Check if this week is still in the same year
    IF date_part('year', week_start) != date_part('year', after_ts) THEN
      -- Week extends into next year, skip it
      CONTINUE;
    END IF;

    IF rule.byday IS NOT NULL THEN
      -- Generate all BYDAY occurrences in this week
      FOR occurrence IN
        SELECT r FROM rrule.rrule_week_byday_set(week_start, rule.byday, rule.wkst, max_results - result_count) r
      LOOP
        -- Only return occurrences that are still in the same year
        IF date_part('year', occurrence) = date_part('year', after_ts) THEN
          RETURN NEXT occurrence;
          result_count := result_count + 1;
          EXIT WHEN max_results IS NOT NULL AND result_count >= max_results;
        END IF;
      END LOOP;
      EXIT WHEN max_results IS NOT NULL AND result_count >= max_results;
    ELSE
      -- No BYDAY specified - return the week start date
      RETURN NEXT week_start;
      result_count := result_count + 1;
      EXIT WHEN max_results IS NOT NULL AND result_count >= max_results;
    END IF;
  END LOOP;

  RETURN;
END;
$$ LANGUAGE plpgsql IMMUTABLE;  -- STRICT removed to allow NULL max_results


------------------------------------------------------------------------------------------------------
-- Return another year's worth of events
------------------------------------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION yearly_set(
  after_ts TIMESTAMP WITH TIME ZONE,
  rule rrule.rrule_parts,
  max_results INT DEFAULT NULL  -- NULL = unlimited, otherwise stop after N results
) RETURNS SETOF TIMESTAMP WITH TIME ZONE AS $$
DECLARE
  current_base TIMESTAMP WITH TIME ZONE;
  curse REFCURSOR;
BEGIN
  -- Maintain STRICT semantics for required parameters
  IF after_ts IS NULL OR rule IS NULL THEN
    RETURN;
  END IF;

  -- Validate: BYMONTH + BYYEARDAY is contradictory and not supported
  -- Example invalid case: "February (month 2) on day 100 of year" is impossible
  IF rule.bymonth IS NOT NULL AND rule.byyearday IS NOT NULL THEN
    RAISE EXCEPTION 'Invalid RRULE: FREQ=YEARLY with both BYMONTH and BYYEARDAY is not supported. BYMONTH specifies a specific month, while BYYEARDAY specifies a day of the year - these constraints are contradictory. Use either BYMONTH or BYYEARDAY, not both. Example valid patterns: FREQ=YEARLY;BYMONTH=2 or FREQ=YEARLY;BYYEARDAY=100';
  END IF;

  -- Determine which generator to use, with BYWEEKNO as filter or generator
  IF rule.bymonth IS NOT NULL THEN
    -- BYMONTH is the primary generator
    -- BYWEEKNO acts as a filter on the generated dates
    OPEN curse SCROLL FOR SELECT r FROM rrule.rrule_yearly_bymonth_set(after_ts, rule, max_results) r;
    FOR current_base IN SELECT d FROM rrule.rrule_bysetpos_filter(curse, rule.bysetpos) d LOOP
      current_base := date_trunc('day', current_base) + (after_ts::time)::interval;
      -- Apply BYWEEKNO filter if specified
      IF rule.byweekno IS NOT NULL THEN
        IF NOT rrule.get_week_number(current_base, rule.wkst) = ANY (rule.byweekno) THEN
          CONTINUE;  -- Skip this date, wrong week number
        END IF;
      END IF;
      RETURN NEXT current_base;
    END LOOP;

  ELSIF rule.byyearday IS NOT NULL THEN
    -- BYYEARDAY is the primary generator
    -- BYWEEKNO acts as a filter on the generated dates
    OPEN curse SCROLL FOR SELECT r FROM rrule.rrule_yearly_byyearday_set(after_ts, rule, max_results) r ORDER BY 1;
    IF rule.byweekno IS NOT NULL THEN
      -- Filter results by week number
      FOR current_base IN SELECT d FROM rrule.rrule_bysetpos_filter(curse, rule.bysetpos) d LOOP
        IF rrule.get_week_number(current_base, rule.wkst) = ANY (rule.byweekno) THEN
          RETURN NEXT current_base;
        END IF;
      END LOOP;
    ELSE
      -- No BYWEEKNO filter needed
      RETURN QUERY SELECT d FROM rrule.rrule_bysetpos_filter(curse, rule.bysetpos) d;
    END IF;

  ELSIF rule.byweekno IS NOT NULL THEN
    -- BYWEEKNO is the primary generator (no BYMONTH or BYYEARDAY)
    -- Example: FREQ=YEARLY;BYWEEKNO=1,10 = All dates in weeks 1 and 10
    -- Example: FREQ=YEARLY;BYWEEKNO=1;BYDAY=MO = All Mondays in week 1
    OPEN curse SCROLL FOR SELECT r FROM rrule.rrule_yearly_byweekno_set(after_ts, rule, max_results) r ORDER BY 1;
    RETURN QUERY SELECT d FROM rrule.rrule_bysetpos_filter(curse, rule.bysetpos) d;

  ELSE
    -- No BYMONTH, BYYEARDAY, or BYWEEKNO - return anniversary of dtstart
    -- Example: FREQ=YEARLY with dtstart=2025-03-15 = March 15 every year
    -- Apply BYDAY filter if specified
    IF rule.byday IS NOT NULL AND NOT substring(to_char(after_ts, 'DY') for 2 from 1) = ANY (rule.byday) THEN
      RETURN;
    END IF;
    RETURN NEXT after_ts;
  END IF;
END;
$$ LANGUAGE plpgsql IMMUTABLE;  -- STRICT removed to allow NULL max_results


------------------------------------------------------------------------------------------------------
-- SUB-DAY FREQUENCY FUNCTIONS (HOURLY, MINUTELY, SECONDLY)
------------------------------------------------------------------------------------------------------
--
-- These frequencies are DISABLED in the standard installation for security reasons.
--
-- WHY DISABLED?
-- - HOURLY: Can generate 8,760 occurrences per year
-- - MINUTELY: Can generate 525,600 occurrences per year
-- - SECONDLY: Can generate 31,536,000 occurrences per year
-- - Risk of denial-of-service in multi-tenant environments
-- - Can exhaust CPU, memory, and database connection pools
--
-- HOW TO ENABLE:
-- Use the alternative installation script which includes sub-day frequency support:
--   cd src
--   psql -d your_database -f install_with_subday.sql
--
-- This will load rrule_subday.sql which defines:
--   - hourly_set() function
--   - minutely_set() function
--   - secondly_set() function
--   - Modified event loop that enables sub-day frequencies
--
-- SECURITY REQUIREMENTS:
-- Before enabling, you MUST:
-- 1. Review security implications in INCLUDING_SUBDAY_OPERATIONS.md
-- 2. Implement application-level validation (COUNT/UNTIL limits)
-- 3. Configure statement_timeout to prevent runaway queries
-- 4. Set up monitoring for long-running queries
-- 5. Test thoroughly in staging environment
--
-- See src/rrule_subday.sql for the complete implementation.
--
------------------------------------------------------------------------------------------------------


------------------------------------------------------------------------------------------------------
-- Combine all of that into something which we can use to generate a series from an arbitrary DTSTART/RRULE
------------------------------------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION rrule_event_instances_range(
  basedate TIMESTAMP WITH TIME ZONE,
  repeatrule TEXT,
  mindate TIMESTAMP WITH TIME ZONE,
  maxdate TIMESTAMP WITH TIME ZONE,
  max_count INT
) RETURNS SETOF TIMESTAMP WITH TIME ZONE AS $$
DECLARE
  loopmax INT;
  loopcount INT;
  base_day TIMESTAMP WITH TIME ZONE;
  current_base TIMESTAMP WITH TIME ZONE;
  current TIMESTAMP WITH TIME ZONE;
  rule rrule.rrule_parts%ROWTYPE;
BEGIN
  loopcount := 0;

  SELECT * INTO rule FROM rrule.parse_rrule_parts(basedate, repeatrule);

  -- Security: Calculate safe iteration limit accounting for sparse BYxxx filters
  -- (e.g., FREQ=DAILY;BYDAY=MO only matches 1/7 days, requiring 20x headroom)
  -- See calculate_safe_iteration_limit() for detailed security rationale.
  loopmax := rrule.calculate_safe_iteration_limit(rule.freq, rule.count, max_count);

  current_base := basedate;
  base_day := date_trunc('day', basedate);
  WHILE loopcount < loopmax AND current_base < maxdate LOOP
    IF rule.freq = 'DAILY' THEN
      FOR current IN SELECT d FROM rrule.daily_set(current_base, rule,
                                                     CASE WHEN rule.bysetpos IS NULL
                                                          THEN loopmax - loopcount
                                                          ELSE NULL END) d WHERE d >= base_day LOOP
          EXIT WHEN rule.until IS NOT NULL AND current > rule.until;
          IF current >= mindate THEN
            RETURN NEXT current;
          END IF;
          loopcount := loopcount + 1;
          EXIT WHEN loopcount >= loopmax;
      END LOOP;
      current_base := current_base + make_interval(days => rule.interval);
    ELSIF rule.freq = 'WEEKLY' THEN
      FOR current IN SELECT w FROM rrule.weekly_set(current_base, rule,
                                                      CASE WHEN rule.bysetpos IS NULL
                                                           THEN loopmax - loopcount
                                                           ELSE NULL END) w WHERE w >= base_day LOOP
        IF rrule.test_byyearday_rule(current, rule.byyearday)
               AND rrule.test_bymonthday_rule(current, rule.bymonthday)
               AND rrule.test_bymonth_rule(current, rule.bymonth)
        THEN
          EXIT WHEN rule.until IS NOT NULL AND current > rule.until;
          IF current >= mindate THEN
            RETURN NEXT current;
          END IF;
          loopcount := loopcount + 1;
          EXIT WHEN loopcount >= loopmax;
        END IF;
      END LOOP;
      current_base := current_base + make_interval(weeks => rule.interval);
    ELSIF rule.freq = 'MONTHLY' THEN
      FOR current IN SELECT m FROM rrule.monthly_set(current_base, rule,
                                                       CASE WHEN rule.bysetpos IS NULL
                                                            THEN loopmax - loopcount
                                                            ELSE NULL END) m WHERE m >= base_day LOOP
          EXIT WHEN rule.until IS NOT NULL AND current > rule.until;
          IF current >= mindate THEN
            RETURN NEXT current;
          END IF;
          loopcount := loopcount + 1;
          EXIT WHEN loopcount >= loopmax;
      END LOOP;
      current_base := current_base + make_interval(months => rule.interval);
    ELSIF rule.freq = 'YEARLY' THEN
      FOR current IN SELECT y FROM rrule.yearly_set(current_base, rule,
                                                      CASE WHEN rule.bysetpos IS NULL
                                                           THEN loopmax - loopcount
                                                           ELSE NULL END) y WHERE y >= base_day LOOP
        EXIT WHEN rule.until IS NOT NULL AND current > rule.until;
        IF current >= mindate THEN
          RETURN NEXT current;
        END IF;
        loopcount := loopcount + 1;
        EXIT WHEN loopcount >= loopmax;
      END LOOP;
      current_base := current_base + make_interval(years => rule.interval);

    -- ⚠️ SUB-DAY FREQUENCIES NOT AVAILABLE IN STANDARD INSTALLATION
    --
    -- HOURLY, MINUTELY, and SECONDLY frequencies are disabled by default due to
    -- security and performance concerns (can generate millions of occurrences).
    --
    -- To enable sub-day frequencies, use the alternative installation:
    --   psql -d your_database -f src/install_with_subday.sql
    --
    -- SECURITY WARNING: Sub-day frequencies pose DoS risks in multi-tenant environments.
    -- See INCLUDING_SUBDAY_OPERATIONS.md for:
    --   - Risk assessment and mitigation strategies
    --   - Required application-level validation (COUNT/UNTIL limits)
    --   - Performance implications and monitoring requirements
    --
    ELSE
      -- Provide helpful error message for sub-day frequencies
      IF rule.freq IN ('HOURLY', 'MINUTELY', 'SECONDLY') THEN
        RAISE EXCEPTION 'Frequency "%" is not supported in standard installation. Sub-day frequencies (HOURLY, MINUTELY, SECONDLY) are disabled by default for security. To enable them, use: psql -d your_database -f src/install_with_subday.sql. See INCLUDING_SUBDAY_OPERATIONS.md for security considerations.', rule.freq;
      ELSE
        RAISE EXCEPTION 'Unsupported frequency: %. Valid values are: DAILY, WEEKLY, MONTHLY, YEARLY. For sub-day frequencies, see INCLUDING_SUBDAY_OPERATIONS.md', rule.freq;
      END IF;
    END IF;
    EXIT WHEN rule.until IS NOT NULL AND current > rule.until;
  END LOOP;
END;
$$ LANGUAGE plpgsql IMMUTABLE STRICT;


------------------------------------------------------------------------------------------------------
-- PUBLIC API FUNCTIONS
--
-- The following functions provide a standard API compatible with rrule.js and python-dateutil
------------------------------------------------------------------------------------------------------

-- Create 'rrule' type as a domain over VARCHAR
DO $$
BEGIN
    -- Check if rrule type exists
    IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'rrule' AND typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'rrule')) THEN
        -- Create domain if type doesn't exist
        CREATE DOMAIN rrule AS VARCHAR;
    END IF;
END $$;


------------------------------------------------------------------------------------------------------
-- CORE API: rrule.all()
--
-- Returns all occurrences matching the RRULE (streaming via SETOF)
-- Matches: rrule.js .all() and python-dateutil iteration
--
-- Parameters:
--   rrule_string: RRULE string (e.g., 'FREQ=DAILY;COUNT=10' or 'FREQ=DAILY;COUNT=10;TZID=America/New_York')
--   dtstart: Start date as naive TIMESTAMP (wall-clock time in the timezone specified by TZID, or UTC if no TZID)
--
-- Returns: SETOF naive TIMESTAMPs (wall-clock times in the same timezone as dtstart)
--
-- TZID Support:
-- - If TZID is specified in rrule_string, dtstart is interpreted as wall-clock time in that timezone
-- - Returned timestamps are wall-clock times in that same timezone
-- - DST transitions are handled automatically by PostgreSQL
-- - If no TZID is specified, treats dtstart as UTC (legacy behavior)
--
-- Implementation notes:
-- - Generates occurrences up to 10 years from dtstart
-- - Returns up to 1000 occurrences by default
-- - Uses SETOF for streaming (memory efficient)
------------------------------------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION "all"(
    rrule_string VARCHAR,
    dtstart TIMESTAMP
)
RETURNS SETOF TIMESTAMP AS $$
DECLARE
    max_count INT;
    dtstart_utc TIMESTAMPTZ;
    maxdate_utc TIMESTAMPTZ;
    tzid TEXT;
BEGIN
    max_count := 1000;

    -- Extract TZID from rrule string
    tzid := substring(rrule_string from 'TZID=([^;]+)(;|$)');

    -- Validate TZID if provided (using centralized validation helper)
    PERFORM rrule.validate_timezone(tzid);

    -- CRITICAL: For TZID support, we generate occurrences in naive TIMESTAMP space
    -- treating it as UTC, then the naive timestamps are interpreted as wall-clock times
    -- in the target timezone. This ensures "10 AM" stays "10 AM" across DST transitions.
    --
    -- Example: FREQ=DAILY with TZID=America/New_York
    --   - Generate: 2025-03-08 10:00, 2025-03-09 10:00, 2025-03-10 10:00 (naive)
    --   - Interpret as: 10 AM EST, 10 AM EDT, 10 AM EDT (wall-clock times)
    --   - NOT: 10 AM EST (15:00 UTC), 11 AM EDT (15:00 UTC) ← wrong!

    dtstart_utc := dtstart AT TIME ZONE 'UTC';
    maxdate_utc := dtstart_utc + INTERVAL '10 years';

    -- Generate occurrences in UTC space (naive timestamps treated as UTC)
    -- Return as SETOF for streaming (memory efficient)
    RETURN QUERY
        SELECT (d AT TIME ZONE 'UTC')::TIMESTAMP
        FROM rrule.rrule_event_instances_range(
            dtstart_utc,
            rrule_string,
            dtstart_utc,
            maxdate_utc,
            max_count
        ) d;
END;
$$ LANGUAGE plpgsql STABLE;


------------------------------------------------------------------------------------------------------
-- CORE API: rrule.between()
--
-- Returns occurrences between two dates (streaming via SETOF)
-- Matches: rrule.js .between() and python-dateutil .between()
------------------------------------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION "between"(
    rrule_string VARCHAR,
    dtstart TIMESTAMP,
    start_date TIMESTAMP,
    end_date TIMESTAMP
)
RETURNS SETOF TIMESTAMP AS $$
DECLARE
    max_count INT;
    dtstart_utc TIMESTAMPTZ;
    start_utc TIMESTAMPTZ;
    end_utc TIMESTAMPTZ;
    tzid TEXT;
BEGIN
    max_count := 1000;

    -- Extract TZID from rrule string
    tzid := substring(rrule_string from 'TZID=([^;]+)(;|$)');

    -- Validate TZID if provided (using centralized validation helper)
    PERFORM rrule.validate_timezone(tzid);

    -- Generate in naive TIMESTAMP space (see all() function for explanation)
    dtstart_utc := dtstart AT TIME ZONE 'UTC';
    start_utc := start_date AT TIME ZONE 'UTC';
    end_utc := end_date AT TIME ZONE 'UTC';

    -- Generate occurrences in UTC space (naive timestamps treated as UTC)
    RETURN QUERY
        SELECT (d AT TIME ZONE 'UTC')::TIMESTAMP
        FROM rrule.rrule_event_instances_range(
            dtstart_utc,
            rrule_string,
            start_utc,
            end_utc,
            max_count
        ) d;
END;
$$ LANGUAGE plpgsql STABLE;


------------------------------------------------------------------------------------------------------
-- CORE API: rrule.after()
--
-- Returns the first occurrence after a specific date
-- Matches: python-dateutil .after()
------------------------------------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION "after"(
    rrule_string VARCHAR,
    dtstart TIMESTAMP,
    after_date TIMESTAMP
)
RETURNS TIMESTAMP AS $$
DECLARE
    next_occurrence TIMESTAMP;
BEGIN
    -- Get next occurrence using all() and filter
    -- TZID handling is done automatically by all()
    SELECT occurrence INTO next_occurrence
    FROM rrule.all(rrule_string, dtstart) AS occurrence
    WHERE occurrence > after_date
    ORDER BY occurrence ASC
    LIMIT 1;

    RETURN next_occurrence;
END;
$$ LANGUAGE plpgsql STABLE;


------------------------------------------------------------------------------------------------------
-- CORE API: rrule.before()
--
-- Returns the last occurrence before a specific date
-- Matches: python-dateutil .before()
------------------------------------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION "before"(
    rrule_string VARCHAR,
    dtstart TIMESTAMP,
    before_date TIMESTAMP
)
RETURNS TIMESTAMP AS $$
DECLARE
    previous_occurrence TIMESTAMP;
BEGIN
    -- Get previous occurrence using all() and filter
    -- TZID handling is done automatically by all()
    SELECT occurrence INTO previous_occurrence
    FROM rrule.all(rrule_string, dtstart) AS occurrence
    WHERE occurrence < before_date
    ORDER BY occurrence DESC
    LIMIT 1;

    RETURN previous_occurrence;
END;
$$ LANGUAGE plpgsql STABLE;


------------------------------------------------------------------------------------------------------
-- CORE API: rrule.count()
--
-- Returns the total number of occurrences
-- Matches: python-dateutil .count()
------------------------------------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION "count"(
    rrule_string VARCHAR,
    dtstart TIMESTAMP
)
RETURNS INTEGER AS $$
DECLARE
    occurrence_count INTEGER;
BEGIN
    SELECT COUNT(*)::INTEGER INTO occurrence_count
    FROM rrule.all(rrule_string, dtstart);

    RETURN occurrence_count;
END;
$$ LANGUAGE plpgsql STABLE;


------------------------------------------------------------------------------------------------------
-- CONVENIENCE: rrule.next()
--
-- Get the next occurrence from NOW (current timestamp)
-- Common use case: "When does this event occur next?"
------------------------------------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION "next"(
    rrule_string VARCHAR,
    dtstart TIMESTAMP
)
RETURNS TIMESTAMP AS $$
BEGIN
    RETURN "after"(rrule_string, dtstart, NOW()::TIMESTAMP);
END;
$$ LANGUAGE plpgsql STABLE;


------------------------------------------------------------------------------------------------------
-- CONVENIENCE: rrule.most_recent()
--
-- Get the most recent occurrence before NOW (current timestamp)
-- Common use case: "When did this event last occur?"
------------------------------------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION "most_recent"(
    rrule_string VARCHAR,
    dtstart TIMESTAMP
)
RETURNS TIMESTAMP AS $$
BEGIN
    RETURN "before"(rrule_string, dtstart, NOW()::TIMESTAMP);
END;
$$ LANGUAGE plpgsql STABLE;


------------------------------------------------------------------------------------------------------
-- ADVANCED: rrule.overlaps()
--
-- Check if a recurring event has ANY occurrences overlapping a date range
-- Useful for calendar queries: "Does this meeting conflict with this date range?"
--
-- This is an optimized version that stops at the first occurrence found
------------------------------------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION "overlaps"(
    dtstart TIMESTAMP WITH TIME ZONE,
    dtend TIMESTAMP WITH TIME ZONE,
    rrule_string TEXT,
    mindate TIMESTAMP WITH TIME ZONE,
    maxdate TIMESTAMP WITH TIME ZONE
)
RETURNS BOOLEAN AS $$
DECLARE
    base_date TIMESTAMP WITH TIME ZONE;
    found_date TIMESTAMP WITH TIME ZONE;
BEGIN
    IF dtstart IS NULL THEN
        RETURN NULL;
    END IF;

    IF dtend IS NULL THEN
        base_date := dtstart;
    ELSE
        base_date := dtend;
    END IF;

    -- Adjust maxdate to account for event duration
    IF maxdate IS NOT NULL THEN
        maxdate := maxdate + (base_date - dtstart);
    ELSE
        maxdate := current_date + '10 years'::interval;
    END IF;

    IF mindate IS NULL THEN
        mindate := current_date - '10 years'::interval;
    END IF;

    IF rrule_string IS NULL THEN
        RETURN (dtstart < maxdate AND base_date >= mindate);
    END IF;

    -- Check if there's at least one occurrence in the range
    SELECT d INTO found_date
    FROM rrule.rrule_event_instances_range(base_date, rrule_string, mindate, maxdate, 60) d
    LIMIT 1;

    RETURN FOUND;

END;
$$ LANGUAGE plpgsql STABLE;

-- ================================================================================================================
-- TIMEZONE-AWARE RRULE API
-- ================================================================================================================
--
-- This file implements a timezone-aware API for generating recurrence rule occurrences.
-- Unlike the base API which works with TIMESTAMP WITH TIME ZONE and can drift across DST boundaries,
-- this API properly preserves wall-clock times during Daylight Saving Time transitions.
--
-- KEY DESIGN:
-- - Public API accepts TIMESTAMPTZ + optional timezone parameter
-- - Internally converts to naive TIMESTAMP in the target timezone
-- - Generates occurrences using naive timestamp arithmetic (preserves wall-clock time)
-- - Converts results back to TIMESTAMPTZ in the target timezone
--
-- DST HANDLING:
-- When adding "1 day" to a TIMESTAMP (naive), PostgreSQL adds calendar days, preserving wall-clock time.
-- When adding "1 day" to TIMESTAMPTZ, PostgreSQL adds 24 hours in UTC, causing drift across DST.
--
-- Example:
--   TIMESTAMP:    '2025-03-08 10:00:00' + '1 day' = '2025-03-09 10:00:00' ✓ Preserves 10 AM
--   TIMESTAMPTZ:  '2025-03-08 10:00 EST' + '1 day' = '2025-03-09 09:00 EDT' ✗ Drifts to 9 AM
--
-- TIMEZONE PRIORITY:
-- 1. Explicit timezone parameter
-- 2. TZID in RRULE string (e.g., "TZID=America/New_York;FREQ=DAILY")
-- 3. UTC fallback
--
-- ================================================================================================================


-- ================================================================================================================
-- INTERNAL FUNCTION: Timezone-aware event instance generation
-- ================================================================================================================
--
-- This is the core generation function that works with naive TIMESTAMP values to preserve wall-clock times.
-- It is almost identical to rrule_event_instances_range() but uses TIMESTAMP instead of TIMESTAMPTZ.
--
-- This function should NOT be called directly by users - use the public API functions below instead.
-- ================================================================================================================

CREATE OR REPLACE FUNCTION rrule.rrule_event_instances_range_tz(
    basedate TIMESTAMP,              -- Naive timestamp (wall-clock time in target timezone)
    repeatrule TEXT,                 -- RRULE string
    mindate TIMESTAMP,               -- Naive timestamp (range start)
    maxdate TIMESTAMP,               -- Naive timestamp (range end)
    max_count INT                    -- Maximum iterations
) RETURNS SETOF TIMESTAMP AS $$
#variable_conflict use_variable
DECLARE
    loopmax INT;
    loopcount INT;
    base_day TIMESTAMP;
    current_base TIMESTAMP;
    current TIMESTAMP;
    rrule rrule.rrule_parts%ROWTYPE;
BEGIN
    loopcount := 0;

    -- Parse the RRULE (note: basedate is converted to TIMESTAMPTZ for parsing, but only for date extraction)
    SELECT * INTO rrule FROM rrule.parse_rrule_parts( basedate::TIMESTAMPTZ, repeatrule );

    -- Security: Calculate safe iteration limit accounting for sparse BYxxx filters
    -- (e.g., FREQ=DAILY;BYDAY=MO only matches 1/7 days, requiring 20x headroom)
    -- See calculate_safe_iteration_limit() for detailed security rationale.
    loopmax := rrule.calculate_safe_iteration_limit(rrule.freq, rrule.count, max_count);

    current_base := basedate;
    base_day := date_trunc('day', basedate);

    WHILE loopcount < loopmax AND current_base < maxdate LOOP
        IF rrule.freq = 'DAILY' THEN
            -- Call the existing daily_set but convert to/from TIMESTAMPTZ for compatibility
            FOR current IN
                SELECT d::TIMESTAMP
                FROM rrule.daily_set(current_base::TIMESTAMPTZ, rrule) d
                WHERE d::TIMESTAMP >= base_day
            LOOP
                EXIT WHEN rrule.until IS NOT NULL AND current::TIMESTAMPTZ > rrule.until;
                IF current >= mindate THEN
                    RETURN NEXT current;
                END IF;
                loopcount := loopcount + 1;
                EXIT WHEN loopcount >= loopmax;
            END LOOP;
            -- KEY FIX: Adding interval to naive TIMESTAMP preserves wall-clock time
            current_base := current_base + make_interval(days => rrule.interval);

        ELSIF rrule.freq = 'WEEKLY' THEN
            FOR current IN
                SELECT w::TIMESTAMP
                FROM rrule.weekly_set(current_base::TIMESTAMPTZ, rrule) w
                WHERE w::TIMESTAMP >= base_day
            LOOP
                -- Apply filters
                IF rrule.test_byyearday_rule(current::TIMESTAMPTZ, rrule.byyearday)
                   AND rrule.test_bymonthday_rule(current::TIMESTAMPTZ, rrule.bymonthday)
                   AND rrule.test_bymonth_rule(current::TIMESTAMPTZ, rrule.bymonth)
                THEN
                    EXIT WHEN rrule.until IS NOT NULL AND current::TIMESTAMPTZ > rrule.until;
                    IF current >= mindate THEN
                        RETURN NEXT current;
                    END IF;
                    loopcount := loopcount + 1;
                    EXIT WHEN loopcount >= loopmax;
                END IF;
            END LOOP;
            current_base := current_base + make_interval(weeks => rrule.interval);

        ELSIF rrule.freq = 'MONTHLY' THEN
            FOR current IN
                SELECT m::TIMESTAMP
                FROM rrule.monthly_set(current_base::TIMESTAMPTZ, rrule) m
                WHERE m::TIMESTAMP >= base_day
            LOOP
                EXIT WHEN rrule.until IS NOT NULL AND current::TIMESTAMPTZ > rrule.until;
                IF current >= mindate THEN
                    RETURN NEXT current;
                END IF;
                loopcount := loopcount + 1;
                EXIT WHEN loopcount >= loopmax;
            END LOOP;
            current_base := current_base + make_interval(months => rrule.interval);

        ELSIF rrule.freq = 'YEARLY' THEN
            FOR current IN
                SELECT y::TIMESTAMP
                FROM rrule.yearly_set(current_base::TIMESTAMPTZ, rrule) y
                WHERE y::TIMESTAMP >= base_day
            LOOP
                EXIT WHEN rrule.until IS NOT NULL AND current::TIMESTAMPTZ > rrule.until;
                IF current >= mindate THEN
                    RETURN NEXT current;
                END IF;
                loopcount := loopcount + 1;
                EXIT WHEN loopcount >= loopmax;
            END LOOP;
            current_base := current_base + make_interval(years => rrule.interval);

        ELSE
            RAISE EXCEPTION 'Unsupported frequency: %', rrule.freq;
        END IF;
    END LOOP;
END;
$$ LANGUAGE plpgsql STABLE SET search_path = rrule, pg_catalog;


-- ================================================================================================================
-- PUBLIC API: all() - Generate all occurrences (with limits)
-- ================================================================================================================
--
-- Returns all occurrences of the recurrence rule, properly handling DST transitions.
--
-- Parameters:
--   rrule_string - The RRULE string (e.g., 'FREQ=DAILY;COUNT=10')
--   dtstart      - The start datetime as TIMESTAMPTZ
--   timezone     - Optional timezone name (e.g., 'America/New_York'). If NULL, uses TZID from RRULE or UTC.
--
-- Timezone Priority:
--   1. Explicit timezone parameter
--   2. TZID in RRULE string
--   3. UTC fallback
--
-- Example:
--   SELECT "all"('FREQ=DAILY;COUNT=3', '2025-03-08 10:00:00-05', 'America/New_York');
--   Returns:
--     2025-03-08 10:00:00-05  (Saturday, EST)
--     2025-03-09 10:00:00-04  (Sunday, EDT - DST spring forward, wall-clock preserved!)
--     2025-03-10 10:00:00-04  (Monday, EDT)
--
-- ================================================================================================================

CREATE OR REPLACE FUNCTION rrule."all"(
    rrule_string TEXT,
    dtstart TIMESTAMPTZ,
    timezone TEXT DEFAULT NULL
) RETURNS SETOF TIMESTAMPTZ AS $$
DECLARE
    tz_name TEXT;
    wall_clock_start TIMESTAMP;
    wall_clock_end TIMESTAMP;
    naive_occurrence TIMESTAMP;
BEGIN
    -- Determine timezone (priority: explicit param > TZID in RRULE > UTC)
    tz_name := COALESCE(
        timezone,
        substring(rrule_string from 'TZID=([^;]+)(;|$)'),
        'UTC'
    );

    -- Validate timezone (using centralized validation helper)
    PERFORM rrule.validate_timezone(tz_name);

    -- Convert TIMESTAMPTZ to wall-clock time in target timezone
    wall_clock_start := dtstart AT TIME ZONE tz_name;

    -- Calculate reasonable end date (10 years from start)
    wall_clock_end := wall_clock_start + INTERVAL '10 years';

    -- Generate occurrences as naive timestamps (preserves wall-clock time)
    FOR naive_occurrence IN
        SELECT * FROM rrule.rrule_event_instances_range_tz(
            wall_clock_start,
            rrule_string,
            wall_clock_start,
            wall_clock_end,
            1000  -- max_count limit
        )
    LOOP
        -- Convert naive timestamp back to TIMESTAMPTZ in target timezone
        RETURN NEXT (naive_occurrence AT TIME ZONE tz_name);
    END LOOP;
END;
$$ LANGUAGE plpgsql STABLE;


-- ================================================================================================================
-- PUBLIC API: between() - Generate occurrences within a date range
-- ================================================================================================================

CREATE OR REPLACE FUNCTION rrule."between"(
    rrule_string TEXT,
    dtstart TIMESTAMPTZ,
    range_start TIMESTAMPTZ,
    range_end TIMESTAMPTZ,
    timezone TEXT DEFAULT NULL
) RETURNS SETOF TIMESTAMPTZ AS $$
DECLARE
    tz_name TEXT;
    wall_clock_start TIMESTAMP;
    wall_clock_range_start TIMESTAMP;
    wall_clock_range_end TIMESTAMP;
    naive_occurrence TIMESTAMP;
BEGIN
    -- Determine timezone
    tz_name := COALESCE(
        timezone,
        substring(rrule_string from 'TZID=([^;]+)(;|$)'),
        'UTC'
    );

    -- Validate timezone (using centralized validation helper)
    PERFORM rrule.validate_timezone(tz_name);

    -- Convert all timestamps to wall-clock time in target timezone
    wall_clock_start := dtstart AT TIME ZONE tz_name;
    wall_clock_range_start := range_start AT TIME ZONE tz_name;
    wall_clock_range_end := range_end AT TIME ZONE tz_name;

    -- Generate occurrences
    FOR naive_occurrence IN
        SELECT * FROM rrule.rrule_event_instances_range_tz(
            wall_clock_start,
            rrule_string,
            wall_clock_range_start,
            wall_clock_range_end,
            1000
        )
    LOOP
        RETURN NEXT (naive_occurrence AT TIME ZONE tz_name);
    END LOOP;
END;
$$ LANGUAGE plpgsql STABLE;


-- ================================================================================================================
-- PUBLIC API: after() - Generate N occurrences after a date
-- ================================================================================================================

CREATE OR REPLACE FUNCTION rrule."after"(
    rrule_string TEXT,
    dtstart TIMESTAMPTZ,
    after_date TIMESTAMPTZ,
    count INT,
    timezone TEXT DEFAULT NULL
) RETURNS SETOF TIMESTAMPTZ AS $$
DECLARE
    tz_name TEXT;
    wall_clock_start TIMESTAMP;
    wall_clock_after TIMESTAMP;
    wall_clock_end TIMESTAMP;
    naive_occurrence TIMESTAMP;
    occurrence_count INT := 0;
BEGIN
    -- Determine timezone
    tz_name := COALESCE(
        timezone,
        substring(rrule_string from 'TZID=([^;]+)(;|$)'),
        'UTC'
    );

    -- Validate timezone (using centralized validation helper)
    PERFORM rrule.validate_timezone(tz_name);

    -- Convert to wall-clock time
    wall_clock_start := dtstart AT TIME ZONE tz_name;
    wall_clock_after := after_date AT TIME ZONE tz_name;
    wall_clock_end := wall_clock_start + INTERVAL '10 years';

    -- Generate occurrences
    FOR naive_occurrence IN
        SELECT * FROM rrule.rrule_event_instances_range_tz(
            wall_clock_start,
            rrule_string,
            wall_clock_after,
            wall_clock_end,
            1000
        )
    LOOP
        RETURN NEXT (naive_occurrence AT TIME ZONE tz_name);
        occurrence_count := occurrence_count + 1;
        EXIT WHEN occurrence_count >= count;
    END LOOP;
END;
$$ LANGUAGE plpgsql STABLE;


-- ================================================================================================================
-- PUBLIC API: before() - Generate N occurrences before a date
-- ================================================================================================================

CREATE OR REPLACE FUNCTION rrule."before"(
    rrule_string TEXT,
    dtstart TIMESTAMPTZ,
    before_date TIMESTAMPTZ,
    count INT,
    timezone TEXT DEFAULT NULL
) RETURNS SETOF TIMESTAMPTZ AS $$
DECLARE
    tz_name TEXT;
    wall_clock_start TIMESTAMP;
    wall_clock_before TIMESTAMP;
    naive_occurrence TIMESTAMP;
    results TIMESTAMPTZ[];
    result TIMESTAMPTZ;
    i INT;
BEGIN
    -- Determine timezone
    tz_name := COALESCE(
        timezone,
        substring(rrule_string from 'TZID=([^;]+)(;|$)'),
        'UTC'
    );

    -- Validate timezone (using centralized validation helper)
    PERFORM rrule.validate_timezone(tz_name);

    -- Convert to wall-clock time
    wall_clock_start := dtstart AT TIME ZONE tz_name;
    wall_clock_before := before_date AT TIME ZONE tz_name;

    -- Generate all occurrences up to before_date and collect them
    results := ARRAY[]::TIMESTAMPTZ[];
    FOR naive_occurrence IN
        SELECT * FROM rrule.rrule_event_instances_range_tz(
            wall_clock_start,
            rrule_string,
            wall_clock_start,
            wall_clock_before,
            1000
        )
    LOOP
        results := array_append(results, naive_occurrence AT TIME ZONE tz_name);
    END LOOP;

    -- Return the last N occurrences (handle NULL array_length when results is empty)
    IF array_length(results, 1) IS NOT NULL THEN
        FOR i IN GREATEST(1, array_length(results, 1) - count + 1) .. array_length(results, 1) LOOP
            RETURN NEXT results[i];
        END LOOP;
    END IF;
END;
$$ LANGUAGE plpgsql STABLE;


-- ================================================================================================================
-- PUBLIC API: count() - Count total occurrences (TIMESTAMPTZ version with timezone support)
-- ================================================================================================================

CREATE OR REPLACE FUNCTION rrule.count(
    rrule_string TEXT,
    dtstart TIMESTAMPTZ,
    timezone TEXT DEFAULT NULL
) RETURNS INTEGER AS $$
DECLARE
    occurrence_count INTEGER;
BEGIN
    -- Leverage the all() function which handles timezone resolution
    SELECT COUNT(*)::INTEGER INTO occurrence_count
    FROM rrule."all"(rrule_string, dtstart, timezone);

    RETURN occurrence_count;
END;
$$ LANGUAGE plpgsql STABLE;


-- ================================================================================================================
-- PUBLIC API: next() - Get next occurrence from NOW (TIMESTAMPTZ version with timezone support)
-- ================================================================================================================

CREATE OR REPLACE FUNCTION rrule.next(
    rrule_string TEXT,
    dtstart TIMESTAMPTZ,
    timezone TEXT DEFAULT NULL
) RETURNS TIMESTAMPTZ AS $$
DECLARE
    tz_name TEXT;
    now_in_tz TIMESTAMPTZ;
BEGIN
    -- Determine timezone (priority: explicit param > TZID in RRULE > UTC)
    tz_name := COALESCE(
        timezone,
        substring(rrule_string from 'TZID=([^;]+)(;|$)'),
        'UTC'
    );

    -- Get current time in the target timezone
    now_in_tz := NOW() AT TIME ZONE tz_name;

    -- Use after() to find the next occurrence
    RETURN (
        SELECT * FROM rrule."after"(rrule_string, dtstart, now_in_tz, 1, timezone)
        LIMIT 1
    );
END;
$$ LANGUAGE plpgsql STABLE;


-- ================================================================================================================
-- PUBLIC API: most_recent() - Get most recent occurrence before NOW (TIMESTAMPTZ version with timezone support)
-- ================================================================================================================

CREATE OR REPLACE FUNCTION rrule.most_recent(
    rrule_string TEXT,
    dtstart TIMESTAMPTZ,
    timezone TEXT DEFAULT NULL
) RETURNS TIMESTAMPTZ AS $$
DECLARE
    tz_name TEXT;
    now_in_tz TIMESTAMPTZ;
BEGIN
    -- Determine timezone (priority: explicit param > TZID in RRULE > UTC)
    tz_name := COALESCE(
        timezone,
        substring(rrule_string from 'TZID=([^;]+)(;|$)'),
        'UTC'
    );

    -- Get current time in the target timezone
    now_in_tz := NOW() AT TIME ZONE tz_name;

    -- Use before() to find the most recent occurrence
    RETURN (
        SELECT * FROM rrule."before"(rrule_string, dtstart, now_in_tz, 1, timezone)
        LIMIT 1
    );
END;
$$ LANGUAGE plpgsql STABLE;


-- ================================================================================================================
-- PUBLIC API: overlaps() - Check if recurring event overlaps date range (add timezone support)
-- ================================================================================================================

CREATE OR REPLACE FUNCTION rrule.overlaps(
    dtstart TIMESTAMPTZ,
    dtend TIMESTAMPTZ,
    rrule_string TEXT,
    mindate TIMESTAMPTZ,
    maxdate TIMESTAMPTZ,
    timezone TEXT DEFAULT NULL
) RETURNS BOOLEAN AS $$
DECLARE
    tz_name TEXT;
    base_date TIMESTAMPTZ;
    found_occurrence TIMESTAMPTZ;
    adjusted_maxdate TIMESTAMPTZ;
    adjusted_mindate TIMESTAMPTZ;
BEGIN
    -- Handle NULL dtstart
    IF dtstart IS NULL THEN
        RETURN NULL;
    END IF;

    -- Determine timezone (priority: explicit param > TZID in RRULE > session timezone)
    tz_name := COALESCE(
        timezone,
        substring(rrule_string from 'TZID=([^;]+)(;|$)'),
        'UTC'
    );

    -- Validate timezone (using centralized validation helper)
    PERFORM rrule.validate_timezone(tz_name);

    -- Determine base date (end time if available, otherwise start time)
    IF dtend IS NULL THEN
        base_date := dtstart;
    ELSE
        base_date := dtend;
    END IF;

    -- Adjust date range to account for event duration
    adjusted_mindate := COALESCE(mindate, CURRENT_TIMESTAMP - INTERVAL '10 years');
    adjusted_maxdate := COALESCE(maxdate, CURRENT_TIMESTAMP + INTERVAL '10 years');

    IF dtend IS NOT NULL THEN
        adjusted_maxdate := adjusted_maxdate + (base_date - dtstart);
    END IF;

    -- If no RRULE, check single event overlap
    IF rrule_string IS NULL THEN
        RETURN (dtstart < adjusted_maxdate AND base_date >= adjusted_mindate);
    END IF;

    -- Check if there's at least one occurrence in the range
    SELECT * INTO found_occurrence
    FROM rrule."between"(rrule_string, base_date, adjusted_mindate, adjusted_maxdate, tz_name)
    LIMIT 1;

    RETURN FOUND;
END;
$$ LANGUAGE plpgsql STABLE;

RESET search_path;
