import http2 from 'node:http2';
import { getPushDevices, deletePushDevice, recordPushFailure, markPushDeviceSeen } from 'officerdb';
import type { Channel, DeliveryResult, Notification } from './types';
import { ApnsAuthToken } from './apns-jwt';
import { buildApnsPayload, collapseId } from './apns-payload';

// The Apple channel. Talks to APNs directly — no Expo, no library.
//
// APNs is HTTP/2 only and expects ONE long-lived connection carrying many requests. Opening a session
// per push is the classic mistake: Apple throttles it, and the TLS handshake dwarfs the send. So the
// session is created once, reused, and re-created only when it dies.
//
// Sandbox and production are different hosts AND different token namespaces. A token minted by a debug
// build fails against production with BadDeviceToken and no other symptom, which is why `environment` is
// a stored column rather than a guess — and why devices are grouped by it here and sent in two batches.

const HOSTS = {
  production: 'https://api.push.apple.com',
  sandbox: 'https://api.sandbox.push.apple.com',
} as const;

/** Apple's reasons for "this token is dead" — the row goes immediately rather than after three strikes. */
const DEAD_TOKEN_REASONS = new Set(['BadDeviceToken', 'Unregistered', 'DeviceTokenNotForTopic']);

const { APNS_KEY_P8, APNS_KEY_ID, APNS_TEAM_ID } = process.env;

const auth =
  APNS_KEY_P8 && APNS_KEY_ID && APNS_TEAM_ID
    ? new ApnsAuthToken({ keyP8: APNS_KEY_P8, keyId: APNS_KEY_ID, teamId: APNS_TEAM_ID })
    : null;

// One session per host, lazily created and reused.
const sessions = new Map<string, http2.ClientHttp2Session>();

function session(host: string): http2.ClientHttp2Session {
  const existing = sessions.get(host);
  if (existing && !existing.closed && !existing.destroyed) return existing;

  const s = http2.connect(host);
  // Without this a transport error becomes an unhandled rejection and takes the sidecar down.
  s.on('error', (err) => {
    console.error(`[notify:apns] session error (${host}):`, err.message);
    sessions.delete(host);
  });
  s.on('close', () => sessions.delete(host));
  sessions.set(host, s);
  return s;
}

type SendOutcome = { ok: boolean; dead: boolean; reason?: string };

function sendOne(host: string, token: string, bundleId: string, n: Notification): Promise<SendOutcome> {
  return new Promise((resolve) => {
    const body = Buffer.from(JSON.stringify(buildApnsPayload(n)));

    const req = session(host).request({
      ':method': 'POST',
      ':path': `/3/device/${token}`,
      authorization: `bearer ${auth!.get()}`,
      'apns-topic': bundleId,
      // `alert` because these are user-visible banners. A silent/background push would be `background`
      // with priority 5, which is a different feature and not what this channel is for.
      'apns-push-type': 'alert',
      'apns-priority': '10',
      'apns-collapse-id': collapseId(n),
      'content-type': 'application/json',
      'content-length': body.length,
    });

    let status = 0;
    let raw = '';

    req.on('response', (headers) => {
      status = Number(headers[':status'] ?? 0);
    });
    req.on('data', (chunk) => {
      raw += chunk;
    });
    req.on('end', () => {
      if (status === 200) return resolve({ ok: true, dead: false });
      // Apple answers failures with { "reason": "BadDeviceToken" } and nothing else useful.
      let reason = `status ${status}`;
      try {
        reason = (JSON.parse(raw) as { reason?: string }).reason ?? reason;
      } catch {
        /* keep the status */
      }
      resolve({ ok: false, dead: DEAD_TOKEN_REASONS.has(reason), reason });
    });
    req.on('error', (err) => resolve({ ok: false, dead: false, reason: err.message }));

    req.setTimeout(10_000, () => {
      req.close();
      resolve({ ok: false, dead: false, reason: 'timeout' });
    });

    req.end(body);
  });
}

export const apnsChannel: Channel = {
  name: 'apns',

  isConfigured: () => auth !== null,

  async send(n: Notification): Promise<DeliveryResult> {
    if (!auth) return { channel: 'apns', sent: 0, failed: 0 };

    const devices = (await getPushDevices(n.userId, n.appSlug)).filter((d) => d.platform === 'ios');
    if (devices.length === 0) return { channel: 'apns', sent: 0, failed: 0 };

    const dead: string[] = [];
    let sent = 0;
    let failed = 0;

    await Promise.all(
      devices.map(async (d) => {
        const host = HOSTS[d.environment as keyof typeof HOSTS] ?? HOSTS.production;
        const outcome = await sendOne(host, d.token, d.bundleId, n);

        if (outcome.ok) {
          sent++;
          await markPushDeviceSeen(d.token);
          return;
        }

        failed++;
        console.error(`[notify:apns] ${d.bundleId} ${d.environment}: ${outcome.reason}`);

        if (outcome.dead) {
          // An uninstalled app or a rotated token. Delete now — retrying cannot help, and a registry
          // full of dead tokens is how delivery quietly degrades.
          dead.push(d.token);
          await deletePushDevice(d.token);
        } else {
          await recordPushFailure(d.token);
        }
      }),
    );

    return { channel: 'apns', sent, failed, ...(dead.length ? { dead } : {}) };
  },
};

/** Close the shared sessions on shutdown so the process can exit promptly. */
export function closeApnsSessions(): void {
  for (const s of sessions.values()) {
    try {
      s.close();
    } catch {
      /* already gone */
    }
  }
  sessions.clear();
}
