import Database from 'better-sqlite3';
import { randomUUID } from 'crypto';
import * as fs from 'fs';
import * as path from 'path';
import { s as shooterDataDir } from './shooter-home-4f_HkdGI.js';

function isDeviceRecord(v) {
  if (!v || typeof v !== "object" || Array.isArray(v)) {
    return false;
  }
  const r = v;
  const isNullableString = (x) => x === null || typeof x === "string";
  return typeof r.id === "string" && typeof r.token === "string" && (r.platform === "ios" || r.platform === "android") && (r.appEnv === "sandbox" || r.appEnv === "production") && typeof r.registeredAt === "string" && typeof r.lastSeenAt === "string" && typeof r.isActive === "boolean" && Number.isInteger(r.failureCount) && r.failureCount >= 0 && isNullableString(r.bundleId) && isNullableString(r.deviceId) && isNullableString(r.friendlyName);
}
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1e3;
const MAX_TOKEN_LENGTH = 512;
const MAX_DEVICE_ID_LENGTH = 256;
const MAX_NAME_LENGTH = 256;
const MAX_BUNDLE_ID_LENGTH = 256;
class DeviceTokenStore {
  dataDir;
  db;
  constructor(dataDir = shooterDataDir()) {
    this.dataDir = dataDir;
    fs.mkdirSync(dataDir, { recursive: true });
    this.db = new Database(path.join(dataDir, "shooter.db"));
    this.db.pragma("journal_mode = WAL");
    this.db.exec(`
      CREATE TABLE IF NOT EXISTS device_tokens (
        id            TEXT PRIMARY KEY,
        token         TEXT NOT NULL,
        platform      TEXT NOT NULL CHECK(platform IN ('ios', 'android')),
        app_env       TEXT NOT NULL DEFAULT 'sandbox'
                                    CHECK(app_env IN ('sandbox', 'production')),
        device_id     TEXT,
        friendly_name TEXT,
        bundle_id     TEXT,
        registered_at TEXT NOT NULL,
        last_seen_at  TEXT NOT NULL,
        failure_count INTEGER NOT NULL DEFAULT 0,
        is_active     INTEGER NOT NULL DEFAULT 1,
        UNIQUE(token),
        UNIQUE(device_id, platform)
      );
      CREATE INDEX IF NOT EXISTS idx_device_tokens_active
        ON device_tokens(platform, is_active);
    `);
  }
  close() {
    this.db.close();
  }
  /** Soft-delete a device by id (sets is_active = 0; auditable until cleanup). */
  deleteById(id) {
    return this.db.prepare("UPDATE device_tokens SET is_active = 0 WHERE id = ?").run(id).changes;
  }
  getByToken(token) {
    const row = this.db.prepare("SELECT * FROM device_tokens WHERE token = ?").get(token);
    return row ? rowToRecord(row) : null;
  }
  /** All active tokens for a platform, most-recently-seen first. */
  listActive(platform) {
    const rows = this.db.prepare(
      "SELECT * FROM device_tokens WHERE platform = ? AND is_active = 1 ORDER BY last_seen_at DESC"
    ).all(platform);
    return rows.map(rowToRecord);
  }
  /** Active tokens for a platform filtered by APNs gateway env. */
  listActiveForEnv(platform, appEnv) {
    const rows = this.db.prepare(
      `SELECT * FROM device_tokens
         WHERE platform = ? AND app_env = ? AND is_active = 1
         ORDER BY last_seen_at DESC`
    ).all(platform, appEnv);
    return rows.map(rowToRecord);
  }
  /**
   * Import tokens from the legacy device-tokens.json and the setup-wizard
   * device-token-seeds.json, then rename the consumed files. Idempotent: the
   * rename prevents re-import, and INSERT OR IGNORE prevents UNIQUE(token) dups.
   */
  migrate(now = /* @__PURE__ */ new Date()) {
    this.importFile(path.join(this.dataDir, "device-tokens.json"), ".migrated", now);
    this.importFile(path.join(this.dataDir, "device-token-seeds.json"), ".processed", now);
  }
  /** Soft-delete a set of dead tokens (lazy pruning after a failed delivery). */
  pruneByTokens(tokens) {
    if (tokens.length === 0) {
      return 0;
    }
    const placeholders = tokens.map(() => "?").join(", ");
    return this.db.prepare(`UPDATE device_tokens SET is_active = 0 WHERE token IN (${placeholders})`).run(...tokens).changes;
  }
  /**
   * Hard-delete inactive rows whose last_seen_at is older than 30 days.
   * Active rows are NEVER deleted by age — a device that is still on but
   * hasn't re-registered should keep receiving notifications.
   */
  startupCleanup(now = /* @__PURE__ */ new Date()) {
    const cutoff = new Date(now.getTime() - THIRTY_DAYS_MS).toISOString();
    return this.db.prepare("DELETE FROM device_tokens WHERE is_active = 0 AND last_seen_at < ?").run(cutoff).changes;
  }
  /** Bump last_seen_at for tokens that just delivered successfully. */
  touchLastSeen(tokens, now = /* @__PURE__ */ new Date()) {
    if (tokens.length === 0) {
      return 0;
    }
    const placeholders = tokens.map(() => "?").join(", ");
    return this.db.prepare(`UPDATE device_tokens SET last_seen_at = ? WHERE token IN (${placeholders})`).run(now.toISOString(), ...tokens).changes;
  }
  /**
   * Register or refresh a device. Three cases, resolved in one transaction:
   *  1. Known device (deviceId matches an existing row) → rotate token in place.
   *     If the incoming token is currently held by a DIFFERENT row, APNs has
   *     recycled it to us, so that stale row is hard-deleted first — otherwise
   *     the rotation UPDATE would violate UNIQUE(token). (A soft-delete would
   *     NOT free the constraint: the token value stays in the inactive row.)
   *  2. New legacy device (no deviceId) → deactivate prior null-device rows for
   *     the platform (can't tell them apart) then insert/refresh by token.
   *  3. New device (deviceId not yet known) → insert; on token conflict, the
   *     ownership WHERE guard gates the WHOLE update: an unknown device cannot
   *     steal OR clobber the metadata of a token already owned by another active
   *     device (the conflict resolves to a no-op, leaving that row untouched).
   */
  upsert(input, now = /* @__PURE__ */ new Date()) {
    const ts = now.toISOString();
    const appEnv = input.appEnv ?? (process.env.APNS_PRODUCTION === "true" ? "production" : "sandbox");
    const deviceId = input.deviceId ?? null;
    const friendlyName = input.friendlyName ?? null;
    const bundleId = input.bundleId ?? null;
    const { platform } = input;
    const token = input.token.trim();
    if (token.length === 0) {
      throw new Error("DeviceTokenStore.upsert: token must be a non-empty string");
    }
    if (token.length > MAX_TOKEN_LENGTH) {
      throw new Error(`DeviceTokenStore.upsert: token exceeds ${MAX_TOKEN_LENGTH} chars`);
    }
    if (deviceId !== null && deviceId.length > MAX_DEVICE_ID_LENGTH) {
      throw new Error(`DeviceTokenStore.upsert: deviceId exceeds ${MAX_DEVICE_ID_LENGTH} chars`);
    }
    if (friendlyName !== null && friendlyName.length > MAX_NAME_LENGTH) {
      throw new Error(`DeviceTokenStore.upsert: friendlyName exceeds ${MAX_NAME_LENGTH} chars`);
    }
    if (bundleId !== null && bundleId.length > MAX_BUNDLE_ID_LENGTH) {
      throw new Error(`DeviceTokenStore.upsert: bundleId exceeds ${MAX_BUNDLE_ID_LENGTH} chars`);
    }
    this.db.transaction(() => {
      if (deviceId !== null) {
        const ownRow = this.db.prepare("SELECT id FROM device_tokens WHERE device_id = ? AND platform = ?").get(deviceId, platform);
        if (ownRow) {
          this.db.prepare("DELETE FROM device_tokens WHERE token = ? AND id != ?").run(token, ownRow.id);
          this.db.prepare(
            `UPDATE device_tokens SET
                 token         = ?,
                 last_seen_at  = ?,
                 app_env       = ?,
                 failure_count = 0,
                 is_active     = 1,
                 friendly_name = COALESCE(?, friendly_name),
                 bundle_id     = COALESCE(?, bundle_id)
               WHERE id = ?`
          ).run(token, ts, appEnv, friendlyName, bundleId, ownRow.id);
          return;
        }
      } else {
        this.db.prepare(
          `UPDATE device_tokens SET is_active = 0
             WHERE platform = ? AND device_id IS NULL AND token != ? AND is_active = 1`
        ).run(platform, token);
      }
      this.db.prepare(
        `INSERT INTO device_tokens
             (id, token, platform, app_env, device_id, friendly_name, bundle_id,
              registered_at, last_seen_at, failure_count, is_active)
           VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 1)
           ON CONFLICT(token) DO UPDATE SET
             last_seen_at  = excluded.last_seen_at,
             app_env       = excluded.app_env,
             failure_count = 0,
             is_active     = 1,
             friendly_name = COALESCE(excluded.friendly_name, device_tokens.friendly_name),
             bundle_id     = COALESCE(excluded.bundle_id, device_tokens.bundle_id),
             device_id     = excluded.device_id
           WHERE device_tokens.device_id IS NULL
              OR device_tokens.device_id = excluded.device_id
              OR device_tokens.is_active = 0`
      ).run(randomUUID(), token, platform, appEnv, deviceId, friendlyName, bundleId, ts, ts);
    })();
    const rec = this.getByToken(token);
    if (!rec) {
      throw new Error("DeviceTokenStore.upsert: row not found after write");
    }
    return rec;
  }
  importFile(filePath, renameSuffix, now) {
    if (!fs.existsSync(filePath)) {
      return;
    }
    try {
      const raw = JSON.parse(fs.readFileSync(filePath, "utf8"));
      this.importTokenMap(raw, now);
      fs.renameSync(filePath, filePath + renameSuffix);
    } catch (err) {
      console.warn(`[device-token-store] failed to import ${filePath}:`, err);
    }
  }
  importTokenMap(raw, now) {
    if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
      return;
    }
    const obj = raw;
    const ts = now.toISOString();
    const appEnv = process.env.APNS_PRODUCTION === "true" ? "production" : "sandbox";
    const insert = this.db.prepare(
      `INSERT OR IGNORE INTO device_tokens
         (id, token, platform, app_env, device_id, friendly_name, bundle_id,
          registered_at, last_seen_at, failure_count, is_active)
       VALUES (?, ?, ?, ?, NULL, NULL, NULL, ?, ?, 0, 1)`
    );
    for (const platform of ["ios", "android"]) {
      const value = obj[platform];
      const tokens = Array.isArray(value) ? value : typeof value === "string" ? [value] : [];
      for (const tok of tokens) {
        if (typeof tok !== "string") {
          continue;
        }
        const normalized = tok.trim();
        if (normalized.length > 0) {
          insert.run(randomUUID(), normalized, platform, appEnv, ts, ts);
        }
      }
    }
  }
}
function rowToRecord(row) {
  const rec = {
    appEnv: row.app_env,
    bundleId: row.bundle_id ?? null,
    deviceId: row.device_id ?? null,
    failureCount: row.failure_count,
    friendlyName: row.friendly_name ?? null,
    id: row.id,
    isActive: row.is_active === 1,
    lastSeenAt: row.last_seen_at,
    platform: row.platform,
    registeredAt: row.registered_at,
    token: row.token
  };
  if (!isDeviceRecord(rec)) {
    throw new Error(`DeviceTokenStore: row failed DeviceRecord validation (id=${String(row.id)})`);
  }
  return rec;
}
const DTS_GLOBAL_KEY = "__shooter_device_token_store";
const deviceTokenStore = globalThis[DTS_GLOBAL_KEY] || new DeviceTokenStore();
globalThis[DTS_GLOBAL_KEY] = deviceTokenStore;

export { MAX_TOKEN_LENGTH as M, MAX_DEVICE_ID_LENGTH as a, MAX_NAME_LENGTH as b, MAX_BUNDLE_ID_LENGTH as c, deviceTokenStore as d };
//# sourceMappingURL=device-token-store-Ct7aTeR8.js.map
