import "server-only";
import { after } from "next/server";

/**
 * Inscription des leads dans Listmonk (self-hosted).
 *
 * Voir `docs/superpowers/specs/2026-07-31-listmonk-capture-design.md`.
 *
 * PRINCIPE DIRECTEUR — un échec ici ne doit JAMAIS être visible du visiteur ni
 * retarder sa réponse. Ces formulaires servent d'abord à capter un lead
 * commercial ; perdre ce lead à cause d'une newsletter indisponible serait
 * absurde. D'où : appel non bloquant, timeout court, erreurs avalées, et sortie
 * silencieuse si la configuration manque.
 *
 * CONSENTEMENT — deux statuts distincts se superposent dans Listmonk, et les
 * confondre coûte un 500 :
 *
 *   - `status` au niveau ABONNÉ  : `enabled` | `blocklisted` uniquement.
 *     Y écrire "unconfirmed" fait échouer l'insertion côté Postgres
 *     (`invalid input value for enum subscriber_status`).
 *   - `subscription_status` par LISTE : `unconfirmed` | `confirmed` |
 *     `unsubscribed`. C'est celui-là qui porte le consentement, et on ne le
 *     fixe pas directement : `preconfirm_subscriptions: false` le place en
 *     `unconfirmed`.
 *
 * On envoie donc `enabled` (l'abonné n'est pas sur liste noire) et on laisse
 * Listmonk envoyer son email de confirmation. Sans clic, la souscription reste
 * `unconfirmed` et aucune campagne ne part. Ce clic vaut consentement, ce qui
 * évite d'ajouter une case sur les formulaires.
 */

export type SubscriberSource =
  | "contact"
  | "comparateur"
  | "simulateur"
  | "newsletter";

export type SubscriberAttribs = Record<
  string,
  string | number | boolean | null | undefined
>;

type CaptureInput = {
  email: string;
  /** La société. Les outils ne collectent pas de nom de personne. */
  name: string;
  source: SubscriberSource;
  attribs?: SubscriberAttribs;
};

type Config = {
  baseUrl: string;
  authHeader: string;
  listId: number;
};

const TIMEOUT_MS = 3_000;

/** Évite de répéter l'avertissement de configuration à chaque soumission. */
let configWarningEmitted = false;

/**
 * Lit la configuration. Retourne `null` dès qu'une variable manque : le site
 * doit tourner sans Listmonk configuré, exactement comme avant l'introduction
 * de ce module.
 *
 * En revanche cette sortie n'est PAS silencieuse — première version du module,
 * corrigée après coup. Un module qui ne fait rien sans le dire est
 * indiagnosticable : on ne peut pas distinguer « pas exécuté » de « exécuté et
 * échoué ». On nomme donc les variables manquantes, jamais leurs valeurs, et
 * une seule fois par instance pour ne pas inonder les logs.
 */
function readConfig(): Config | null {
  const baseUrl = process.env.LISTMONK_URL?.trim();
  const user = process.env.LISTMONK_API_USER?.trim();
  const key = process.env.LISTMONK_API_KEY?.trim();
  const rawListId = process.env.LISTMONK_LIST_ID?.trim();
  const listId = Number(rawListId);

  const missing: string[] = [];
  if (!baseUrl) missing.push("LISTMONK_URL");
  if (!user) missing.push("LISTMONK_API_USER");
  if (!key) missing.push("LISTMONK_API_KEY");
  if (!rawListId) missing.push("LISTMONK_LIST_ID");
  else if (!Number.isInteger(listId) || listId <= 0) {
    missing.push(`LISTMONK_LIST_ID (valeur non entière positive)`);
  }

  if (missing.length) {
    if (!configWarningEmitted) {
      configWarningEmitted = true;
      console.warn(
        `[listmonk] désactivé, configuration incomplète : ${missing.join(", ")}`,
      );
    }
    return null;
  }

  // Le bloc `missing` ci-dessus garantit que les trois chaînes sont non vides,
  // mais TypeScript ne peut pas le déduire d'un tableau : on l'affirme ici.
  return {
    // Sans cette normalisation, un LISTMONK_URL avec slash final produirait
    // des URLs en `//api/...` que Listmonk rejette.
    baseUrl: baseUrl!.replace(/\/+$/, ""),
    authHeader: `token ${user!}:${key!}`,
    listId,
  };
}

/** Retire les clés vides : Listmonk stocke `attribs` tel quel, autant qu'il soit propre. */
function cleanAttribs(attribs: SubscriberAttribs = {}): SubscriberAttribs {
  return Object.fromEntries(
    Object.entries(attribs).filter(
      ([, v]) => v !== undefined && v !== null && v !== "",
    ),
  );
}

async function listmonkFetch(
  config: Config,
  path: string,
  init: RequestInit = {},
): Promise<Response> {
  return fetch(`${config.baseUrl}${path}`, {
    ...init,
    headers: {
      Authorization: config.authHeader,
      "Content-Type": "application/json",
      ...init.headers,
    },
    signal: AbortSignal.timeout(TIMEOUT_MS),
    cache: "no-store",
  });
}

/**
 * Sur 409, fusionne les nouveaux attributs sans écraser le reste de la fiche.
 *
 * On passe par `search=` et non par `query=`. Ce dernier est injecté tel quel
 * dans une clause SQL côté Listmonk : il exige la permission
 * `subscribers:sql_query` — accorder l'exécution de SQL arbitraire pour
 * retrouver un email serait disproportionné — et il imposerait d'échapper
 * nous-mêmes l'adresse. `search=` est une recherche texte, couverte par le
 * simple `subscribers:get`, et ne construit aucune requête.
 *
 * En contrepartie `search=` est approximatif : il peut renvoyer des fiches dont
 * seul le nom contient la chaîne. On ne prend donc JAMAIS le premier résultat,
 * on cherche l'égalité stricte sur l'email.
 */
async function mergeExisting(
  config: Config,
  email: string,
  attribs: SubscriberAttribs,
): Promise<void> {
  const lookup = await listmonkFetch(
    config,
    `/api/subscribers?search=${encodeURIComponent(email)}&page=1&per_page=100`,
  );

  if (!lookup.ok) {
    console.warn("[listmonk] recherche de l'abonné existant:", lookup.status);
    return;
  }

  const found = await lookup.json();
  const subscriber = (found?.data?.results ?? []).find(
    (s: { email?: string }) => s.email?.toLowerCase() === email,
  );
  if (!subscriber?.id) {
    console.warn("[listmonk] 409 reçu mais abonné introuvable à la relecture");
    return;
  }

  // Fusion unidirectionnelle : nos clés écrasent, toutes les autres survivent —
  // y compris celles saisies à la main dans l'admin Listmonk.
  const merged = { ...(subscriber.attribs ?? {}), ...attribs };

  const update = await listmonkFetch(
    config,
    `/api/subscribers/${subscriber.id}`,
    {
      method: "PUT",
      body: JSON.stringify({
        email: subscriber.email,
        name: subscriber.name,
        // On ne renvoie PAS `status` : rien ne doit pouvoir faire passer un
        // abonné de `unconfirmed` à `confirmed` dans son dos.
        lists: [...new Set([...(subscriber.lists?.map((l: { id: number }) => l.id) ?? []), config.listId])],
        attribs: merged,
      }),
    },
  );

  if (!update.ok) {
    console.warn("[listmonk] mise à jour de l'abonné:", update.status);
  }
}

async function subscribe(input: CaptureInput): Promise<void> {
  const config = readConfig();
  if (!config) return;

  const email = input.email.trim().toLowerCase();
  if (!email) return;

  const attribs = cleanAttribs({ source: input.source, ...input.attribs });

  const response = await listmonkFetch(config, "/api/subscribers", {
    method: "POST",
    body: JSON.stringify({
      email,
      name: input.name?.trim() || email,
      // Statut ABONNÉ, pas statut de souscription — voir l'en-tête du module.
      status: "enabled",
      lists: [config.listId],
      // Place la souscription à la liste en `unconfirmed` : c'est ce qui
      // déclenche le double opt-in de Listmonk.
      preconfirm_subscriptions: false,
      attribs,
    }),
  });

  if (response.ok) {
    // Trace de succès volontaire : sans elle, un silence total ne permet pas de
    // distinguer « a fonctionné » de « n'a jamais tourné ». Aucune donnée
    // personnelle, seulement l'origine.
    console.log(`[listmonk] inscrit source=${input.source}`);
    return;
  }

  if (response.status === 409) {
    await mergeExisting(config, email, attribs);
    console.log(`[listmonk] fusionné source=${input.source}`);
    return;
  }

  // Volontairement sans l'URL ni les identifiants : ces logs partent chez
  // l'hébergeur.
  console.warn(
    `[listmonk] inscription refusée (${response.status}) source=${input.source}`,
  );
}

/**
 * Point d'entrée unique. Ne rend pas de promesse et ne jette jamais : les server
 * actions l'appellent sans `await` et poursuivent leur cours immédiatement.
 */
export function captureSubscriber(input: CaptureInput): void {
  after(async () => {
    try {
      await subscribe(input);
    } catch (error) {
      const reason =
        error instanceof Error && error.name === "TimeoutError"
          ? `timeout ${TIMEOUT_MS}ms`
          : error instanceof Error
            ? error.message
            : "erreur inconnue";
      console.warn(`[listmonk] échec source=${input.source}: ${reason}`);
    }
  });
}
