/**
 * src/push/apns.ts (T-iOS-20) — APNs sender + device-token registry for the
 * iOS client (PLAN_IOS_CLIENT §0.3/§7 declared server touch-point).
 *
 * Sits BESIDE the web-push `push-service` behind the same `NotifyService` seam:
 * the server combines both via `combineNotifyServices` so every existing hook
 * event point (held gate → needs-input, Stop/SessionEnd → done) fans out to
 * browsers AND iPhones with zero new event paths.
 *
 * Config is env-only and all-or-disabled: APNS_KEY_PATH + APNS_KEY_ID +
 * APNS_TEAM_ID are the critical trio; any piece missing (or an unreadable /
 * non-EC key) cleanly disables the feature — one log line, never a crash,
 * never any key material in logs.
 *
 * PAYLOAD MINIMIZATION (security invariant, mirrors push-service SP1/SEC-C5):
 * the visible alert carries ONLY a session short-prefix + status word; the
 * custom payload carries ONLY what the web-push payload already carries
 * (sessionId, cls, and — needs-input only — the single-use capability token
 * for /hook/decision). Never cwd, never command content, never terminal bytes.
 *
 * Transport: hand-rolled HTTP/2 + ES256 JWT over node:http2/node:crypto (zero
 * new npm deps, repo rule). The HTTP/2 layer hides behind the `ApnsHttp2Client`
 * seam so tests never touch the network. JWT is cached ~50 min (Apple wants
 * 20–60 min); a 403 drops the cache so the next send re-signs.
 */

import { readFileSync } from 'node:fs';
import { chmod, writeFile } from 'node:fs/promises';
import { connect } from 'node:http2';
import { createPrivateKey, sign as signBytes, type KeyObject } from 'node:crypto';
import { homedir } from 'node:os';
import path from 'node:path';
import type { Config, NotifyClass, NotifyService, Session } from '../types.js';

// ── constants ─────────────────────────────────────────────────────────────────

/** 0600 — owner read/write only; the file holds device push tokens. */
const FILE_MODE = 0o600;
/** Default APNs topic when APNS_BUNDLE_ID is unset. */
const DEFAULT_BUNDLE_ID = 'com.yaojia.webterm';
/** Default token-store filename (under the home dir), mirroring pushStorePath. */
const DEFAULT_STORE_FILENAME = '.web-terminal-apns-tokens.json';
/** Production APNs host; APNS_HOST overrides (sandbox / tests). */
const DEFAULT_APNS_HOST = 'https://api.push.apple.com';
/** Refresh the provider JWT after ~50 min (Apple requires 20–60 min). */
const JWT_MAX_AGE_MS = 50 * 60_000;
/** Expiration window (seconds) for low-priority DONE pushes (mirrors web-push TTL). */
const DONE_EXPIRATION_SECONDS = 600;
/** Frozen wire shape: an APNs device token is 64–160 hex chars, lowercased. */
const APNS_TOKEN_PATTERN = /^[0-9a-f]{64,160}$/;
/** Notification category the iOS app registers Allow/Deny actions against. */
const GATE_CATEGORY = 'WEBTERM_GATE';
/** apns-priority: deliver immediately (held gate) vs. power-friendly (done). */
const PRIORITY_IMMEDIATE = '10';
const PRIORITY_CONSERVE = '5';
/** Give a single HTTP/2 exchange this long before failing it (never hang). */
const REQUEST_TIMEOUT_MS = 10_000;
/** Chars of the session id shown in the visible alert (minimization). */
const ALERT_ID_PREFIX_LEN = 8;

// ── types ─────────────────────────────────────────────────────────────────────

/** Frozen, validated APNs runtime config (loaded from the APNS_* env group). */
export interface ApnsConfig {
  readonly keyPath: string;
  readonly keyId: string;
  readonly teamId: string;
  readonly bundleId: string;
  readonly storePath: string;
  readonly host: string;
}

export type ApnsConfigResult =
  | { readonly ok: true; readonly config: ApnsConfig }
  | { readonly ok: false; readonly reason: string };

/** One registered iPhone (device token is hex, already lowercase-normalized). */
export interface ApnsTokenRecord {
  readonly token: string;
  readonly createdAt: number;
}

export interface ApnsTokenStore {
  /** Frozen snapshot of current records (immutable to callers). */
  list(): readonly ApnsTokenRecord[];
  /** Idempotent upsert by token (FIFO-capped). Throws on invalid input. */
  add(record: ApnsTokenRecord): void;
  /** Remove by token; no-op when absent (idempotent). */
  remove(token: string): void;
  /** Remove a batch of dead tokens (410 / BadDeviceToken evictions). */
  prune(deadTokens: readonly string[]): void;
  /** Persist to disk (0600). Best-effort: logs and resolves on failure. */
  persist(): Promise<void>;
}

/** One APNs HTTP/2 exchange (headers exclude pseudo-headers except via path). */
export interface ApnsRequest {
  readonly path: string;
  readonly headers: Readonly<Record<string, string>>;
  readonly body: string;
}

export interface ApnsResponse {
  readonly status: number;
  readonly body: string;
}

/** Transport seam over node:http2 (injectable for tests — no network in tests). */
export interface ApnsHttp2Client {
  request(req: ApnsRequest): Promise<ApnsResponse>;
}

export interface ApnsServiceDeps {
  client?: ApnsHttp2Client;
  now?: () => number;
}

/** What the server wires up when the feature is enabled. */
export interface ApnsRuntime {
  readonly service: NotifyService;
  readonly store: ApnsTokenStore;
}

// ── config loading ────────────────────────────────────────────────────────────

/** Read a required env var; empty string counts as missing. */
function envOrNull(env: Record<string, string | undefined>, key: string): string | null {
  const value = env[key];
  return typeof value === 'string' && value.length > 0 ? value : null;
}

/**
 * Load the APNS_* env group. All-or-disabled: any missing critical piece
 * (APNS_KEY_PATH / APNS_KEY_ID / APNS_TEAM_ID) or a malformed APNS_HOST yields
 * `{ok:false, reason}` — the caller disables the feature, never crashes.
 */
export function loadApnsConfig(env: Record<string, string | undefined>): ApnsConfigResult {
  const missing = ['APNS_KEY_PATH', 'APNS_KEY_ID', 'APNS_TEAM_ID'].filter(
    (key) => envOrNull(env, key) === null,
  );
  if (missing.length > 0) {
    return { ok: false, reason: `${missing.join('/')} not set` };
  }
  const host = envOrNull(env, 'APNS_HOST') ?? DEFAULT_APNS_HOST;
  try {
    const parsed = new URL(host);
    if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
      return { ok: false, reason: 'APNS_HOST must be an http(s) URL' };
    }
  } catch {
    return { ok: false, reason: 'APNS_HOST is not a valid URL' };
  }
  return {
    ok: true,
    config: {
      keyPath: envOrNull(env, 'APNS_KEY_PATH') as string,
      keyId: envOrNull(env, 'APNS_KEY_ID') as string,
      teamId: envOrNull(env, 'APNS_TEAM_ID') as string,
      bundleId: envOrNull(env, 'APNS_BUNDLE_ID') ?? DEFAULT_BUNDLE_ID,
      storePath: envOrNull(env, 'APNS_STORE_PATH') ?? path.join(homedir(), DEFAULT_STORE_FILENAME),
      host,
    },
  };
}

/**
 * Load and sanity-check the .p8 signing key. Returns null (logged, no key
 * material) on any failure: missing file, unparsable PEM, non-EC key.
 */
export function loadApnsKey(keyPath: string): KeyObject | null {
  let pem: string;
  try {
    pem = readFileSync(keyPath, 'utf8');
  } catch (err) {
    console.error(
      `apns: cannot read key file at ${keyPath} (${(err as NodeJS.ErrnoException)?.code ?? 'error'})`,
    );
    return null;
  }
  try {
    const key = createPrivateKey(pem);
    if (key.asymmetricKeyType !== 'ec') {
      console.error('apns: key is not an EC key (APNs requires ES256 / P-256)');
      return null;
    }
    return key;
  } catch {
    // Never echo parse errors — they can quote fragments of the key material.
    console.error(`apns: key file at ${keyPath} is not a valid PKCS#8 EC key`);
    return null;
  }
}

// ── token validation + store ──────────────────────────────────────────────────

/**
 * Boundary validation for the frozen wire shape: a device token is a string of
 * 64–160 hex chars; case-insensitive input is normalized to lowercase.
 * Anything else → null (the route answers 400).
 */
export function normalizeApnsToken(raw: unknown): string | null {
  if (typeof raw !== 'string') return null;
  const token = raw.toLowerCase();
  return APNS_TOKEN_PATTERN.test(token) ? token : null;
}

/** Type guard: a structurally valid, persistable token record. */
function isValidApnsTokenRecord(x: unknown): x is ApnsTokenRecord {
  if (x === null || typeof x !== 'object') return false;
  const r = x as Record<string, unknown>;
  if (typeof r['token'] !== 'string' || !APNS_TOKEN_PATTERN.test(r['token'])) return false;
  if (typeof r['createdAt'] !== 'number' || !Number.isFinite(r['createdAt'])) return false;
  return true;
}

/** Best-effort load (mirrors subscription-store): missing/malformed → empty. */
function loadTokenRecords(filePath: string): ApnsTokenRecord[] {
  let raw: string;
  try {
    raw = readFileSync(filePath, 'utf8');
  } catch (err) {
    if ((err as NodeJS.ErrnoException)?.code !== 'ENOENT') {
      console.error('apns: token store load failed, starting empty', err);
    }
    return [];
  }
  try {
    const parsed: unknown = JSON.parse(raw);
    if (!Array.isArray(parsed)) return [];
    return parsed.filter(isValidApnsTokenRecord);
  } catch {
    console.error('apns: token store is malformed JSON, starting empty');
    return [];
  }
}

/**
 * Load (or initialise empty) the device-token store backed by `filePath`.
 * Same conventions as the web-push subscription-store: immutable replacement
 * on every mutation, FIFO cap, 0600 persistence, tolerant load.
 * @param filePath absolute path to the JSON store (APNS_STORE_PATH)
 * @param maxTokens FIFO cap (mirrors cfg.pushMaxSubs)
 */
export function loadApnsTokenStore(filePath: string, maxTokens: number): ApnsTokenStore {
  let records: ApnsTokenRecord[] = loadTokenRecords(filePath);

  return {
    list(): readonly ApnsTokenRecord[] {
      return Object.freeze(records.slice());
    },

    add(record: ApnsTokenRecord): void {
      if (!isValidApnsTokenRecord(record)) {
        throw new TypeError('apns: invalid token record');
      }
      const deduped = records.filter((r) => r.token !== record.token);
      const next = [...deduped, record];
      records = next.length > maxTokens ? next.slice(next.length - maxTokens) : next;
    },

    remove(token: string): void {
      records = records.filter((r) => r.token !== token);
    },

    prune(deadTokens: readonly string[]): void {
      if (deadTokens.length === 0) return;
      const dead = new Set(deadTokens);
      records = records.filter((r) => !dead.has(r.token));
    },

    async persist(): Promise<void> {
      try {
        await writeFile(filePath, JSON.stringify(records, null, 2), { mode: FILE_MODE });
        // writeFile's mode only applies on creation; enforce 0600 if pre-existing.
        await chmod(filePath, FILE_MODE);
      } catch (err) {
        console.error('apns: token store persist failed', err);
      }
    },
  };
}

// ── ES256 provider JWT ────────────────────────────────────────────────────────

function base64urlJson(value: unknown): string {
  return Buffer.from(JSON.stringify(value)).toString('base64url');
}

/**
 * Build the APNs provider JWT: ES256 (ECDSA P-256 / SHA-256) with the raw
 * r||s signature Apple expects (dsaEncoding 'ieee-p1363', NOT ASN.1/DER).
 * @param nowMs injectable clock (tests) — iat is floor(nowMs/1000)
 */
export function buildApnsJwt(key: KeyObject, keyId: string, teamId: string, nowMs: number): string {
  const header = base64urlJson({ alg: 'ES256', kid: keyId });
  const claims = base64urlJson({ iss: teamId, iat: Math.floor(nowMs / 1000) });
  const signingInput = `${header}.${claims}`;
  const signature = signBytes('sha256', Buffer.from(signingInput), {
    key,
    dsaEncoding: 'ieee-p1363',
  });
  return `${signingInput}.${signature.toString('base64url')}`;
}

// ── message construction (payload minimization lives HERE) ───────────────────

interface ApnsMessage {
  readonly headers: Readonly<Record<string, string>>;
  readonly body: string;
}

/** apns-collapse-id: session UUID sans dashes (32 chars — mirrors web-push topic). */
function collapseId(sessionId: string): string {
  return sessionId.replace(/-/g, '').slice(0, 64);
}

/**
 * Build headers + body for one signal. Returns null for classes outside the P1
 * APNs signal list (needs-input / done). The alert is minimal by design: a
 * status word + the session id's short prefix — no cwd, no command content, no
 * full session id in the visible text. The custom payload mirrors the web-push
 * `PushPayload` minus the cwd-derived `detail`.
 */
function buildApnsMessage(
  cfg: Config,
  apnsCfg: ApnsConfig,
  session: Session,
  cls: NotifyClass,
  nowMs: number,
  token?: string,
): ApnsMessage | null {
  if (cls !== 'needs-input' && cls !== 'done') return null;
  const sessionId = session.meta.id;
  const isGate = cls === 'needs-input';
  const shortId = sessionId.slice(0, ALERT_ID_PREFIX_LEN);
  const expirationSeconds = isGate
    ? Math.ceil(cfg.decisionTokenTtlMs / 1000)
    : DONE_EXPIRATION_SECONDS;

  const aps: Record<string, unknown> = {
    alert: { title: isGate ? 'Needs input' : 'Done', body: `Session ${shortId}` },
    ...(isGate ? { category: GATE_CATEGORY, sound: 'default' } : {}),
  };
  const payload: Record<string, unknown> = {
    aps,
    sessionId,
    cls,
    ...(isGate && token !== undefined ? { token } : {}),
  };

  return {
    headers: {
      'apns-topic': apnsCfg.bundleId,
      'apns-push-type': 'alert',
      'apns-priority': isGate ? PRIORITY_IMMEDIATE : PRIORITY_CONSERVE,
      'apns-collapse-id': collapseId(sessionId),
      'apns-expiration': String(Math.floor(nowMs / 1000) + expirationSeconds),
    },
    body: JSON.stringify(payload),
  };
}

// ── default HTTP/2 client ─────────────────────────────────────────────────────

/** Extract APNs' error `reason` from a response body, if parseable. */
function reasonOf(body: string): string | undefined {
  try {
    const parsed: unknown = JSON.parse(body);
    if (parsed !== null && typeof parsed === 'object') {
      const reason = (parsed as Record<string, unknown>)['reason'];
      if (typeof reason === 'string') return reason;
    }
  } catch {
    /* non-JSON body — no reason */
  }
  return undefined;
}

/**
 * Minimal per-request node:http2 client. One connection per send (KISS): our
 * volume is a handful of pushes per gate event, so connection reuse machinery
 * (GOAWAY handling, keep-alive pings) isn't worth its failure modes here.
 * Every error path settles the promise — never an unhandled 'error' event.
 */
function createHttp2Client(authority: string): ApnsHttp2Client {
  return {
    request(req: ApnsRequest): Promise<ApnsResponse> {
      return new Promise<ApnsResponse>((resolve, reject) => {
        let settled = false;
        const settle = (fn: () => void): void => {
          if (!settled) {
            settled = true;
            fn();
          }
        };
        let session: ReturnType<typeof connect>;
        try {
          session = connect(authority);
        } catch (err) {
          reject(err instanceof Error ? err : new Error(String(err)));
          return;
        }
        const fail = (err: Error): void => {
          session.close();
          settle(() => reject(err));
        };
        session.on('error', fail);
        session.setTimeout(REQUEST_TIMEOUT_MS, () => fail(new Error('apns: request timed out')));
        let stream: ReturnType<typeof session.request>;
        try {
          stream = session.request({ ':method': 'POST', ':path': req.path, ...req.headers });
        } catch (err) {
          fail(err instanceof Error ? err : new Error(String(err)));
          return;
        }
        let status = 0;
        let body = '';
        stream.setEncoding('utf8');
        stream.on('response', (headers) => {
          status = Number(headers[':status'] ?? 0);
        });
        stream.on('data', (chunk: string) => {
          body += chunk;
        });
        stream.on('end', () => {
          session.close();
          settle(() => resolve({ status, body }));
        });
        stream.on('error', fail);
        stream.end(req.body);
      });
    },
  };
}

// ── sender (NotifyService implementation) ─────────────────────────────────────

/**
 * Create the APNs NotifyService. Same seam as `createPushService`: the server
 * fans hook events out through `notify(session, cls, token?)`.
 * @param cfg frozen runtime config (DND/done toggles, decision-token TTL)
 * @param apnsCfg validated APNS_* env group
 * @param key the loaded .p8 EC private key (never logged)
 * @param store device-token store (read for fan-out, pruned on 410/BadDeviceToken)
 * @param deps injectable HTTP/2 client + clock (tests never touch the network)
 */
export function createApnsService(
  cfg: Config,
  apnsCfg: ApnsConfig,
  key: KeyObject,
  store: ApnsTokenStore,
  deps: ApnsServiceDeps = {},
): NotifyService {
  const client = deps.client ?? createHttp2Client(apnsCfg.host);
  const now = deps.now ?? Date.now;
  let cachedJwt: { readonly value: string; readonly issuedAtMs: number } | null = null;

  function currentJwt(nowMs: number): string {
    if (cachedJwt === null || nowMs - cachedJwt.issuedAtMs > JWT_MAX_AGE_MS) {
      cachedJwt = { value: buildApnsJwt(key, apnsCfg.keyId, apnsCfg.teamId, nowMs), issuedAtMs: nowMs };
    }
    return cachedJwt.value;
  }

  /** Mirrors web-push shouldSend; additionally scopes APNs to its P1 signals. */
  function shouldSend(cls: NotifyClass): boolean {
    if (cls !== 'needs-input' && cls !== 'done') return false;
    if (cfg.notifyDnd) return false;
    if (cls === 'done' && !cfg.notifyDone) return false;
    return true;
  }

  async function sendOne(record: ApnsTokenRecord, message: ApnsMessage, dead: string[]): Promise<void> {
    let res: ApnsResponse;
    try {
      res = await client.request({
        path: `/3/device/${record.token}`,
        headers: message.headers,
        body: message.body,
      });
    } catch (err) {
      // Connection-level failure (refused/reset/timeout): keep the token, log
      // without secrets, never crash the server.
      console.error(`apns: send failed (${err instanceof Error ? err.message : 'connection error'})`);
      return;
    }
    if (res.status === 200) return;
    const reason = reasonOf(res.body);
    if (res.status === 410 || (res.status === 400 && reason === 'BadDeviceToken')) {
      dead.push(record.token); // device gone / token invalid → evict
      return;
    }
    if (res.status === 403) {
      cachedJwt = null; // Expired/InvalidProviderToken → re-sign on the next send
    }
    // 429 and other transient statuses: keep the token, log (no token/key material).
    console.error(`apns: send failed (status ${res.status}${reason !== undefined ? ` ${reason}` : ''})`);
  }

  return {
    isEnabled: () => true,

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

      const nowMs = now();
      const built = buildApnsMessage(cfg, apnsCfg, session, cls, nowMs, token);
      if (built === null) return;
      const message: ApnsMessage = {
        body: built.body,
        headers: { ...built.headers, authorization: `bearer ${currentJwt(nowMs)}` },
      };
      const dead: string[] = [];

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

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

// ── composition + server-facing init ──────────────────────────────────────────

/**
 * Fan a notify out to several providers (web-push + APNs). One provider
 * failing must never block or hide the others (best-effort, logged).
 */
export function combineNotifyServices(...services: readonly NotifyService[]): NotifyService {
  return {
    isEnabled: () => services.some((service) => service.isEnabled()),

    async notify(session: Session, cls: NotifyClass, token?: string): Promise<void> {
      await Promise.all(
        services.map(async (service) => {
          try {
            await service.notify(session, cls, token);
          } catch (err) {
            console.error(
              `apns: notify fan-out failed (${err instanceof Error ? err.message : 'error'})`,
            );
          }
        }),
      );
    },
  };
}

/**
 * Server entry point: read the APNS_* env group and build the runtime, or
 * return null when the feature is disabled. Exactly one log line either way;
 * startup NEVER crashes on APNs problems; key material is never logged.
 * @param maxTokens FIFO cap for the token store — pass cfg.pushMaxSubs
 */
export function initApns(cfg: Config, env: Record<string, string | undefined>): ApnsRuntime | null {
  const result = loadApnsConfig(env);
  if (!result.ok) {
    console.error(`apns: disabled (${result.reason})`);
    return null;
  }
  const key = loadApnsKey(result.config.keyPath);
  if (key === null) {
    // loadApnsKey already logged the specific (key-material-free) cause.
    return null;
  }
  const store = loadApnsTokenStore(result.config.storePath, cfg.pushMaxSubs);
  const service = createApnsService(cfg, result.config, key, store);
  console.error(
    `apns: enabled (topic ${result.config.bundleId}, store ${result.config.storePath})`,
  );
  return { service, store };
}
