import { NextResponse } from "next/server";
import { getSession } from "@/lib/auth/session";
import {
  getPlan,
  updatePlan,
  type UpdatablePlanFields,
} from "@/lib/db/queries/plans";
import { isLegacyFlatPlan, isPerSeatPlan } from "@/lib/plans-shared";
import { logActivity } from "@/lib/db/audit";
import { triggerVitrineRebuild } from "@/lib/vitrine-rebuild";

// Route handler de mutation des plans. Première mutation autorisée par le
// panel sur la DB en direct (cf. AGENTS.md "Périmètre des mutations admin").
//
// Mitigations :
// - Re-check is_superadmin avant d'agir (défense en profondeur, en plus
//   du proxy.ts).
// - Whitelist des champs au niveau Route Handler ET au niveau query
//   (defense en profondeur). Un caller ne peut pas glisser `password_hash`
//   ou un autre champ non prévu.
// - Audit avant l'UPDATE : on lit l'état précédent et on log un diff
//   structuré dans activities_log (table également auditable depuis le
//   journal). Si l'audit échoue, l'UPDATE est annulé (le throw remonte).

// Partial : `yearly_price` est volontairement absent — calculé serveur (cf.
// commentaire au niveau du champ ci-dessous + mf_ask #77).
const VALIDATORS: Partial<
  Record<keyof UpdatablePlanFields, (v: unknown) => unknown | undefined>
> = {
  name: (v) =>
    typeof v === "string" && v.trim().length > 0 && v.length <= 100
      ? v.trim()
      : undefined,
  // description : peut être null (champ effacé) ou string non vide ≤ 2 KB
  description: (v) => {
    if (v === null) return null;
    if (typeof v === "string") {
      const trimmed = v.trim();
      if (trimmed.length === 0) return null;
      if (trimmed.length > 2000) return undefined;
      return trimmed;
    }
    return undefined;
  },
  monthly_price: (v) => coerceMoney(v),
  // Pas de validator `yearly_price` : la convention "yearly = monthly × 10"
  // (2 mois offerts) est figée côté app (cf. mf_ask #77 + PlanService::
  // getVisiblePlans). Si le PATCH inclut `monthly_price`, on recalcule
  // `yearly_price` automatiquement plus bas (defense in depth, voir le bloc
  // après la boucle de validation).
  max_technicians: (v) => coerceCount(v),
  max_sites: (v) => coerceCount(v),
  max_machines: (v) => coerceCount(v),
  max_templates: (v) => coerceCount(v),
  max_admins: (v) => coerceCount(v),
  features: (v) =>
    Array.isArray(v)
      ? v
          .map((x) => (typeof x === "string" ? x.trim() : ""))
          .filter((x) => x.length > 0 && x.length <= 100)
          .slice(0, 30)
      : undefined,
  visible: (v) => (typeof v === "boolean" ? v : undefined),
  cta_label: (v) => coerceNullableString(v, 64),
  highlighted: (v) => (typeof v === "boolean" ? v : undefined),
  marketing_bullets: (v) =>
    Array.isArray(v)
      ? v
          .map((x) => (typeof x === "string" ? x.trim() : ""))
          .filter((x) => x.length > 0 && x.length <= 200)
          .slice(0, 20)
      : undefined,
};

// Champ optionnel : null = effacement explicite, string trimmée vide = null,
// string > maxLen = rejeté (validation côté UI doit avoir tenu).
function coerceNullableString(v: unknown, maxLen: number): string | null | undefined {
  if (v === null) return null;
  if (typeof v === "string") {
    const trimmed = v.trim();
    if (trimmed.length === 0) return null;
    if (trimmed.length > maxLen) return undefined;
    return trimmed;
  }
  return undefined;
}

// Décision UX 2026-05-10 : prix entiers uniquement (pas de centimes). Les
// abonnements HVAC se vendent en € ronds — éviter les `699.52 €/mois` moches
// + supprime les artefacts float quand on calcule yearly = monthly × 10.
function coerceMoney(v: unknown): number | undefined {
  const n = typeof v === "number" ? v : Number(v);
  if (!Number.isInteger(n) || n < 0 || n > 999_999) return undefined;
  return n;
}

function coerceCount(v: unknown): number | undefined {
  const n = typeof v === "number" ? v : Number(v);
  // Convention métier : -1 = illimité (pas de quota). 0 = limite à zéro
  // (techniquement valide, signifie que la fonctionnalité est désactivée).
  // Plage : -1 puis 0…100000.
  if (!Number.isInteger(n) || n < -1 || n > 100_000) return undefined;
  return n;
}

export async function PATCH(
  req: Request,
  { params }: { params: Promise<{ id: string }> },
) {
  const session = await getSession();
  if (!session?.user.is_superadmin) {
    return NextResponse.json(
      { success: false, error: "FORBIDDEN" },
      { status: 403 },
    );
  }

  const { id: idStr } = await params;
  const id = Number.parseInt(idStr, 10);
  if (!Number.isFinite(id) || id <= 0) {
    return NextResponse.json(
      { success: false, error: "INVALID_ID" },
      { status: 400 },
    );
  }

  const raw = (await req.json().catch(() => null)) as
    | Record<string, unknown>
    | null;
  if (!raw || typeof raw !== "object") {
    return NextResponse.json(
      { success: false, error: "INVALID_BODY" },
      { status: 400 },
    );
  }

  const validated: UpdatablePlanFields = {};
  const rejected: string[] = [];
  for (const key of Object.keys(VALIDATORS) as (keyof UpdatablePlanFields)[]) {
    if (!(key in raw)) continue;
    const validator = VALIDATORS[key];
    if (!validator) continue;
    const cleaned = validator(raw[key]);
    if (cleaned === undefined) {
      rejected.push(key);
      continue;
    }
    // Cast nécessaire — TS ne suit pas la corrélation key/type dans
    // la map de validators. La validation au-dessus garantit la cohérence.
    (validated as Record<string, unknown>)[key] = cleaned;
  }

  // Recalcul automatique de yearly_price si monthly_price change (cf. mf_ask
  // #77). Convention figée côté app : 10 mois payés au lieu de 12. Le panel ne
  // permet plus de saisir yearly_price librement (champ read-only dans la
  // modale) ; ce recalcul couvre aussi les cas où un caller bypass l'UI.
  // monthly_price est garanti entier (cf. coerceMoney), donc * 10 reste entier.
  if (typeof validated.monthly_price === "number") {
    validated.yearly_price = validated.monthly_price * 10;
  }

  if (rejected.length > 0) {
    return NextResponse.json(
      {
        success: false,
        error: "VALIDATION_FAILED",
        rejected,
      },
      { status: 400 },
    );
  }

  if (Object.keys(validated).length === 0) {
    return NextResponse.json(
      { success: false, error: "NO_FIELDS" },
      { status: 400 },
    );
  }

  // Lecture du before pour audit, puis update, puis lecture du after.
  // Si le plan n'existe pas, 404 sans muter.
  const before = await getPlan(id);
  if (!before) {
    return NextResponse.json(
      { success: false, error: "NOT_FOUND" },
      { status: 404 },
    );
  }

  // Garde-fou per-seat (cf. mf_ask #128) : les plans facturés au siège (flex)
  // sont pilotés par `pricing_tiers`, pas par les colonnes prix/quotas de
  // `plans`. Les muter ici n'aurait aucun effet côté app et tromperait
  // l'audit. L'UI les rend déjà read-only ; on bloque aussi le PATCH direct.
  if (isPerSeatPlan(before.code)) {
    return NextResponse.json(
      {
        success: false,
        error: "PER_SEAT_READONLY",
        message:
          "Plan per-seat piloté par pricing_tiers — non éditable depuis le panel.",
      },
      { status: 409 },
    );
  }

  // Garde-fou legacy (cf. mf_ask #134) : les plans flat dépréciés
  // (starter/pro/enterprise) sont conservés en base pour les tenants
  // historiques mais ne doivent plus être ré-exposés au signup public depuis
  // la bascule per-seat (ils sont passés `visible=0`, `flex` est le seul plan
  // vendable). On bloque toute mutation — notamment un retour `visible=1` —
  // pour éviter qu'un admin les remette en vente par erreur. L'UI les rend déjà
  // read-only ; on bloque aussi le PATCH direct (défense en profondeur).
  if (isLegacyFlatPlan(before.code)) {
    return NextResponse.json(
      {
        success: false,
        error: "LEGACY_PLAN_READONLY",
        message:
          "Plan legacy déprécié (bascule per-seat) — non éditable depuis le panel.",
      },
      { status: 409 },
    );
  }

  try {
    // L'audit est inséré AVANT l'UPDATE — si l'audit plante, on n'a pas
    // muté. Si l'UPDATE plante après, on a un audit "tentative" qui pointe
    // vers l'absence d'effet (la session admin verra l'incohérence en
    // rechargeant la liste des plans).
    await logActivity({
      type: "superadmin.plan_update",
      title: `Modification du plan ${before.code}`,
      description: JSON.stringify({
        plan_id: id,
        plan_code: before.code,
        changes: validated,
      }),
      user_id: session.user.id,
    });

    await updatePlan(id, validated);
    const after = await getPlan(id);

    // Fire-and-forget : rebuild missioflow-site (Astro statique) pour que la
    // page tarifs publique reflète le nouveau wording. Cf. mf_ask #70 +
    // src/lib/vitrine-rebuild.ts. Ne JAMAIS bloquer la réponse au browser
    // sur ce trigger — si GitLab est down, l'admin a quand même son UPDATE.
    void triggerVitrineRebuild(before.code).catch((err) => {
      console.warn("[plan-update] vitrine rebuild failed:", err);
    });

    return NextResponse.json({ success: true, data: after });
  } catch (err) {
    return NextResponse.json(
      {
        success: false,
        error: "DB_ERROR",
        message: err instanceof Error ? err.message : "unknown",
      },
      { status: 500 },
    );
  }
}
