/**
 * src/push/fcm.ts (A33) — FCM v1 sender + device-token registry for the Android
 * client (ANDROID_CLIENT_PLAN §4.5 — the ONE additive server touch-point).
 *
 * Sits BESIDE web-push and APNs behind the same `NotifyService` seam: the server
 * combines all three via `combineNotifyServices` (exported by apns.ts) so every
 * existing hook event point (held gate → needs-input, Stop/SessionEnd → done)
 * fans out to browsers, iPhones AND Android phones with zero new event paths.
 *
 * Config is env-only and all-or-disabled: FCM_PROJECT_ID + FCM_KEY_PATH are the
 * critical pair; any piece missing (or an unreadable / malformed service-account
 * key) cleanly disables the feature — one log line, never a crash, never any key
 * material in logs (§8 "No secrets in code": the service-account JSON is
 * server-side only).
 *
 * PAYLOAD MINIMIZATION (security invariant, mirrors apns.ts / push-service SP1):
 * the message is DATA-ONLY (no `notification` block — the Android client builds
 * the lock-screen notification + Allow/Deny actions locally, §4.5/R1). `data`
 * 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: OAuth2 bearer via `google-auth-library` (R7 — a deliberate exception
 * to the zero-new-dep rule: less crypto to own/test than a hand-rolled RS256 JWT).
 * The library mints + refreshes the ~1h access token and auto-refreshes on 401.
 * The auth + HTTP POST hide behind the `FcmTransport` seam so tests inject a fake
 * and NEVER touch the network or Google.
 */

import { readFileSync } from 'node:fs';
import { chmod, writeFile } from 'node:fs/promises';
import { homedir } from 'node:os';
import path from 'node:path';
import { GoogleAuth } from 'google-auth-library';
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 token-store filename (under the home dir), mirroring the apns store. */
const DEFAULT_STORE_FILENAME = '.web-terminal-fcm-tokens.json';
/** Production FCM host; FCM_HOST overrides (tests / staging). */
const DEFAULT_FCM_HOST = 'https://fcm.googleapis.com';
/** OAuth2 scope the FCM v1 send endpoint requires. */
const FCM_SCOPE = 'https://www.googleapis.com/auth/firebase.messaging';
/** ttl (seconds) for low-priority DONE pushes (mirrors web-push / apns). */
const DONE_EXPIRATION_SECONDS = 600;
/**
 * Loose FCM-token validator (§4.5, deliberate): FCM registration-token
 * format/length is undocumented and drifts, so a strict `{140,170}` regex risks
 * rejecting valid tokens. Accept non-empty base64url charset plus ':' up to a
 * bounded max. Case is preserved (FCM tokens are case-sensitive).
 */
const FCM_TOKEN_PATTERN = /^[A-Za-z0-9_:-]+$/;
/** Upper bound on a stored token (bounded, but generous — real tokens are ~160). */
const MAX_FCM_TOKEN_LENGTH = 4096;
/** AndroidConfig.priority for a held gate (immediate wake) vs. a done signal.
 *  NOTE: the FCM v1 `AndroidConfig.priority` enum is UPPERCASE HIGH/NORMAL — a
 *  lowercase value is rejected with INVALID_ARGUMENT (which would then prune the
 *  token). See the deviation note in the A33 log entry. */
const PRIORITY_HIGH = 'HIGH';
const PRIORITY_NORMAL = 'NORMAL';
/** FCM v1 error codes that mean "this token is dead" → prune it. */
const PRUNE_ERROR_CODES = new Set(['UNREGISTERED', 'INVALID_ARGUMENT']);

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

/** Frozen, validated FCM runtime config (loaded from the FCM_* env group). */
export interface FcmConfig {
  readonly projectId: string;
  readonly keyPath: string;
  readonly storePath: string;
  readonly host: string;
}

export type FcmConfigResult =
  | { readonly ok: true; readonly config: FcmConfig }
  | { readonly ok: false; readonly reason: string };

/** One registered Android device (FCM registration token, verbatim). */
export interface FcmTokenRecord {
  readonly token: string;
  readonly createdAt: number;
}

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

/** One FCM v1 send exchange (the transport adds the OAuth2 bearer internally). */
export interface FcmRequest {
  readonly url: string;
  readonly body: string;
}

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

/**
 * Transport seam: mints/refreshes the OAuth2 bearer AND performs the POST.
 * Injectable for tests — the fake records requests and never touches Google.
 */
export interface FcmTransport {
  send(req: FcmRequest): Promise<FcmResponse>;
}

export interface FcmServiceDeps {
  transport?: FcmTransport;
}

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

// ── 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 FCM_* env group. All-or-disabled: any missing critical piece
 * (FCM_PROJECT_ID / FCM_KEY_PATH) or a malformed FCM_HOST yields `{ok:false,
 * reason}` — the caller disables the feature, never crashes.
 */
export function loadFcmConfig(env: Record<string, string | undefined>): FcmConfigResult {
  const missing = ['FCM_PROJECT_ID', 'FCM_KEY_PATH'].filter((key) => envOrNull(env, key) === null);
  if (missing.length > 0) {
    return { ok: false, reason: `${missing.join('/')} not set` };
  }
  const host = envOrNull(env, 'FCM_HOST') ?? DEFAULT_FCM_HOST;
  try {
    const parsed = new URL(host);
    if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
      return { ok: false, reason: 'FCM_HOST must be an http(s) URL' };
    }
  } catch {
    return { ok: false, reason: 'FCM_HOST is not a valid URL' };
  }
  return {
    ok: true,
    config: {
      projectId: envOrNull(env, 'FCM_PROJECT_ID') as string,
      keyPath: envOrNull(env, 'FCM_KEY_PATH') as string,
      storePath: envOrNull(env, 'FCM_STORE_PATH') ?? path.join(homedir(), DEFAULT_STORE_FILENAME),
      host,
    },
  };
}

/**
 * Sanity-check the service-account JSON key without ever echoing its contents.
 * Returns false (logged, no key material) on any failure: missing file,
 * unparsable JSON, or a shape missing client_email / private_key.
 */
export function validateFcmKeyFile(keyPath: string): boolean {
  let raw: string;
  try {
    raw = readFileSync(keyPath, 'utf8');
  } catch (err) {
    console.error(
      `fcm: cannot read service-account key at ${keyPath} (${(err as NodeJS.ErrnoException)?.code ?? 'error'})`,
    );
    return false;
  }
  try {
    const parsed = JSON.parse(raw) as Record<string, unknown>;
    if (typeof parsed['client_email'] !== 'string' || typeof parsed['private_key'] !== 'string') {
      console.error(`fcm: service-account key at ${keyPath} is missing client_email/private_key`);
      return false;
    }
    return true;
  } catch {
    // Never echo parse errors — they can quote fragments of the private_key.
    console.error(`fcm: service-account key at ${keyPath} is not valid JSON`);
    return false;
  }
}

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

/**
 * Boundary validation for the (loose, deliberate) FCM-token wire shape: a
 * non-empty base64url string (plus ':') within a bounded length. Returns the
 * token verbatim (case-preserved) or null (the route answers 400).
 */
export function normalizeFcmToken(raw: unknown): string | null {
  if (typeof raw !== 'string') return null;
  if (raw.length === 0 || raw.length > MAX_FCM_TOKEN_LENGTH) return null;
  return FCM_TOKEN_PATTERN.test(raw) ? raw : null;
}

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

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

/**
 * Load (or initialise empty) the device-token store backed by `filePath`.
 * Same conventions as the apns store: immutable replacement on every mutation,
 * FIFO cap, 0600 persistence, tolerant load.
 * @param filePath absolute path to the JSON store (FCM_STORE_PATH)
 * @param maxTokens FIFO cap (pass cfg.pushMaxSubs)
 */
export function loadFcmTokenStore(filePath: string, maxTokens: number): FcmTokenStore {
  let records: FcmTokenRecord[] = loadTokenRecords(filePath);

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

    add(record: FcmTokenRecord): void {
      if (!isValidFcmTokenRecord(record)) {
        throw new TypeError('fcm: 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('fcm: token store persist failed', err);
      }
    },
  };
}

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

interface FcmMessage {
  readonly data: Readonly<Record<string, string>>;
  readonly priority: string;
  readonly ttl: string;
}

/**
 * Build the DATA-ONLY message fields for one signal. Returns null for classes
 * outside the P1 FCM signal list (needs-input / done). No `notification` block:
 * the Android client renders the lock-screen UI locally (§4.5/R1). `data` mirrors
 * the web-push payload minus the cwd-derived label — sessionId, cls, and (gate
 * only) the single-use capability token. Never cwd, command, or terminal bytes.
 */
function buildFcmMessage(
  cfg: Config,
  session: Session,
  cls: NotifyClass,
  token?: string,
): FcmMessage | null {
  if (cls !== 'needs-input' && cls !== 'done') return null;
  const isGate = cls === 'needs-input';
  const ttlSeconds = isGate ? Math.ceil(cfg.decisionTokenTtlMs / 1000) : DONE_EXPIRATION_SECONDS;
  const data: Record<string, string> = {
    sessionId: session.meta.id,
    cls,
    ...(isGate && token !== undefined ? { token } : {}),
  };
  return {
    data,
    priority: isGate ? PRIORITY_HIGH : PRIORITY_NORMAL,
    ttl: `${ttlSeconds}s`,
  };
}

/** Serialize one per-token FCM v1 request body (data-only — no notification). */
function serializeMessage(recordToken: string, message: FcmMessage): string {
  return JSON.stringify({
    message: {
      token: recordToken,
      data: message.data,
      android: { priority: message.priority, ttl: message.ttl },
    },
  });
}

// ── error classification ──────────────────────────────────────────────────────

/** Collect every FCM error identifier (error.status + details[].errorCode). */
function fcmErrorCodes(body: string): Set<string> {
  const codes = new Set<string>();
  try {
    const parsed: unknown = JSON.parse(body);
    if (parsed === null || typeof parsed !== 'object') return codes;
    const error = (parsed as Record<string, unknown>)['error'];
    if (error === null || typeof error !== 'object') return codes;
    const e = error as Record<string, unknown>;
    if (typeof e['status'] === 'string') codes.add(e['status']);
    const details = e['details'];
    if (Array.isArray(details)) {
      for (const d of details) {
        if (d !== null && typeof d === 'object') {
          const errorCode = (d as Record<string, unknown>)['errorCode'];
          if (typeof errorCode === 'string') codes.add(errorCode);
        }
      }
    }
  } catch {
    /* non-JSON body — no codes */
  }
  return codes;
}

/** True when the response says this token is permanently dead → evict it. */
function isDeadToken(body: string): boolean {
  for (const code of fcmErrorCodes(body)) {
    if (PRUNE_ERROR_CODES.has(code)) return true;
  }
  return false;
}

// ── default transport (google-auth-library) ───────────────────────────────────

/** Safely read a numeric `status` off an unknown Gaxios response/error. */
function statusOf(value: unknown): number {
  if (value !== null && typeof value === 'object' && 'status' in value) {
    const status = (value as { status: unknown }).status;
    if (typeof status === 'number') return status;
  }
  return 0;
}

/** Coerce an unknown Gaxios `data` (string with responseType 'text') to string. */
function bodyOf(data: unknown): string {
  if (typeof data === 'string') return data;
  if (data === undefined || data === null) return '';
  try {
    return JSON.stringify(data);
  } catch {
    return '';
  }
}

/**
 * Default transport: `google-auth-library` mints + refreshes the OAuth2 access
 * token (scope firebase.messaging) and issues the POST.
 *
 * We deliberately do NOT pass `validateStatus: () => true`. Gaxios throws on any
 * non-2xx status, and that throw is exactly what triggers OAuth2Client's internal
 * "re-auth on 401/403 → refresh access token → retry once" (see
 * oauth2client.requestAsync's catch block — R7's whole reason for the dep).
 * Swallowing 4xx/5xx into a resolved response would silently defeat that refresh.
 *
 * A thrown GaxiosError carries the HTTP result on `err.response` — we convert it
 * back to an FcmResponse so sendOne's existing classification (200 / dead-token
 * UNREGISTERED|INVALID_ARGUMENT → prune / other) still runs. A thrown error with
 * NO response is a real transport failure (DNS, connection refused, token mint) —
 * we re-throw so sendOne treats it as a send failure and KEEPS the token, never a
 * false dead-token prune.
 */
function createGoogleFcmTransport(keyPath: string): FcmTransport {
  const auth = new GoogleAuth({ keyFile: keyPath, scopes: [FCM_SCOPE] });
  return {
    async send(req: FcmRequest): Promise<FcmResponse> {
      const client = await auth.getClient();
      try {
        const res = await client.request({
          url: req.url,
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          data: req.body,
          responseType: 'text',
        });
        return { status: statusOf(res), body: bodyOf(res.data) };
      } catch (err) {
        const response = (err as { response?: unknown }).response;
        if (response !== null && typeof response === 'object') {
          return { status: statusOf(response), body: bodyOf((response as { data?: unknown }).data) };
        }
        throw err; // no HTTP response ⇒ real transport error, surface it to sendOne
      }
    },
  };
}

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

/**
 * Create the FCM NotifyService. Same seam as the web-push / apns services: the
 * server fans hook events out through `notify(session, cls, token?)`.
 * @param cfg frozen runtime config (DND/done toggles, decision-token TTL)
 * @param fcmCfg validated FCM_* env group
 * @param store device-token store (read for fan-out, pruned on dead-token errors)
 * @param deps injectable transport (tests never touch the network / Google)
 */
export function createFcmService(
  cfg: Config,
  fcmCfg: FcmConfig,
  store: FcmTokenStore,
  deps: FcmServiceDeps = {},
): NotifyService {
  const transport = deps.transport ?? createGoogleFcmTransport(fcmCfg.keyPath);
  const sendUrl = `${fcmCfg.host}/v1/projects/${fcmCfg.projectId}/messages:send`;

  /** Mirrors web-push/apns shouldSend; additionally scopes FCM 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: FcmTokenRecord, message: FcmMessage, dead: string[]): Promise<void> {
    let res: FcmResponse;
    try {
      res = await transport.send({ url: sendUrl, body: serializeMessage(record.token, message) });
    } catch (err) {
      // Connection-level / auth-mint failure: keep the token, log without secrets.
      console.error(`fcm: send failed (${err instanceof Error ? err.message : 'connection error'})`);
      return;
    }
    if (res.status === 200) return;
    if (isDeadToken(res.body)) {
      dead.push(record.token); // UNREGISTERED / INVALID_ARGUMENT → evict
      return;
    }
    // 401 (lib auto-refreshes internally), 429 and 5xx: keep the token, log
    // without any token or key material.
    const codes = [...fcmErrorCodes(res.body)];
    console.error(`fcm: send failed (status ${res.status}${codes.length > 0 ? ` ${codes.join('/')}` : ''})`);
  }

  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 message = buildFcmMessage(cfg, session, cls, token);
      if (message === null) return;
      const dead: string[] = [];

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

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

// ── server-facing init ────────────────────────────────────────────────────────

/**
 * Server entry point: read the FCM_* env group and build the runtime, or return
 * null when the feature is disabled. Exactly one log line either way; startup
 * NEVER crashes on FCM problems; key material is never logged.
 */
export function initFcm(cfg: Config, env: Record<string, string | undefined>): FcmRuntime | null {
  const result = loadFcmConfig(env);
  if (!result.ok) {
    console.error(`fcm: disabled (${result.reason})`);
    return null;
  }
  if (!validateFcmKeyFile(result.config.keyPath)) {
    // validateFcmKeyFile already logged the specific (key-material-free) cause.
    return null;
  }
  const store = loadFcmTokenStore(result.config.storePath, cfg.pushMaxSubs);
  const service = createFcmService(cfg, result.config, store);
  console.error(`fcm: enabled (project ${result.config.projectId}, store ${result.config.storePath})`);
  return { service, store };
}
