-- t-paliad-212 — Slice 1 of the CalDAV multi-calendar design (see
-- docs/design-caldav-multi-calendar-2026-05-19.md). Pure schema +
-- backfill; the sync engine is NOT touched in this migration. Slice 2
-- wires the per-binding fan-out.
--
-- What we add:
--   1. paliad.user_calendar_bindings — N bindings per user, each with
--      a scope_kind enum (all_visible / personal_only / project /
--      client / litigation / patent / case) and an optional scope_id
--      pointing at a paliad.projects row when the scope is hierarchy-
--      anchored. The same Appointment can be PUT into multiple of
--      these bindings (e.g. master cal + per-project cal).
--   2. paliad.appointment_caldav_targets — (appointment_id, binding_id)
--      join carrying the per-target caldav_uid + caldav_etag. The
--      canonical UID is still per-appointment (paliad-appointment-
--      <uuid>@paliad.de) so the same event in N cals shares one UID.
--   3. Backfill: one all_visible binding per existing
--      user_caldav_config row, plus one target row per Appointment
--      already pushed (caldav_uid IS NOT NULL). Backfill maps the
--      target's binding_id to the appointment creator's binding —
--      that matches today's Phase F semantics, where the creator's
--      sync goroutine owns the etag.
--
-- The scalar columns paliad.appointments.caldav_uid / caldav_etag
-- STAY in place through Slice 1 and Slice 2. Slice 1 keeps them as
-- read-once denormalised pointers to the default binding's target
-- row; Slice 4 drops them after telemetry confirms no path still
-- reads them.
--
-- Idempotent: every CREATE uses IF NOT EXISTS, both backfills are
-- guarded by NOT EXISTS. Safe to re-run.
--
-- audit_reason set_config required at the top because m's recent
-- migration friction had several mig failures from missing reasons.
-- The trigger raising 'audit reason required' is on
-- paliad.deadline_rules only — this migration doesn't touch that
-- table — but we set the reason for symmetry per paliadin's 2026-05-19
-- coder-shift brief.

SELECT set_config(
    'paliad.audit_reason',
    'mig 101: CalDAV multi-calendar schema + backfill (Slice 1 of t-paliad-212; design doc docs/design-caldav-multi-calendar-2026-05-19.md). No row mutations on existing trigger-guarded tables; this is a defensive symmetry set_config.',
    true);

-- =========================================================================
-- 1. paliad.user_calendar_bindings
-- =========================================================================

CREATE TABLE IF NOT EXISTS paliad.user_calendar_bindings (
    id               uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id          uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,

    -- Full URL or path under user_caldav_config.url. The CalDAV client
    -- resolves it against the user's server URL the same way it
    -- resolves the legacy user_caldav_config.calendar_path today.
    calendar_path    text NOT NULL,

    -- What the picker UI shows for this binding. Discovered via
    -- PROPFIND <displayname/> at add-time and cached here so we don't
    -- re-fetch every render. Default '' (Slice 1 backfill leaves it
    -- empty; Slice 2 fills it during the picker flow).
    display_name     text NOT NULL DEFAULT '',

    -- Which appointments push into this calendar. Slice 1 only really
    -- needs 'all_visible' (that's all the backfill creates) but we
    -- ship the full enum now so the schema is final and Slice 2/3
    -- don't have to ALTER it.
    scope_kind       text NOT NULL,
    scope_id         uuid REFERENCES paliad.projects(id) ON DELETE CASCADE,

    -- Only meaningful when scope_kind is hierarchy-anchored
    -- (project / client / litigation / patent / case). When true,
    -- the binding ALSO receives the user's personal (project_id IS
    -- NULL AND created_by = user_id) appointments. Ignored for
    -- 'all_visible' (already includes them) and 'personal_only'.
    include_personal boolean NOT NULL DEFAULT false,

    enabled          boolean NOT NULL DEFAULT true,
    last_sync_at     timestamptz,
    last_sync_error  text,

    created_at       timestamptz NOT NULL DEFAULT now(),
    updated_at       timestamptz NOT NULL DEFAULT now(),

    CONSTRAINT user_calendar_bindings_scope_kind_chk CHECK (
        scope_kind IN ('all_visible','personal_only','project','client','litigation','patent','case')
    ),
    CONSTRAINT user_calendar_bindings_scope_id_chk CHECK (
        (scope_kind IN ('all_visible','personal_only') AND scope_id IS NULL)
        OR
        (scope_kind NOT IN ('all_visible','personal_only') AND scope_id IS NOT NULL)
    )
);

-- One binding per (user, calendar) — can't bind the same external
-- calendar twice for the same user.
CREATE UNIQUE INDEX IF NOT EXISTS user_calendar_bindings_user_path_uniq
    ON paliad.user_calendar_bindings (user_id, calendar_path);

-- One hierarchy binding per (user, scope_kind, scope_id) — a user
-- can't have two bindings for the same project, but CAN have a
-- 'project' binding for project X alongside an 'all_visible'
-- master binding (different scope_kind ⇒ different row).
CREATE UNIQUE INDEX IF NOT EXISTS user_calendar_bindings_scope_hier_uniq
    ON paliad.user_calendar_bindings (user_id, scope_kind, scope_id)
    WHERE scope_id IS NOT NULL;

-- One scope-less binding per (user, scope_kind) — at most one
-- 'all_visible' and one 'personal_only' per user.
CREATE UNIQUE INDEX IF NOT EXISTS user_calendar_bindings_scope_root_uniq
    ON paliad.user_calendar_bindings (user_id, scope_kind)
    WHERE scope_id IS NULL;

CREATE INDEX IF NOT EXISTS user_calendar_bindings_user_idx
    ON paliad.user_calendar_bindings (user_id)
    WHERE enabled;

-- No updated_at trigger — paliad.user_caldav_config also doesn't have
-- one. The Go service layer sets updated_at = NOW() explicitly on
-- every write (see SaveConfig in caldav_service.go); we follow the
-- same convention here so all CalDAV-related tables are consistent.

ALTER TABLE paliad.user_calendar_bindings ENABLE ROW LEVEL SECURITY;

-- Same shape as user_caldav_config policies: a user sees + mutates
-- only their own rows. auth.uid() returns the authenticated user's
-- id (mirrors auth.uid()).
DROP POLICY IF EXISTS user_calendar_bindings_self_select ON paliad.user_calendar_bindings;
CREATE POLICY user_calendar_bindings_self_select ON paliad.user_calendar_bindings
    FOR SELECT TO authenticated
    USING (user_id = auth.uid());

DROP POLICY IF EXISTS user_calendar_bindings_self_insert ON paliad.user_calendar_bindings;
CREATE POLICY user_calendar_bindings_self_insert ON paliad.user_calendar_bindings
    FOR INSERT TO authenticated
    WITH CHECK (user_id = auth.uid());

DROP POLICY IF EXISTS user_calendar_bindings_self_update ON paliad.user_calendar_bindings;
CREATE POLICY user_calendar_bindings_self_update ON paliad.user_calendar_bindings
    FOR UPDATE TO authenticated
    USING (user_id = auth.uid())
    WITH CHECK (user_id = auth.uid());

DROP POLICY IF EXISTS user_calendar_bindings_self_delete ON paliad.user_calendar_bindings;
CREATE POLICY user_calendar_bindings_self_delete ON paliad.user_calendar_bindings
    FOR DELETE TO authenticated
    USING (user_id = auth.uid());


-- =========================================================================
-- 2. paliad.appointment_caldav_targets
-- =========================================================================

CREATE TABLE IF NOT EXISTS paliad.appointment_caldav_targets (
    appointment_id  uuid NOT NULL REFERENCES paliad.appointments(id) ON DELETE CASCADE,
    binding_id      uuid NOT NULL REFERENCES paliad.user_calendar_bindings(id) ON DELETE CASCADE,

    -- 'paliad-appointment-<uuid>@paliad.de' — derived from
    -- appointment_id, identical across all bindings of one appointment.
    caldav_uid      text NOT NULL,

    -- ETag returned by the CalDAV server on the last successful PUT.
    -- NULLABLE to match the legacy paliad.appointments.caldav_etag
    -- column: some servers don't return ETag on PUT and we
    -- re-PROPFIND lazily on next tick.
    caldav_etag     text,

    last_pushed_at  timestamptz NOT NULL DEFAULT now(),

    PRIMARY KEY (appointment_id, binding_id)
);

CREATE INDEX IF NOT EXISTS appointment_caldav_targets_binding_idx
    ON paliad.appointment_caldav_targets (binding_id);

CREATE INDEX IF NOT EXISTS appointment_caldav_targets_uid_idx
    ON paliad.appointment_caldav_targets (caldav_uid);

ALTER TABLE paliad.appointment_caldav_targets ENABLE ROW LEVEL SECURITY;

-- A target row is visible/mutable to the user who owns the binding.
-- Appointment-side visibility is enforced separately by AppointmentService;
-- the target is a sync-state row, scoped per-user.
DROP POLICY IF EXISTS appointment_caldav_targets_self_select ON paliad.appointment_caldav_targets;
CREATE POLICY appointment_caldav_targets_self_select ON paliad.appointment_caldav_targets
    FOR SELECT TO authenticated
    USING (EXISTS (
        SELECT 1 FROM paliad.user_calendar_bindings b
         WHERE b.id = appointment_caldav_targets.binding_id
           AND b.user_id = auth.uid()
    ));

DROP POLICY IF EXISTS appointment_caldav_targets_self_insert ON paliad.appointment_caldav_targets;
CREATE POLICY appointment_caldav_targets_self_insert ON paliad.appointment_caldav_targets
    FOR INSERT TO authenticated
    WITH CHECK (EXISTS (
        SELECT 1 FROM paliad.user_calendar_bindings b
         WHERE b.id = appointment_caldav_targets.binding_id
           AND b.user_id = auth.uid()
    ));

DROP POLICY IF EXISTS appointment_caldav_targets_self_update ON paliad.appointment_caldav_targets;
CREATE POLICY appointment_caldav_targets_self_update ON paliad.appointment_caldav_targets
    FOR UPDATE TO authenticated
    USING (EXISTS (
        SELECT 1 FROM paliad.user_calendar_bindings b
         WHERE b.id = appointment_caldav_targets.binding_id
           AND b.user_id = auth.uid()
    ))
    WITH CHECK (EXISTS (
        SELECT 1 FROM paliad.user_calendar_bindings b
         WHERE b.id = appointment_caldav_targets.binding_id
           AND b.user_id = auth.uid()
    ));

DROP POLICY IF EXISTS appointment_caldav_targets_self_delete ON paliad.appointment_caldav_targets;
CREATE POLICY appointment_caldav_targets_self_delete ON paliad.appointment_caldav_targets
    FOR DELETE TO authenticated
    USING (EXISTS (
        SELECT 1 FROM paliad.user_calendar_bindings b
         WHERE b.id = appointment_caldav_targets.binding_id
           AND b.user_id = auth.uid()
    ));


-- =========================================================================
-- 3. Backfill — one all_visible binding per existing CalDAV-configured user
-- =========================================================================

-- For every paliad.user_caldav_config row, insert an 'all_visible'
-- binding that mirrors today's single-target Phase F push. The new
-- binding inherits the legacy `calendar_path` (or, when that's empty,
-- the server URL itself — same fallback the client uses today). The
-- enabled flag carries over.
--
-- Idempotent: skipped when this user already has an all_visible binding
-- (re-running the migration is a no-op).
INSERT INTO paliad.user_calendar_bindings
    (user_id, calendar_path, display_name, scope_kind, scope_id, include_personal, enabled)
SELECT
    c.user_id,
    COALESCE(NULLIF(c.calendar_path, ''), c.url),
    '',
    'all_visible',
    NULL,
    false,
    c.enabled
FROM paliad.user_caldav_config c
WHERE NOT EXISTS (
    SELECT 1 FROM paliad.user_calendar_bindings b
     WHERE b.user_id = c.user_id
       AND b.scope_kind = 'all_visible'
);


-- =========================================================================
-- 4. Backfill — one target row per already-pushed appointment
-- =========================================================================

-- For every appointment with a non-null caldav_uid, insert one target
-- row pointing at the appointment creator's new all_visible binding.
-- That preserves the (appointment, calendar) sync state exactly as it
-- existed before this migration.
--
-- Why created_by, not "every visible user": today's Phase F
-- caldav_uid/caldav_etag scalars on appointments are populated by
-- whoever happened to push last; in practice the etag almost always
-- belongs to the creator's calendar because pull-side updates only
-- run when CreatedBy = userID (caldav_service.go:449). Mapping the
-- backfill target to the creator's binding keeps the etag pointing
-- where it actually came from. Other users' goroutines will create
-- their own target rows on their next sync tick after Slice 2 ships.
--
-- Idempotent: skipped when (appointment_id, binding_id) target already
-- exists.
INSERT INTO paliad.appointment_caldav_targets
    (appointment_id, binding_id, caldav_uid, caldav_etag, last_pushed_at)
SELECT
    a.id,
    b.id,
    a.caldav_uid,
    a.caldav_etag,
    a.updated_at
FROM paliad.appointments a
JOIN paliad.user_calendar_bindings b
     ON b.user_id = a.created_by
    AND b.scope_kind = 'all_visible'
WHERE a.caldav_uid IS NOT NULL
  AND a.created_by IS NOT NULL
  AND NOT EXISTS (
      SELECT 1 FROM paliad.appointment_caldav_targets t
       WHERE t.appointment_id = a.id
         AND t.binding_id = b.id
  );


-- =========================================================================
-- 5. Assertions — hard fail if the backfill didn't catch every row
-- =========================================================================

-- Every paliad.user_caldav_config row must have at least one
-- all_visible binding after this migration. If it doesn't, either a
-- row was inserted between the backfill and the assertion (race —
-- run is wrapped in a transaction by golang-migrate, so this can't
-- happen) or the backfill is buggy. Hard fail either way.
DO $$
DECLARE
    missing_users int;
BEGIN
    SELECT count(*) INTO missing_users
      FROM paliad.user_caldav_config c
     WHERE NOT EXISTS (
         SELECT 1 FROM paliad.user_calendar_bindings b
          WHERE b.user_id = c.user_id
            AND b.scope_kind = 'all_visible'
     );
    IF missing_users > 0 THEN
        RAISE EXCEPTION
            'mig 101 assertion failed: % paliad.user_caldav_config row(s) without an all_visible binding',
            missing_users;
    END IF;
END $$;

-- Every appointment with a non-null caldav_uid AND a non-null
-- created_by must have a target row pointing at its creator's
-- all_visible binding. created_by can be NULL on legacy rows
-- (e.g. seed data) so we exclude those from the assertion.
DO $$
DECLARE
    missing_targets int;
BEGIN
    SELECT count(*) INTO missing_targets
      FROM paliad.appointments a
     WHERE a.caldav_uid IS NOT NULL
       AND a.created_by IS NOT NULL
       AND NOT EXISTS (
           SELECT 1
             FROM paliad.appointment_caldav_targets t
             JOIN paliad.user_calendar_bindings b
                  ON b.id = t.binding_id
            WHERE t.appointment_id = a.id
              AND b.user_id = a.created_by
              AND b.scope_kind = 'all_visible'
       );
    IF missing_targets > 0 THEN
        RAISE EXCEPTION
            'mig 101 assertion failed: % appointment(s) with caldav_uid but no all_visible target row',
            missing_targets;
    END IF;
END $$;
