// Multi-tenant SQLite store — single source of truth for users,
// sessions, magic-link tokens, subscriptions, tenant credits, and
// the library_meta index over /data/history/<userId>/*.json files.
//
// Created only when RECAP_MODE === 'multi'. In single mode this module
// is never imported — `getDb()` would crash trying to require
// better-sqlite3 anyway, but the auth-middleware short-circuits before
// reaching it. Keep all SQLite access funneled through `getDb()` so
// single-mode boots don't touch the native binding at all.
//
// Forward-only schema. No migration framework — every release is one
// `db.exec(SCHEMA_SQL)` at boot. New columns get `ALTER TABLE …`
// statements appended below the original CREATEs and guarded with an
// existence check; new tables just go in fresh. Rollback is
// "checkpoint your /data dir before upgrading."

import path from "path";

let dbInstance = null;

const SCHEMA_SQL = `
-- ── users ──────────────────────────────────────────────────────────────
-- One row per authenticated end-user. The operator-owner is also a row
-- here (is_admin = 1) so per-user library scoping works uniformly.
CREATE TABLE IF NOT EXISTS users (
  id                    TEXT PRIMARY KEY,
  email                 TEXT NOT NULL UNIQUE,
  password_hash         TEXT,
  created_at            INTEGER NOT NULL,
  last_signin_at        INTEGER,
  synthetic_install_id  TEXT NOT NULL UNIQUE,
  keysat_license        TEXT,
  display_name          TEXT,
  is_admin              INTEGER NOT NULL DEFAULT 0,
  -- Core-decoupling: the user's subscription tier ("core" | "pro" | "max").
  -- The Recap Relay is the source of truth (keyed by user-id); this is the
  -- Recaps-side cache used for feature gating, kept in sync by the operator
  -- grant flow (which writes here AND POSTs the relay's /relay/user-tier).
  tier                  TEXT NOT NULL DEFAULT 'core',
  -- Captured at first signup for forensic / abuse-investigation use.
  -- NOT used for auth decisions — just data for the operator to grep
  -- when an abuse pattern shows up in the admin dashboard.
  signup_ip             TEXT,
  signup_user_agent     TEXT,
  -- Daily Digest (opt-in, multi-mode): a daily email of the user's last
  -- ~24h of library recaps. Off by default. last_digest_at is the
  -- ms-epoch watermark of the last send; the scan covers recaps created
  -- AFTER it (dedup), and opt-in stamps it to "now" so the first digest
  -- doesn't dump the whole backlog. NULL = never sent.
  -- digest_unsub_token is a per-user random string for the one-click
  -- unsubscribe link in each digest email (no login needed); minted
  -- lazily on first send.
  digest_enabled        INTEGER NOT NULL DEFAULT 0,
  last_digest_at        INTEGER,
  digest_unsub_token    TEXT
);
CREATE INDEX IF NOT EXISTS idx_users_email     ON users(email);
CREATE INDEX IF NOT EXISTS idx_users_signup_ip ON users(signup_ip);
-- NB: idx_users_unsub_token is created in migrateUserDigestPrefs, not here
-- — SCHEMA_SQL runs before the column migration on existing DBs, so an
-- index over digest_unsub_token here would fail with "no such column".

-- ── sessions ───────────────────────────────────────────────────────────
-- Server-side session store so we can revoke individual sessions from
-- the dashboard. Cookies carry only the random session id.
CREATE TABLE IF NOT EXISTS sessions (
  id            TEXT PRIMARY KEY,
  user_id       TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  created_at    INTEGER NOT NULL,
  expires_at    INTEGER NOT NULL,
  last_used_at  INTEGER,
  user_agent    TEXT,
  ip_address    TEXT
);
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at);

-- ── magic_link_tokens ──────────────────────────────────────────────────
-- Plaintext token only ever exists in the outbound email and the
-- inbound verify URL — what we persist is the SHA-256 hash. Tokens are
-- single-use (used_at NOT NULL = spent) and short-lived (15 min).
CREATE TABLE IF NOT EXISTS magic_link_tokens (
  token_hash  TEXT PRIMARY KEY,
  email       TEXT NOT NULL,
  created_at  INTEGER NOT NULL,
  expires_at  INTEGER NOT NULL,
  used_at     INTEGER,
  intent      TEXT NOT NULL,
  -- Request context for abuse investigation. Captured at /auth/request-link
  -- time, never used for auth decisions — just for the recent-signups admin
  -- view to surface scripted abuse patterns.
  request_ip  TEXT,
  request_ua  TEXT,
  -- Anon trial cookie that was present at /auth/request-link time.
  -- Stored server-side (NOT in the magic-link URL itself — that would
  -- leak it to anyone who saw the email) so that at /auth/verify we
  -- can link the trial → user even when the magic-link click lands
  -- in a different browser / cookie jar than the one that initiated
  -- the request (Safari Private mode + email-app in-app browser is
  -- the canonical case). Server-side binding means the cookie ID
  -- can't be spoofed: an attacker who intercepts the magic link
  -- still can't change which trial gets linked.
  trial_cookie_id TEXT
);
CREATE INDEX IF NOT EXISTS idx_magic_email ON magic_link_tokens(email);
CREATE INDEX IF NOT EXISTS idx_magic_ip    ON magic_link_tokens(request_ip, created_at);

-- ── subscriptions ──────────────────────────────────────────────────────
-- One row per paid period. Multiple rows accumulate as a user renews.
-- We don't try to model "the active subscription" — joins to MAX(started_at)
-- with status='active' do the job and stay honest about history.
CREATE TABLE IF NOT EXISTS subscriptions (
  id                TEXT PRIMARY KEY,
  user_id           TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  tier              TEXT NOT NULL,
  started_at        INTEGER NOT NULL,
  expires_at        INTEGER NOT NULL,
  cancelled_at      INTEGER,
  btcpay_invoice_id TEXT,
  amount_sats       INTEGER,
  status            TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_subs_user ON subscriptions(user_id);

-- ── tenant_credits ─────────────────────────────────────────────────────
-- Per-tenant local credit ledger. Cloud users with their OWN keysat
-- license bill the relay directly (via the license-keyed pool); this
-- table is the source of truth for everyone else — signed-in users on
-- the free / cloud-default tier, and family-share tenants on a self-
-- hosted multi-tenant Recap.
--
-- Two buckets per user:
--   purchased_balance  — a la carte purchases + admin grants + carry-over
--                        from anon trial conversions. PERMANENT — never
--                        wiped or refilled.
--   replenish_balance  — initial signup allowance + periodic refills.
--                        REFILLED to tenant_default_credits on each
--                        anniversary period boundary (period set via
--                        the tenant_credit_replenish_period config).
--                        Leftover replenish credits at the end of a
--                        period are FORFEIT (use-it-or-lose-it).
--
-- Spend order: debit replenish_balance first (it'll refresh anyway),
-- then purchased_balance only when the refillable bucket is empty.
-- last_replenish_at: epoch-ms of the most recent refill, used to compute
-- the next anniversary boundary.
CREATE TABLE IF NOT EXISTS tenant_credits (
  user_id            TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
  purchased_balance  INTEGER NOT NULL DEFAULT 0,
  replenish_balance  INTEGER NOT NULL DEFAULT 0,
  last_replenish_at  INTEGER,
  lifetime_granted   INTEGER NOT NULL DEFAULT 0,
  lifetime_consumed  INTEGER NOT NULL DEFAULT 0
);

-- ── anon_trials ────────────────────────────────────────────────────────
-- Cookie-gated "taste before sign-up" trial. The first time an
-- unauthenticated visitor hits /api/process, we issue a recap_anon_trial
-- cookie (32-byte random), insert a row here with N credits (set by
-- the trial_credits_per_visitor operator config), and let them
-- summarize without signing up. After credits_used >= credits_total,
-- the UI nudges them to sign up for more.
--
-- Trial requests forward the OPERATOR's install_id + license to the
-- relay, so the operator's credit pool is what actually pays for the
-- Gemini call. tenant_credits.balance is irrelevant for trials —
-- the credits_total field on this row is the only gate.
--
-- ip_address rate-limits trial-cookie issuance: trials_per_ip_per_day
-- caps how many fresh trial cookies one IP can mint in 24h. Doesn't
-- stop sophisticated abuse (IP rotation), but raises the floor for
-- scripted laptop attacks and gives the operator a column to grep on.
--
-- converted_to_user_id is set when the trial holder signs up — links
-- the trial summary into their library and lets the operator measure
-- the trial → signup conversion rate.
CREATE TABLE IF NOT EXISTS anon_trials (
  cookie_id             TEXT PRIMARY KEY,
  ip_address            TEXT,
  user_agent            TEXT,
  created_at            INTEGER NOT NULL,
  credits_total         INTEGER NOT NULL,
  credits_used          INTEGER NOT NULL DEFAULT 0,
  last_used_at          INTEGER,
  converted_to_user_id  TEXT REFERENCES users(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_anon_trials_ip      ON anon_trials(ip_address, created_at);
CREATE INDEX IF NOT EXISTS idx_anon_trials_created ON anon_trials(created_at);

-- ── pending_purchases ──────────────────────────────────────────────────
-- Tracks every credit-purchase invoice initiated through Recap so that
-- when the invoice settles (via BTCPay webhook → relay → poll round-
-- trip back to us) we know WHO to credit locally.
--
-- The BTCPay invoice on the relay side credits the OPERATOR's pool —
-- the operator paid for the underlying Gemini/etc capacity at the
-- relay. Recap's local accounting layer (tenant_credits for signed-in
-- users, anon_trials.credits_total for trial cookies) is what gates
-- the actual buyer's spend, so we mark this row applied once the
-- relevant local balance is incremented. applied_at being non-null is
-- the idempotency guard — a poll firing twice doesn't double-credit.
--
-- buyer_type values:
--   "user"  → buyer_id is users.id; credits land in tenant_credits
--   "anon"  → buyer_id is anon_trials.cookie_id; credits land in
--             anon_trials.credits_total. If the cookie has since been
--             converted to a user (anon_trials.converted_to_user_id),
--             credits route to that user's tenant_credits instead.
CREATE TABLE IF NOT EXISTS pending_purchases (
  invoice_id   TEXT PRIMARY KEY,
  buyer_type   TEXT NOT NULL,
  buyer_id     TEXT NOT NULL,
  credits      INTEGER NOT NULL,
  created_at   INTEGER NOT NULL,
  applied_at   INTEGER
);
CREATE INDEX IF NOT EXISTS idx_pending_purchases_buyer ON pending_purchases(buyer_type, buyer_id);
CREATE INDEX IF NOT EXISTS idx_pending_purchases_unapplied ON pending_purchases(applied_at) WHERE applied_at IS NULL;

-- ── pending_signups ────────────────────────────────────────────────────
-- Buyer-creates-account flow: when an anon visitor picks Pro / Max
-- from the tier signup modal, they enter an email and pay BTCPay
-- BEFORE any user account exists. We record the (invoice_id, email,
-- policy_slug) here so the poll-settle handler can create the user +
-- attach the issued license + send a magic-link email once payment
-- lands. applied_at is the idempotency guard — multiple polls after
-- settle don't double-create the user.
--
-- Distinct from pending_purchases (credit-pack buys) because the
-- settle effects are completely different: pending_signups creates
-- a USER and sends an email; pending_purchases just credits an
-- existing buyer's local balance.
CREATE TABLE IF NOT EXISTS pending_signups (
  invoice_id   TEXT PRIMARY KEY,
  email        TEXT NOT NULL,
  policy_slug  TEXT NOT NULL,
  created_at   INTEGER NOT NULL,
  applied_at   INTEGER
);
CREATE INDEX IF NOT EXISTS idx_pending_signups_email ON pending_signups(email);
CREATE INDEX IF NOT EXISTS idx_pending_signups_unapplied ON pending_signups(applied_at) WHERE applied_at IS NULL;

-- ── subscription_reminders ─────────────────────────────────────────────
-- Dedup ledger for the self-serve expiry-reminder emails. The relay owns
-- the subscription expiry; a daily Recaps scan asks it who's expiring and
-- emails them. This table guarantees each (user, period, kind) email goes
-- out at most once. period_expires_at is the ISO expiry instant the
-- reminder is for — when the user renews, expiry changes, so a fresh set
-- of reminders re-arms for the new period without re-sending old ones.
-- kind is one of 'upcoming_7d', 'upcoming_1d', or 'lapsed'.
CREATE TABLE IF NOT EXISTS subscription_reminders (
  user_id           TEXT NOT NULL,
  period_expires_at TEXT NOT NULL,
  kind              TEXT NOT NULL,
  sent_at           INTEGER NOT NULL,
  PRIMARY KEY (user_id, period_expires_at, kind)
);

-- ── library_meta ───────────────────────────────────────────────────────
-- Index over /data/history/<userId>/<sessionId>.json. The summary
-- content stays on disk; this table is just for fast listing without
-- scanning the filesystem.
CREATE TABLE IF NOT EXISTS library_meta (
  session_id     TEXT PRIMARY KEY,
  user_id        TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  video_id       TEXT,
  url            TEXT,
  title          TEXT,
  type           TEXT,
  topic_count    INTEGER,
  segment_count  INTEGER,
  created_at     INTEGER NOT NULL,
  upload_date    TEXT
);
CREATE INDEX IF NOT EXISTS idx_library_user ON library_meta(user_id, created_at DESC);
`;

// initDb({ dataDir })
// Idempotent. Opens /data/recap.db, applies the schema, returns the
// connection. Safe to call multiple times — repeat calls return the
// existing handle.
export async function initDb({ dataDir }) {
  if (dbInstance) return dbInstance;

  // Lazy import so single-mode never loads the native binding.
  const { default: Database } = await import("better-sqlite3");

  const dbPath = path.join(dataDir, "recap.db");
  const db = new Database(dbPath);

  // WAL mode for the obvious reasons: concurrent reads while a write
  // is in flight, and durable enough for our small write volume
  // (signups, sessions, library inserts). `synchronous = NORMAL` is
  // the standard pairing — fsync on checkpoint, not every commit.
  db.pragma("journal_mode = WAL");
  db.pragma("synchronous = NORMAL");
  db.pragma("foreign_keys = ON");

  db.exec(SCHEMA_SQL);

  // ── In-place schema migrations ──────────────────────────────────────
  // SCHEMA_SQL above is the FRESH-INSTALL schema. Existing installs
  // may have an older shape (e.g. tenant_credits with the legacy
  // `balance` column). We bring them up to current by introspecting
  // PRAGMA table_info and ALTER-ing only where needed. Each migration
  // is idempotent — running boot multiple times is safe.
  migrateTenantCreditsSchema(db);
  migrateMagicLinkTokensTrialCookie(db);
  migrateUsersTier(db);
  migrateUserDigestPrefs(db);

  dbInstance = db;
  console.log(`[db] opened ${dbPath} (multi-tenant store)`);
  return db;
}

// Core-decoupling — add users.tier to existing DBs (fresh installs get it
// from SCHEMA_SQL). Idempotent: ALTERs only when the column is missing.
function migrateUsersTier(db) {
  let cols;
  try {
    cols = db.prepare("PRAGMA table_info(users)").all();
  } catch {
    return;
  }
  if (!cols.some((c) => c.name === "tier")) {
    db.exec("ALTER TABLE users ADD COLUMN tier TEXT NOT NULL DEFAULT 'core'");
    console.log("[db] added users.tier column (core-decoupling)");
  }
}

// Daily Digest — add the opt-in columns to existing DBs (fresh installs get
// them from SCHEMA_SQL). Idempotent: ALTERs only the columns still missing.
function migrateUserDigestPrefs(db) {
  let cols;
  try {
    cols = db.prepare("PRAGMA table_info(users)").all();
  } catch {
    return;
  }
  if (!cols.some((c) => c.name === "digest_enabled")) {
    db.exec("ALTER TABLE users ADD COLUMN digest_enabled INTEGER NOT NULL DEFAULT 0");
    console.log("[db] added users.digest_enabled column (daily-digest)");
  }
  if (!cols.some((c) => c.name === "last_digest_at")) {
    db.exec("ALTER TABLE users ADD COLUMN last_digest_at INTEGER");
    console.log("[db] added users.last_digest_at column (daily-digest)");
  }
  if (!cols.some((c) => c.name === "digest_unsub_token")) {
    db.exec("ALTER TABLE users ADD COLUMN digest_unsub_token TEXT");
    console.log("[db] added users.digest_unsub_token column (daily-digest)");
  }
  // Created here (not in SCHEMA_SQL) so it runs AFTER the column exists on
  // both fresh and migrated DBs. Idempotent. Keeps the public unsubscribe
  // token lookup off a full-table scan.
  db.exec(
    "CREATE INDEX IF NOT EXISTS idx_users_unsub_token ON users(digest_unsub_token)",
  );
}

// v0.2.92 — split the single tenant_credits.balance into two buckets
// (purchased + replenish) so we can refill the latter periodically
// without wiping the former.
function migrateTenantCreditsSchema(db) {
  let cols;
  try {
    cols = db.prepare("PRAGMA table_info(tenant_credits)").all();
  } catch {
    return; // table doesn't exist yet (shouldn't happen post-SCHEMA_SQL)
  }
  const colNames = new Set(cols.map((c) => c.name));

  // 1. Rename legacy `balance` → `purchased_balance`. Existing balances
  //    were a mix of signup-grant + admin-grant + purchase; treating
  //    them all as "purchased" (permanent) is the safe interpretation
  //    — we'd rather over-preserve than wipe credits on upgrade.
  if (colNames.has("balance") && !colNames.has("purchased_balance")) {
    db.exec(
      "ALTER TABLE tenant_credits RENAME COLUMN balance TO purchased_balance",
    );
    console.log(
      "[db] migrated tenant_credits.balance → tenant_credits.purchased_balance",
    );
    colNames.delete("balance");
    colNames.add("purchased_balance");
  }

  if (!colNames.has("replenish_balance")) {
    db.exec(
      "ALTER TABLE tenant_credits ADD COLUMN replenish_balance INTEGER NOT NULL DEFAULT 0",
    );
    console.log("[db] added tenant_credits.replenish_balance");
  }
  if (!colNames.has("last_replenish_at")) {
    db.exec(
      "ALTER TABLE tenant_credits ADD COLUMN last_replenish_at INTEGER",
    );
    console.log("[db] added tenant_credits.last_replenish_at");
  }
}

// v0.2.104 — add trial_cookie_id to magic_link_tokens so cross-cookie-
// jar magic-link clicks (Safari Private → Gmail webview, etc.) still
// link the anon trial to the new user at /auth/verify time. Existing
// installs get the column added in-place; pre-existing rows just keep
// trial_cookie_id = NULL (no linking via the new path, falls back to
// the legacy req.cookies path).
function migrateMagicLinkTokensTrialCookie(db) {
  let cols;
  try {
    cols = db.prepare("PRAGMA table_info(magic_link_tokens)").all();
  } catch {
    return;
  }
  const colNames = new Set(cols.map((c) => c.name));
  if (!colNames.has("trial_cookie_id")) {
    db.exec(
      "ALTER TABLE magic_link_tokens ADD COLUMN trial_cookie_id TEXT",
    );
    console.log("[db] added magic_link_tokens.trial_cookie_id");
  }
}


// Returns the open handle. Throws if initDb hasn't run — that's a
// programming error (some single-mode caller reached a multi-mode
// codepath). Callers in multi-mode should assume the handle exists.
export function getDb() {
  if (!dbInstance) {
    throw new Error(
      "[db] getDb() called before initDb(); check RECAP_MODE wiring",
    );
  }
  return dbInstance;
}

// Test/teardown helper. Closes the connection so the next initDb()
// call reopens fresh. Not used in production.
export function closeDb() {
  if (dbInstance) {
    dbInstance.close();
    dbInstance = null;
  }
}
