import { promises as fs } from "fs";
import path from "path";
import { eq, and } from "drizzle-orm";
import webpush from "web-push";
import { db } from "@workspace/db";
import {
  pushSubscriptionsTable,
  usersTable,
} from "@workspace/db";
import { logger } from "./logger";

let initPromise: Promise<{
  publicKey: string;
  privateKey: string;
  subject: string;
} | null> | null = null;

function keysFilePath(): string {
  // LOCAL_STORAGE_ROOT is set in Docker prod (mounted volume at
  // /app/storage). In dev on Replit it's unset, and PRIVATE_OBJECT_DIR
  // points to an object-store path that isn't a real local filesystem,
  // so we fall back to /tmp.
  const root = process.env.LOCAL_STORAGE_ROOT || "/tmp";
  return path.join(root, "vapid-keys.json");
}

/**
 * Resolve VAPID keys. Priority:
 *   1. env (VAPID_PUBLIC_KEY + VAPID_PRIVATE_KEY)
 *   2. cached on-disk file (auto-generated on first boot)
 *   3. generate, persist, return
 *
 * Returns null only if write + generate both fail, in which case
 * Web Push is disabled but the rest of the app keeps running.
 */
async function loadVapid(): Promise<{
  publicKey: string;
  privateKey: string;
  subject: string;
} | null> {
  // Use `||` (not `??`) because docker-compose passes unset vars as
  // empty strings, not undefined. An empty subject fails web-push's
  // validateSubject() with "No subject set in vapidDetails.subject".
  const subject =
    process.env.VAPID_SUBJECT ||
    `mailto:admin@${process.env.LOCAL_DOMAIN || "tx.local"}`;

  const envPub = process.env.VAPID_PUBLIC_KEY;
  const envPriv = process.env.VAPID_PRIVATE_KEY;
  if (envPub && envPriv) {
    try {
      webpush.setVapidDetails(subject, envPub, envPriv);
      logger.info(
        { source: "env" },
        "VAPID keys loaded from environment — push subscriptions persist across container rebuilds.",
      );
      return { publicKey: envPub, privateKey: envPriv, subject };
    } catch (err) {
      logger.error({ err }, "Invalid VAPID env keys — falling back to disk");
    }
  }

  const file = keysFilePath();
  try {
    const raw = await fs.readFile(file, "utf8");
    const parsed = JSON.parse(raw) as {
      publicKey?: string;
      privateKey?: string;
    };
    if (parsed.publicKey && parsed.privateKey) {
      webpush.setVapidDetails(subject, parsed.publicKey, parsed.privateKey);
      logger.info(
        { source: "disk", file },
        "VAPID keys loaded from disk. To share across hosts, copy them into the VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY env vars.",
      );
      return {
        publicKey: parsed.publicKey,
        privateKey: parsed.privateKey,
        subject,
      };
    }
  } catch {
    /* missing — fall through to generate */
  }

  const { publicKey, privateKey } = webpush.generateVAPIDKeys();
  // Attempt to persist so the key survives restarts. If the target
  // directory isn't writable (e.g. Replit dev where LOCAL_STORAGE_ROOT
  // is unset), fall back to /tmp. If even that fails, run with an
  // ephemeral in-memory key — Web Push still works for this process,
  // existing subscriptions will just be invalidated on next restart.
  const candidates = [file, path.join("/tmp", "vapid-keys.json")];
  let persistedAt: string | null = null;
  for (const target of candidates) {
    try {
      await fs.mkdir(path.dirname(target), { recursive: true });
      await fs.writeFile(
        target,
        JSON.stringify({ publicKey, privateKey }, null, 2),
        { mode: 0o600 },
      );
      persistedAt = target;
      break;
    } catch {
      /* try next candidate */
    }
  }
  webpush.setVapidDetails(subject, publicKey, privateKey);
  if (persistedAt) {
    logger.warn(
      { file: persistedAt },
      "Generated VAPID keys (no VAPID_PUBLIC_KEY/PRIVATE_KEY env set). " +
        "Persisted to disk so they survive restarts. Copy them into env to " +
        "share across hosts.",
    );
  } else {
    logger.error(
      "Generated ephemeral VAPID keys (could not write to disk). " +
        "Push subscriptions will be invalidated on next restart.",
    );
  }
  return { publicKey, privateKey, subject };
}

export function getVapid() {
  if (!initPromise) initPromise = loadVapid();
  return initPromise;
}

/**
 * Push payload. Two shapes are accepted so call sites can either:
 *   1. Pass `title` + `body` directly (legacy / single-language), or
 *   2. Pass `titleAr`+`titleEn` and `bodyAr`+`bodyEn` and let
 *      `sendPushToUser` pick the matching strings based on the
 *      recipient's `preferredLanguage` column. This way an English-
 *      preferring user no longer gets Arabic on their lock screen.
 *
 * When both shapes are provided, the bilingual fields win.
 */
export type PushPayload = {
  title: string;
  body: string;
  titleAr?: string;
  titleEn?: string;
  bodyAr?: string;
  bodyEn?: string;
  tag?: string;
  url?: string;
  type?: string;
  relatedId?: number | null;
};

type ChannelType = "order" | "executive_meeting" | "note" | "info";

type UserPushPrefs = {
  muted: boolean;
  orders: boolean;
  meetings: boolean;
  notes: boolean;
  language: string;
};

/**
 * Load the user's notification gating prefs AND their preferred UI
 * language in a single round-trip. The language is used downstream to
 * localise the push title/body when the caller supplied bilingual
 * fields. Mirrors the client-side gating in `useNotificationsSocket`.
 */
async function loadUserPushPrefs(userId: number): Promise<UserPushPrefs | null> {
  const [u] = await db
    .select({
      muted: usersTable.notificationsMuted,
      orders: usersTable.notifyOrdersEnabled,
      meetings: usersTable.notifyMeetingsEnabled,
      notes: usersTable.notifyNotesEnabled,
      language: usersTable.preferredLanguage,
    })
    .from(usersTable)
    .where(eq(usersTable.id, userId))
    .limit(1);
  if (!u) return null;
  return u;
}

function userAllowsChannel(prefs: UserPushPrefs, channel: ChannelType): boolean {
  if (prefs.muted) return false;
  if (channel === "order") return prefs.orders;
  if (channel === "executive_meeting") return prefs.meetings;
  if (channel === "note") return prefs.notes;
  return true;
}

/**
 * Pick the title/body in the recipient's preferred language. Falls
 * back to the legacy `title`/`body` fields when the caller didn't pass
 * bilingual copies, and falls back to the other language when only one
 * side is provided (so a half-localised emit site is still readable).
 */
function localisePayload(
  payload: PushPayload,
  language: string,
): { title: string; body: string } {
  const preferEn = language === "en";
  const title = preferEn
    ? payload.titleEn ?? payload.titleAr ?? payload.title
    : payload.titleAr ?? payload.titleEn ?? payload.title;
  const body = preferEn
    ? payload.bodyEn ?? payload.bodyAr ?? payload.body
    : payload.bodyAr ?? payload.bodyEn ?? payload.body;
  return { title, body };
}

/**
 * Send a Web Push notification to every active subscription a user
 * owns. Drops 404/410 subscriptions on the fly. Honours the user's
 * mute + per-channel preferences. Safe to fire-and-forget.
 */
/**
 * Returns true if the user has at least one active Socket.IO connection
 * (any tab/device). Used to gate Web Push so a connected user doesn't
 * get the in-app chime AND a system notification for the same event.
 *
 * Design note (intentional, user-wide rather than per-device): the
 * primary deployment is a single user with one iPad PWA plus an
 * occasional desktop browser. The product preference is "don't
 * double-ring me on the desktop I'm staring at, even if the iPad is
 * also subscribed" — so when ANY socket is live we trust the in-app
 * chime to surface the notification, and skip system push for every
 * device the user owns. If multi-device per-session push becomes a
 * requirement, narrow this to the specific socket-id / device-id
 * registered alongside each push subscription.
 */
async function isUserConnected(userId: number): Promise<boolean> {
  try {
    const { io } = await import("../index.js");
    const sockets = await io.in(`user:${userId}`).fetchSockets();
    return sockets.length > 0;
  } catch {
    // If the socket layer isn't reachable, fall through and send push —
    // better to over-notify than to silently drop.
    return false;
  }
}

export async function sendPushToUser(
  userId: number,
  payload: PushPayload,
  options: { ignoreConnected?: boolean } = {},
): Promise<void> {
  if (!Number.isInteger(userId) || userId <= 0) return;
  const channel = (payload.type as ChannelType) ?? "info";
  const prefs = await loadUserPushPrefs(userId);
  if (!prefs) return;
  if (!userAllowsChannel(prefs, channel)) return;

  // De-dup: if the user is online (any tab/device with an active socket)
  // they're already getting the in-app notification via Socket.IO, so
  // we skip Web Push to avoid double-alerts. Push is for the
  // backgrounded/closed PWA case.
  //
  // `ignoreConnected` overrides this for time-critical reminders (e.g.
  // the 5-minute upcoming-meeting scheduler) where missing the ring
  // because a stale tab is open is worse than ringing twice.
  if (!options.ignoreConnected && (await isUserConnected(userId))) return;

  const vapid = await getVapid();
  if (!vapid) return;

  const subs = await db
    .select()
    .from(pushSubscriptionsTable)
    .where(eq(pushSubscriptionsTable.userId, userId));
  if (subs.length === 0) return;

  // Localise once, up-front: every subscription for this user is the
  // same human, so they all get the same language. The bilingual
  // fields (titleAr/titleEn/bodyAr/bodyEn) win over the legacy
  // single-language ones when both are present.
  const localised = localisePayload(payload, prefs.language);

  // Web Push payloads are capped at 4096 bytes after encryption padding.
  // We aim for ~3500 to leave headroom and truncate the (likely-Arabic,
  // multi-byte) body if needed so a long note doesn't blow up delivery
  // for every device the user owns.
  const MAX_BYTES = 3500;
  // Strip the bilingual fields from the wire payload — the SW only
  // needs the resolved title/body — and override with the localised
  // pair. This also keeps the payload small.
  const {
    titleAr: _ta,
    titleEn: _te,
    bodyAr: _ba,
    bodyEn: _be,
    ...rest
  } = payload;
  void _ta;
  void _te;
  void _ba;
  void _be;
  let safePayload: PushPayload = { ...rest, title: localised.title, body: localised.body };
  let json = JSON.stringify(safePayload);
  if (Buffer.byteLength(json, "utf8") > MAX_BYTES) {
    const overhead = Buffer.byteLength(
      JSON.stringify({ ...safePayload, body: "" }),
      "utf8",
    );
    const budget = Math.max(0, MAX_BYTES - overhead - 1);
    let truncated = safePayload.body;
    while (Buffer.byteLength(truncated, "utf8") > budget && truncated.length > 0) {
      truncated = truncated.slice(0, -1);
    }
    safePayload = { ...safePayload, body: truncated + "…" };
    json = JSON.stringify(safePayload);
  }

  await Promise.all(
    subs.map(async (sub) => {
      try {
        await webpush.sendNotification(
          {
            endpoint: sub.endpoint,
            keys: { p256dh: sub.p256dh, auth: sub.auth },
          },
          json,
          // iOS PWA receives Web Push through APNs and will *silently
          // delay* or coalesce notifications unless we mark them as
          // `urgency: 'high'`. Without this, an order/meeting alert
          // can land minutes after the event with no sound. TTL stays
          // short — a 60-second-old "new order" ping is useless.
          { TTL: 60, urgency: "high" },
        );
        await db
          .update(pushSubscriptionsTable)
          .set({ lastUsedAt: new Date() })
          .where(eq(pushSubscriptionsTable.id, sub.id));
      } catch (err) {
        const status = (err as { statusCode?: number })?.statusCode;
        if (status === 404 || status === 410) {
          await db
            .delete(pushSubscriptionsTable)
            .where(eq(pushSubscriptionsTable.id, sub.id));
          logger.info(
            { userId, endpoint: sub.endpoint, status },
            "Pruned stale push subscription",
          );
        } else {
          logger.warn({ err, userId, status }, "Web Push send failed");
        }
      }
    }),
  );
}

export async function upsertSubscription(
  userId: number,
  input: {
    endpoint: string;
    p256dh: string;
    auth: string;
    userAgent?: string | null;
  },
): Promise<void> {
  const ua = input.userAgent ? input.userAgent.slice(0, 400) : null;
  // If the same browser endpoint already exists under a different user
  // (shared device, account switch), delete the stale row first so the
  // previous user's notifications can't leak onto this device. The
  // unique constraint on `endpoint` would otherwise quietly hand the
  // subscription over via UPDATE.
  const [existing] = await db
    .select({ id: pushSubscriptionsTable.id, userId: pushSubscriptionsTable.userId })
    .from(pushSubscriptionsTable)
    .where(eq(pushSubscriptionsTable.endpoint, input.endpoint))
    .limit(1);
  if (existing && existing.userId !== userId) {
    await db
      .delete(pushSubscriptionsTable)
      .where(eq(pushSubscriptionsTable.id, existing.id));
  }
  await db
    .insert(pushSubscriptionsTable)
    .values({
      userId,
      endpoint: input.endpoint,
      p256dh: input.p256dh,
      auth: input.auth,
      userAgent: ua,
    })
    .onConflictDoUpdate({
      target: pushSubscriptionsTable.endpoint,
      set: {
        userId,
        p256dh: input.p256dh,
        auth: input.auth,
        userAgent: ua,
        lastUsedAt: new Date(),
      },
    });
}

export async function removeSubscription(
  userId: number,
  endpoint: string,
): Promise<void> {
  await db
    .delete(pushSubscriptionsTable)
    .where(
      and(
        eq(pushSubscriptionsTable.userId, userId),
        eq(pushSubscriptionsTable.endpoint, endpoint),
      ),
    );
}
