// Source: https://orm.drizzle.team/docs/sql-schema-declaration
import {
  mysqlTable,
  mysqlEnum,
  varchar,
  text,
  int,
  date,
  timestamp,
  boolean,
  index,
  unique,
  customType,
} from 'drizzle-orm/mysql-core';

// Custom varchar type with explicit binary collation.
// Drizzle 0.45.x does not expose a first-class collation option on varchar,
// so we use customType to emit `varchar(255) COLLATE utf8mb4_bin`.
// utf8mb4_bin is required for fractional-indexing rank keys: uppercase-prefixed
// ranks (e.g. 'Zz') must sort BEFORE lowercase ranks (e.g. 'a0') — matching JS
// string order — so that drag-to-top persists across a DB ORDER BY rank.
const varcharBin = (name: string) =>
  customType<{ data: string; driverData: string }>({
    dataType: () => 'varchar(255) COLLATE utf8mb4_bin',
  })(name);

// ── Phase 4: List tables ───────────────────────────────────────────────────
// Imported by test/setup.ts for afterEach cleanup — keep exports consistent.

/**
 * Members of the household — identity keyed by oidc_iss + oidc_sub (never email, per D-10).
 * Color auto-assigned from palette on first login (D-06).
 */
export const users = mysqlTable(
  'users',
  {
    id: int().primaryKey().autoincrement(),
    oidcIss: varchar('oidc_iss', { length: 512 }).notNull(),
    oidcSub: varchar('oidc_sub', { length: 256 }).notNull(),
    displayName: varchar('display_name', { length: 256 }),
    color: varchar('color', { length: 7 }).notNull(), // hex e.g. '#4A90D9'
    createdAt: timestamp('created_at').defaultNow().notNull(),
  },
  (t) => [
    // Composite unique key — identity is iss+sub, never email (D-10)
    unique('uniq_oidc_identity').on(t.oidcIss, t.oidcSub),
  ],
);

/**
 * Encrypted Fastmail app-password credentials per member (D-04).
 * Keyed by user_id (oidc_sub → user row). Backend-only, never exposed to frontend.
 * Stored as JSON: { iv, authTag, ciphertext } (AES-256-GCM).
 */
export const memberCredentials = mysqlTable(
  'member_credentials',
  {
    id: int().primaryKey().autoincrement(),
    userId: int('user_id')
      .notNull()
      .references(() => users.id, { onDelete: 'cascade' }),
    // JSON: { iv: string, authTag: string, ciphertext: string }
    encryptedPassword: text('encrypted_password').notNull(),
    fastmailEmail: varchar('fastmail_email', { length: 256 }).notNull(),
    createdAt: timestamp('created_at').defaultNow().notNull(),
    updatedAt: timestamp('updated_at').defaultNow().onUpdateNow(),
  },
  (t) => [index('idx_member_credentials_user_id').on(t.userId)],
);

/**
 * Calendar collections discovered via CalDAV PROPFIND.
 * One row per calendar per member. ctag/syncToken track change state for polling (D-13).
 * isShared: true when this calendar is the shared-family calendar (marked by operator).
 */
export const calendars = mysqlTable(
  'calendars',
  {
    id: int().primaryKey().autoincrement(),
    userId: int('user_id')
      .notNull()
      .references(() => users.id),
    url: varchar('url', { length: 1024 }).notNull(),
    displayName: varchar('display_name', { length: 256 }),
    color: varchar('color', { length: 7 }),
    ctag: varchar('ctag', { length: 512 }),
    syncToken: varchar('sync_token', { length: 1024 }),
    lastSyncedAt: timestamp('last_synced_at'),
    isShared: boolean('is_shared').default(false).notNull(),
  },
  (t) => [
    index('idx_calendars_user_id').on(t.userId),
    // BUG B: calendar identity is (userId, url), not url alone. The two household
    // members share one Fastmail account (D-16), so the SAME collection URL is
    // polled by both credentials. Without this unique key the calendar upsert's
    // onDuplicateKeyUpdate never fired → a new row per poll, and the url-only
    // lookup resolved to the other member's row → events cached under the wrong
    // calendarId. Keying on (userId, url) makes the upsert idempotent per member.
    unique('uniq_calendar_user_url').on(t.userId, t.url),
  ],
);

/**
 * Calendar event cache — raw VEVENT blob + indexed dtstart fields.
 *
 * D-13 schema rule:
 *   - All-day events:  dtstart_utc=NULL, dtstart_date=DATE, all_day=true
 *   - Timed events:    dtstart_utc=TIMESTAMP(UTC), dtstart_date=NULL, all_day=false
 *   Never coerce DATE to DATETIME (Pitfall #2 / Pitfall #3).
 */
export const calendarEvents = mysqlTable(
  'calendar_events',
  {
    id: int().primaryKey().autoincrement(),
    calendarId: int('calendar_id')
      .notNull()
      .references(() => calendars.id, { onDelete: 'cascade' }),
    uid: varchar('uid', { length: 512 }).notNull(), // VEVENT UID — natural idempotency key
    etag: varchar('etag', { length: 256 }),
    objectUrl: varchar('object_url', { length: 1024 }), // CalDAV object URL; populated from obj.url by sync.ts (D-08)
    rawVevent: text('raw_vevent').notNull(), // full VCALENDAR/VEVENT string for ical.js
    // Readable event title extracted from VEVENT SUMMARY by sync.ts (D-02/NOTIF-01).
    // Nullable: populated by Phase 5 sync update; pre-existing rows remain NULL until resynced.
    title: varchar('title', { length: 500 }),
    dtstartUtc: timestamp('dtstart_utc'), // NULL for all-day events
    dtstartDate: date('dtstart_date'), // set for all-day events; NULL for timed
    allDay: boolean('all_day').default(false).notNull(),
    // hasRrule: pre-computed flag for SQL pre-filtering of recurring event masters.
    // Events with hasRrule=true have dtstartUtc potentially years before any window,
    // so the windowed query must include them regardless of dtstartUtc range (see RESEARCH.md §Pitfall 5).
    hasRrule: boolean('has_rrule').default(false).notNull(),
    updatedAt: timestamp('updated_at').defaultNow().onUpdateNow(),
  },
  (t) => [
    index('idx_calendar_events_dtstart_utc').on(t.dtstartUtc),
    index('idx_calendar_events_dtstart_date').on(t.dtstartDate),
    index('idx_calendar_events_has_rrule').on(t.hasRrule),
    // uid is unique per calendar (idempotency key for broker upsert)
    unique('uniq_calendar_uid').on(t.calendarId, t.uid),
  ],
);

/**
 * Transactional outbox for CalDAV write-back (D-05).
 * Pending rows are drained by the outbox worker (broker/outboxWorker.ts).
 * Status machine: pending → done | failed | dead (D-07, D-08).
 *
 * groupId links the delete+create pair for edit-as-move (D-04, Pitfall 5).
 * calendarObjectUrl is null for creates (URL constructed from calendarUrl + uid + '.ics').
 * etag enables If-Match on update/delete to enforce conflict detection (D-08).
 */
export const calendarOutbox = mysqlTable(
  'calendar_outbox',
  {
    id: int().primaryKey().autoincrement(),
    userId: int('user_id')
      .notNull()
      .references(() => users.id, { onDelete: 'cascade' }),
    // 'create' | 'update' | 'delete'
    operation: mysqlEnum(['create', 'update', 'delete']).notNull(),
    // 'pending' | 'done' | 'failed' | 'dead'
    status: mysqlEnum(['pending', 'done', 'failed', 'dead']).notNull().default('pending'),
    uid: varchar('uid', { length: 512 }).notNull(),
    calendarUrl: varchar('calendar_url', { length: 1024 }).notNull(),
    calendarObjectUrl: varchar('calendar_object_url', { length: 1024 }), // null for creates
    etag: varchar('etag', { length: 256 }), // cached etag for If-Match (D-08)
    payload: text('payload'), // icsString for create/update; null for delete
    attemptCount: int('attempt_count').notNull().default(0),
    nextAttemptAt: timestamp('next_attempt_at').defaultNow().notNull(),
    lastError: text('last_error'),
    // groupId links the delete+create pair for edit-as-move (D-04)
    groupId: varchar('group_id', { length: 64 }), // nullable
    createdAt: timestamp('created_at').defaultNow().notNull(),
    updatedAt: timestamp('updated_at').defaultNow().onUpdateNow(),
  },
  (t) => [
    index('idx_outbox_user_status').on(t.userId, t.status),
    index('idx_outbox_next_attempt').on(t.nextAttemptAt, t.status),
    index('idx_outbox_uid').on(t.uid),
  ],
);

/**
 * App-owned named lists — stored in MariaDB, NOT CalDAV (Phase 4).
 *
 * D-01: isShared defaults to true (collaborative household use case).
 * D-02: ownership via ownerId; sharing via listShares join table (member-count-agnostic).
 */
export const lists = mysqlTable(
  'lists',
  {
    id: int().primaryKey().autoincrement(),
    ownerId: int('owner_id')
      .notNull()
      .references(() => users.id, { onDelete: 'cascade' }),
    name: varchar('name', { length: 255 }).notNull(),
    // D-01: default shared — the primary grocery/hub use case is collaborative.
    isShared: boolean('is_shared').default(true).notNull(),
    createdAt: timestamp('created_at').defaultNow().notNull(),
    updatedAt: timestamp('updated_at').defaultNow().onUpdateNow(),
  },
  (t) => [index('idx_lists_owner_id').on(t.ownerId)],
);

/**
 * List sharing join table (D-02, member-count-agnostic).
 *
 * One row per (list, member) pair. v1 UI treats a list as "shared" when any
 * row exists; future UI can offer per-recipient granularity without a migration.
 */
export const listShares = mysqlTable(
  'list_shares',
  {
    id: int().primaryKey().autoincrement(),
    listId: int('list_id')
      .notNull()
      .references(() => lists.id, { onDelete: 'cascade' }),
    userId: int('user_id')
      .notNull()
      .references(() => users.id, { onDelete: 'cascade' }),
    createdAt: timestamp('created_at').defaultNow().notNull(),
  },
  (t) => [
    unique('uniq_list_share').on(t.listId, t.userId),
    index('idx_list_shares_user_id').on(t.userId),
  ],
);

/**
 * Push notification subscriptions (Phase 5 — Web Push).
 *
 * Member-count-agnostic (D-18): one row per browser push subscription endpoint.
 * endpoint is globally unique — a single device endpoint belongs to exactly one user.
 * Cascade delete on user removal keeps subscriptions clean without orphan cleanup jobs.
 */
export const pushSubscriptions = mysqlTable(
  'push_subscriptions',
  {
    id: int().primaryKey().autoincrement(),
    userId: int('user_id')
      .notNull()
      .references(() => users.id, { onDelete: 'cascade' }),
    // CR-02: varchar(2048) matches the Zod max(2048) bound and avoids the InnoDB
    // silent prefix-index truncation that occurs on unbounded text columns.
    // varchar(512) for p256dh matches the Zod max(512) bound.
    endpoint: varchar('endpoint', { length: 2048 }).notNull(),
    p256dh: varchar('p256dh', { length: 512 }).notNull(),
    auth: varchar('auth', { length: 256 }).notNull(),
    createdAt: timestamp('created_at').defaultNow().notNull(),
    updatedAt: timestamp('updated_at').defaultNow().onUpdateNow(),
  },
  (t) => [
    // One endpoint is globally unique — a single device maps to exactly one subscription row
    unique('uniq_push_endpoint').on(t.endpoint),
    index('idx_push_subscriptions_user_id').on(t.userId),
  ],
);

/**
 * Items within a list.
 *
 * D-13: rank uses fractional-indexing strings (e.g. "a0", "a1", "Zz") — a single
 * move rewrites only the moved item's rank (one-row write), which plays well with
 * SSE live sync and concurrent reorders.
 * D-05: checked items render in a "completed" section at the bottom; not removed.
 */
export const listItems = mysqlTable(
  'list_items',
  {
    id: int().primaryKey().autoincrement(),
    listId: int('list_id')
      .notNull()
      .references(() => lists.id, { onDelete: 'cascade' }),
    text: varchar('text', { length: 500 }).notNull(),
    checked: boolean('checked').default(false).notNull(),
    rank: varcharBin('rank').notNull(), // fractional-indexing string (D-13) — COLLATE utf8mb4_bin
    createdAt: timestamp('created_at').defaultNow().notNull(),
    updatedAt: timestamp('updated_at').defaultNow().onUpdateNow(),
  },
  (t) => [
    // Composite index covers ordered list fetch: WHERE list_id=? ORDER BY rank
    index('idx_list_items_list_id_rank').on(t.listId, t.rank),
    // Secondary index for checked/unchecked split queries
    index('idx_list_items_list_id_checked').on(t.listId, t.checked),
  ],
);
