/**
 * src/push/push-service.ts (N-push, A1) — VAPID-signed Web Push sender that
 * implements `NotifyService`.
 *
 * The manager and server call `notify(session, cls, token?)` on the three
 * proactive transitions (needs-input / done / stuck). This module signs a
 * minimal `PushPayload` (§3.3) with VAPID and fans it out to every stored
 * subscription, pruning ones the push service reports gone (404/410).
 *
 * Byte-shuttle boundary (SP1): the payload carries ONLY a session id, a short
 * cwd-derived label, the notify class, and (needs-input only) the per-decision
 * capability token — never raw terminal output and never a secret (SEC-C5).
 *
 * web-push is injected behind the `PushSender` seam so the signing/transport is
 * fully unit-testable without real VAPID keys or network; the default sender
 * wraps the `web-push` package.
 */

import webpush from 'web-push';
import { basename } from 'node:path';
import type {
  Config,
  NotifyClass,
  NotifyService,
  PushPayload,
  PushSubscriptionRecord,
  Session,
} from '../types.js';
import type { SubscriptionStore } from './subscription-store.js';

/** Fallback VAPID `sub` when VAPID_SUBJECT is unset (web-push requires one). */
const DEFAULT_VAPID_SUBJECT = 'mailto:admin@localhost';
/** TTL (seconds) for low-priority DONE/STUCK pushes — they need not linger long. */
const DEFAULT_TTL_SECONDS = 600;
/** Push service "subscription gone" status codes → prune the subscription. */
const GONE_STATUS = new Set([404, 410]);

/** web-push send options we set (subset of web-push's RequestOptions). */
export interface PushSendOptions {
  TTL?: number;
  urgency?: 'very-low' | 'low' | 'normal' | 'high';
  topic?: string;
}

/** Transport seam over `web-push` (injectable for tests). */
export interface PushSender {
  setVapid(subject: string, publicKey: string, privateKey: string): void;
  /** Resolves on accept; rejects with a `{ statusCode }`-bearing error on failure. */
  send(record: PushSubscriptionRecord, payload: string, options: PushSendOptions): Promise<void>;
}

export interface PushServiceDeps {
  sender?: PushSender;
}

/** Default sender backed by the `web-push` package. */
function createWebPushSender(): PushSender {
  return {
    setVapid(subject, publicKey, privateKey) {
      webpush.setVapidDetails(subject, publicKey, privateKey);
    },
    async send(record, payload, options) {
      await webpush.sendNotification(
        { endpoint: record.endpoint, keys: record.keys },
        payload,
        options,
      );
    },
  };
}

/** Safely read a `statusCode` off an unknown thrown value (web-push WebPushError). */
function statusCodeOf(err: unknown): number | undefined {
  if (err !== null && typeof err === 'object' && 'statusCode' in err) {
    const code = (err as { statusCode: unknown }).statusCode;
    if (typeof code === 'number') return code;
  }
  return undefined;
}

/** A valid Web Push `Topic` header is URL-safe base64, ≤32 chars. A UUID minus
 *  its dashes is exactly 32 chars of [0-9a-f] — collapses same-session pushes
 *  (same topic ⇒ the push service replaces, not stacks; A1-FR7). */
function collapseTopic(sessionId: string): string {
  return sessionId.replace(/-/g, '').replace(/[^A-Za-z0-9_]/g, '').slice(0, 32);
}

function buildPayload(session: Session, cls: NotifyClass, token?: string): PushPayload {
  const label = session.cwd ? basename(session.cwd) : undefined;
  return {
    sessionId: session.meta.id,
    cls,
    ...(label ? { detail: label } : {}),
    ...(cls === 'needs-input' && token ? { token } : {}),
  };
}

function buildSendOptions(session: Session, cls: NotifyClass, cfg: Config): PushSendOptions {
  return {
    urgency: cls === 'done' ? 'low' : 'high',
    topic: collapseTopic(session.meta.id),
    TTL: cls === 'needs-input' ? Math.ceil(cfg.decisionTokenTtlMs / 1000) : DEFAULT_TTL_SECONDS,
  };
}

/**
 * Create the push notification service.
 * @param cfg frozen runtime config (VAPID keys, DND/done toggles)
 * @param store subscription store (read for fan-out, pruned on 404/410)
 * @param deps optional injected sender (defaults to the real web-push sender)
 */
export function createPushService(
  cfg: Config,
  store: SubscriptionStore,
  deps: PushServiceDeps = {},
): NotifyService {
  const sender = deps.sender ?? createWebPushSender();
  const enabled = Boolean(cfg.vapidPublicKey && cfg.vapidPrivateKey);

  if (enabled) {
    sender.setVapid(
      cfg.vapidSubject ?? DEFAULT_VAPID_SUBJECT,
      cfg.vapidPublicKey as string,
      cfg.vapidPrivateKey as string,
    );
  }

  function shouldSend(cls: NotifyClass): boolean {
    if (!enabled) return false;
    if (cfg.notifyDnd) return false;
    if (cls === 'done' && !cfg.notifyDone) return false;
    return true;
  }

  async function sendOne(
    record: PushSubscriptionRecord,
    payload: string,
    options: PushSendOptions,
    dead: string[],
  ): Promise<void> {
    try {
      await sender.send(record, payload, options);
    } catch (err) {
      const code = statusCodeOf(err);
      if (code !== undefined && GONE_STATUS.has(code)) {
        dead.push(record.endpoint);
      } else {
        // Transient failure (network/5xx): keep the subscription, log without secrets.
        console.error(`push-service: send failed (status ${code ?? 'unknown'})`);
      }
    }
  }

  return {
    isEnabled: () => enabled,

    async notify(session: Session, cls: NotifyClass, token?: string): Promise<void> {
      if (!shouldSend(cls)) return;
      const records = store.list();
      if (records.length === 0) return;

      const payload = JSON.stringify(buildPayload(session, cls, token));
      const options = buildSendOptions(session, cls, cfg);
      const dead: string[] = [];

      await Promise.all(records.map((record) => sendOne(record, payload, options, dead)));

      if (dead.length > 0) {
        store.prune(dead);
        await store.persist();
      }
    },
  };
}
