// PushService : notifications Web Push (VAPID). Clés VAPID générées une fois au bootstrap
// et stockées dans `settings` (comme le server_secret de l'auth) ; abonnements liés au token
// d'auth (token_id). Quand une session managée passe en `waiting`, pty-manager appelle notify().
import { randomUUID } from 'node:crypto';
import { createRequire } from 'node:module';
import { type Db, getSetting, setSetting } from '../db/index.js';
import type { SecretBox } from './secret-box.js';
import { recordAudit } from './audit-log.js';

// web-push est publié en CommonJS : on le charge via require (verbatimModuleSyntax + NodeNext),
// typé par l'import type — même pattern que @xterm/headless dans screen-reader.ts.
const require = createRequire(import.meta.url);
const webpush = require('web-push') as typeof import('web-push');

export interface PushSubscriptionInput {
  endpoint: string;
  keys: { p256dh: string; auth: string };
}

/** Charge utile JSON poussée au service worker (cf. packages/web/src/sw.ts). */
export interface PushPayload {
  sessionId: string;
  title: string;
  body: string;
  kind: string | null;
  url: string;
}

interface SubRow {
  id: string;
  endpoint: string;
  p256dh: string;
  auth: string;
}

/** Envoi d'une notif à un abonnement — injectable pour les tests ; défaut = web-push réel. */
export type PushSender = (
  subscription: { endpoint: string; keys: { p256dh: string; auth: string } },
  payload: string,
  options: unknown,
) => Promise<unknown>;

export class PushService {
  private readonly vapidPublic: string;
  private readonly vapidPrivate: string;
  private readonly send: PushSender;

  constructor(
    private readonly db: Db,
    private readonly contact: string = 'mailto:arboretum@localhost',
    sender?: PushSender,
    box?: SecretBox,
  ) {
    this.send = sender ?? ((sub, payload, options) => webpush.sendNotification(sub, payload, options as Parameters<typeof webpush.sendNotification>[2]));
    let pub = getSetting(db, 'vapid_public'); // clé publique : jamais chiffrée (sûre à exposer)
    const storedPriv = getSetting(db, 'vapid_private');
    let priv = storedPriv ? (box ? box.decrypt(storedPriv) : storedPriv) : null;
    if (!pub || !priv) {
      const keys = webpush.generateVAPIDKeys();
      pub = keys.publicKey;
      priv = keys.privateKey;
      setSetting(db, 'vapid_public', pub);
      setSetting(db, 'vapid_private', box ? box.encrypt(priv) : priv);
      recordAudit(db, { actor: 'system', action: 'secret.generate', resourceId: 'vapid' });
    } else if (box && storedPriv && !box.isEncrypted(storedPriv)) {
      // migration douce : clé privée pré-existante en clair → re-chiffrée.
      setSetting(db, 'vapid_private', box.encrypt(priv));
    }
    this.vapidPublic = pub;
    this.vapidPrivate = priv;
  }

  /** Clé publique VAPID — sûre à exposer (applicationServerKey côté navigateur). */
  publicKey(): string {
    return this.vapidPublic;
  }

  /** Enregistre (ou ré-associe) un abonnement, lié au token authentifié. */
  subscribe(tokenId: string, sub: PushSubscriptionInput, userAgent: string | null): void {
    this.db
      .prepare(
        `INSERT INTO push_subscriptions (id, token_id, endpoint, p256dh, auth, user_agent, created_at)
         VALUES (?, ?, ?, ?, ?, ?, ?)
         ON CONFLICT(endpoint) DO UPDATE SET
           token_id = excluded.token_id, p256dh = excluded.p256dh,
           auth = excluded.auth, user_agent = excluded.user_agent`,
      )
      .run(randomUUID(), tokenId, sub.endpoint, sub.keys.p256dh, sub.keys.auth, userAgent, new Date().toISOString());
  }

  unsubscribe(endpoint: string): void {
    this.db.prepare('DELETE FROM push_subscriptions WHERE endpoint = ?').run(endpoint);
  }

  count(): number {
    return (this.db.prepare('SELECT COUNT(*) AS n FROM push_subscriptions').get() as { n: number }).n;
  }

  /**
   * Pousse une notification à TOUS les abonnements (un seul utilisateur, plusieurs appareils).
   * Un abonnement expiré (404/410 Gone) est purgé ; les autres erreurs sont ignorées (best-effort,
   * un appareil injoignable ne doit pas bloquer les autres).
   */
  async notify(payload: PushPayload): Promise<void> {
    const rows = this.db.prepare('SELECT id, endpoint, p256dh, auth FROM push_subscriptions').all() as unknown as SubRow[];
    if (rows.length === 0) return;
    const body = JSON.stringify(payload);
    const options = {
      vapidDetails: { subject: this.contact, publicKey: this.vapidPublic, privateKey: this.vapidPrivate },
      TTL: 60,
    };
    await Promise.all(
      rows.map(async (r) => {
        try {
          await this.send({ endpoint: r.endpoint, keys: { p256dh: r.p256dh, auth: r.auth } }, body, options);
          this.db.prepare('UPDATE push_subscriptions SET last_ok_at = ? WHERE id = ?').run(new Date().toISOString(), r.id);
        } catch (err) {
          const code = (err as { statusCode?: number }).statusCode;
          if (code === 404 || code === 410) this.db.prepare('DELETE FROM push_subscriptions WHERE id = ?').run(r.id);
        }
      }),
    );
  }
}
