import PQueue from 'p-queue';
import webPush from 'web-push';
import { logger } from './logger.js';
import type { NotifyEventType, PushSubscriptionRecord, Repository } from './db/repository.js';
import type { VapidKeyStore } from './vapid-store.js';

/**
 * Web Push notification service.
 *
 * Spec: docs/superpowers/specs/2026-05-28-browser-notifications-v2-webpush.md.
 *
 * Worker callers use `enqueue()` as fire-and-forget. The internal PQueue
 * bounds concurrency so a slow push service can't tie up SQLite connections
 * or stall the worker pool.
 */

export interface PushPayload {
  event: NotifyEventType;
  taskId: number;
  taskTitle: string;
  pieceName: string;
  ownerId: string | null;
}

export interface PushServiceOptions {
  queueConcurrency?: number;
  perSendTimeoutMs?: number;
  payloadMaxBytes?: number;
}

const DEFAULT_OPTIONS: Required<PushServiceOptions> = {
  queueConcurrency: 8,
  perSendTimeoutMs: 10_000,
  payloadMaxBytes: 3_072,
};

const EVENT_LABEL: Record<NotifyEventType, string> = {
  running: 'タスク開始',
  succeeded: 'タスク完了',
  failed: 'タスク失敗',
  waiting_human: '回答待ち',
};

const EVENT_EMOJI: Record<NotifyEventType, string> = {
  running: '🟢',
  succeeded: '✅',
  failed: '❌',
  waiting_human: '❓',
};

const MAX_RETRY_ATTEMPTS = 2; // initial + 2 retries = up to 3 sends

interface PushError {
  statusCode?: number;
  message?: string;
}

function isPushError(err: unknown): err is PushError {
  return typeof err === 'object' && err !== null;
}

function sleep(ms: number): Promise<void> {
  return new Promise(resolve => setTimeout(resolve, ms));
}

function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
  return new Promise((resolve, reject) => {
    const timer = setTimeout(() => reject(new Error(`push send timed out after ${ms}ms`)), ms);
    promise.then(
      value => {
        clearTimeout(timer);
        resolve(value);
      },
      err => {
        clearTimeout(timer);
        reject(err);
      },
    );
  });
}

/**
 * Build the JSON payload sent over the push channel. When `includeDetails`
 * is false (default), task title and piece name are NOT included — only the
 * generic event label and task id — so the push service operator cannot
 * read task metadata. Users opt in to detailed payloads via Settings.
 */
export function buildPushPayload(
  payload: PushPayload,
  includeDetails: boolean,
  maxBytes: number,
): string {
  const tag = `task-${payload.taskId}-${payload.event}`;
  const data = { taskId: payload.taskId };

  const tryEncode = (title: string, body: string): { json: string; bytes: number } => {
    const json = JSON.stringify({ title, body, tag, data });
    return { json, bytes: Buffer.byteLength(json, 'utf8') };
  };

  if (includeDetails) {
    const titleFull = `${EVENT_EMOJI[payload.event]} ${EVENT_LABEL[payload.event]}: ${payload.taskTitle}`;
    const detailed = tryEncode(titleFull.slice(0, 200), payload.pieceName.slice(0, 200));
    if (detailed.bytes <= maxBytes) return detailed.json;
    // Trim aggressively before falling back to generic.
    const titleTrim = `${EVENT_EMOJI[payload.event]} ${EVENT_LABEL[payload.event]}: ${payload.taskTitle.slice(0, 60)}`;
    const trimmed = tryEncode(titleTrim, payload.pieceName.slice(0, 40));
    if (trimmed.bytes <= maxBytes) return trimmed.json;
  }
  // Privacy-default: no task title or piece name in transit.
  const generic = tryEncode(
    `${EVENT_EMOJI[payload.event]} タスク #${payload.taskId} ${EVENT_LABEL[payload.event]}`,
    '詳細を見るにはタップ',
  );
  return generic.json;
}

export class PushService {
  private readonly queue: PQueue;
  private readonly options: Required<PushServiceOptions>;

  constructor(
    private readonly repo: Repository,
    private readonly vapidStore: VapidKeyStore,
    options: PushServiceOptions = {},
  ) {
    this.options = { ...DEFAULT_OPTIONS, ...options };
    this.queue = new PQueue({ concurrency: this.options.queueConcurrency });
  }

  /**
   * Fire-and-forget — worker hooks call this and never await the promise.
   * The queue guarantees bounded concurrency and absorbs unhandled rejections.
   */
  enqueue(payload: PushPayload): void {
    if (!payload.ownerId) {
      // Legacy data with null owner_id has no recipient. Skip silently.
      return;
    }
    void this.queue
      .add(() => this.sendPushToUser(payload))
      .catch(err => {
        logger.error(
          `[push] queue error event=${payload.event} task=${payload.taskId} err=${String(err)}`,
        );
      });
  }

  /** Returns when the queue is empty. Mainly useful in tests / shutdown. */
  async waitIdle(): Promise<void> {
    await this.queue.onIdle();
  }

  async sendPushToUser(payload: PushPayload): Promise<void> {
    if (!payload.ownerId) return;
    const prefs = this.repo.getUserNotificationPrefs(payload.ownerId);
    if (!prefs.enabled) return;
    if (!prefs.events[payload.event]) return;

    const subs = this.repo.listPushSubscriptionsForUser(payload.ownerId);
    if (subs.length === 0) return;

    const message = buildPushPayload(payload, prefs.includeDetails, this.options.payloadMaxBytes);
    await Promise.allSettled(subs.map(sub => this.sendWithRetry(sub, message)));
  }

  private async sendWithRetry(sub: PushSubscriptionRecord, message: string): Promise<void> {
    let attempt = 0;
    while (true) {
      const keyMaterial = this.vapidStore.getKey(sub.vapidKeyId);
      if (!keyMaterial) {
        logger.warn(
          `[push] VAPID key missing for subscription id=${sub.id} keyId=${sub.vapidKeyId} — user must re-subscribe`,
        );
        this.repo.markPushSubscriptionFailure(sub.id);
        return;
      }
      webPush.setVapidDetails(
        keyMaterial.subject,
        keyMaterial.publicKey,
        keyMaterial.privateKey,
      );
      try {
        await withTimeout(
          webPush.sendNotification(
            {
              endpoint: sub.endpoint,
              keys: { p256dh: sub.p256dh, auth: sub.auth },
            },
            message,
            { TTL: 60 },
          ),
          this.options.perSendTimeoutMs,
        );
        this.repo.markPushSubscriptionSuccess(sub.id);
        return;
      } catch (err) {
        const code = isPushError(err) ? err.statusCode : undefined;
        // 404 / 410: subscription is gone for good.
        if (code === 404 || code === 410) {
          this.repo.deletePushSubscription(sub.id);
          logger.info(`[push] subscription gone (${code}) id=${sub.id}`);
          return;
        }
        // 413: implementation bug — payload exceeded what the push service accepts.
        if (code === 413) {
          this.repo.markPushSubscriptionFailure(sub.id);
          logger.error(
            `[push] payload too large id=${sub.id} bytes=${Buffer.byteLength(message, 'utf8')}`,
          );
          return;
        }
        // 401 / 403: VAPID auth issue. Do NOT delete; this is an operator problem.
        if (code === 401 || code === 403) {
          this.repo.markPushSubscriptionFailure(sub.id);
          logger.error(
            `[push] VAPID auth failed (${code}) id=${sub.id} keyId=${sub.vapidKeyId} — check vapid_subject / key integrity`,
          );
          return;
        }
        // 429 / 5xx: transient — backoff + jitter, retry up to MAX_RETRY_ATTEMPTS.
        const isTransient =
          code === 429 || (typeof code === 'number' && code >= 500 && code < 600);
        if (isTransient && attempt < MAX_RETRY_ATTEMPTS) {
          attempt += 1;
          const baseDelay = 500 * Math.pow(2, attempt); // 1s, 2s
          const jitter = Math.random() * 300;
          await sleep(baseDelay + jitter);
          continue;
        }
        // Anything else (network error, undefined code, retries exhausted).
        this.repo.markPushSubscriptionFailure(sub.id);
        const msg = isPushError(err) && err.message ? err.message : String(err);
        logger.warn(
          `[push] send failed id=${sub.id} code=${code ?? 'unknown'} attempt=${attempt} msg=${msg}`,
        );
        return;
      }
    }
  }
}
