// src/lib/sites/actions.ts — server actions for sites.
"use server";
import { randomUUID } from "node:crypto";
import { and, eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { cookies } from "next/headers";
import { getActiveOrgId, getActiveUserId } from "@/lib/active-org";
import {
  assertWithinQuotaTx,
  getOrgQuotaLimits,
  lockOrgForQuota,
  QuotaError,
  type QuotaExceeded,
  quotaPayload,
} from "@/lib/billing/quotas";
import { chunk } from "@/lib/db/chunk";
import { db } from "@/lib/db/client";
import {
  backlinks as backlinksTable,
  linkSources as linkSourcesTable,
  siteAccess as siteAccessTable,
  sites as sitesTable,
} from "@/lib/db/domain-schema";
import type { Site } from "@/lib/keywords/types";
import { DEFER_COOKIE } from "@/lib/onboarding/wizard-gate";
import { hasFullSiteAccess } from "./access";
import { type SiteInput, siteInputSchema } from "./schema";

export type CreateSiteResult =
  | { ok: true; site: Site }
  | { ok: false; error: string; quota?: QuotaExceeded };

/** Create a site/project under the active org (validated + sanitized via Zod). */
export async function createSiteAction(
  raw: SiteInput,
): Promise<CreateSiteResult> {
  const parsed = siteInputSchema.safeParse(raw);
  if (!parsed.success) {
    return {
      ok: false,
      error: parsed.error.issues[0]?.message ?? "Données invalides",
    };
  }

  // Owners/admins manage org projects; members are confined to granted sites.
  if (!(await hasFullSiteAccess())) {
    throw new Error("Forbidden");
  }

  const { name, url, primaryLanguage, languages, targetCountries } =
    parsed.data;
  const [orgId, ownerUserId] = await Promise.all([
    getActiveOrgId(),
    getActiveUserId(),
  ]);
  const id = randomUUID();
  // Plan quota: a site IS a project. Sec audit A2/P8 — the check and the
  // insert now share ONE transaction, gated by lockOrgForQuota, so a
  // concurrent createSiteAction for the same org can't read the same
  // pre-write count and both pass. Limit resolved BEFORE the transaction
  // (its own connection) — assertWithinQuotaTx only touches `tx` from here
  // on, see that function's doc for why (pool deadlock otherwise).
  const limits = await getOrgQuotaLimits(orgId);
  try {
    await db.transaction(async (tx) => {
      await lockOrgForQuota(tx, orgId);
      await assertWithinQuotaTx(tx, orgId, "projects", limits.projects, 1);
      await tx.insert(sitesTable).values({
        id,
        orgId,
        ownerUserId,
        name,
        url,
        primaryLanguage,
        languages,
        targetCountries,
      });
      // Le créateur garde un droit explicite sur son site. Owners et admins
      // contournent site_access par leur rôle, mais l'enregistrer rend le
      // modèle honnête : une rétrogradation en member ne doit pas faire
      // perdre l'accès à un site qu'on a soi-même créé.
      await tx
        .insert(siteAccessTable)
        .values({ siteId: id, userId: ownerUserId });
      // Activate the backlink reservoir: target every global link source of
      // the org as a prospect for the new site (mirrors db:seed for the demo
      // site).
      const sources = await tx
        .select({ id: linkSourcesTable.id })
        .from(linkSourcesTable)
        .where(eq(linkSourcesTable.orgId, orgId));
      // The org catalog can grow unbounded → chunk under the Postgres
      // bind-parameter cap (same transaction, stays atomic).
      for (const slice of chunk(sources, 500)) {
        await tx.insert(backlinksTable).values(
          slice.map((s) => ({
            id: `${id}__${s.id}`,
            linkSourceId: s.id,
            siteId: id,
            status: "prospect" as const,
          })),
        );
      }
    });
  } catch (e) {
    if (e instanceof QuotaError) {
      return { ok: false, error: e.message, quota: quotaPayload(e) };
    }
    throw e;
  }
  // Créer un site est LE geste qui doit rouvrir le parcours d'entrée : le
  // nouveau site n'a ni Search Console, ni mots-clés, ni plan, et ses drapeaux
  // dérivés repassent tous à faux. Sans cet effacement, un report posé le matin
  // (cookie de session, `/onboarding/defer`) survivait à la création de
  // l'après-midi et le gate laissait passer en silence — l'utilisateur
  // retombait sur l'annuaire de liens que le parcours existe pour remplacer.
  //
  // Fail-silent : le site EST créé à ce stade. Un incident sur le magasin de
  // cookies ne doit pas transformer une création réussie en erreur ; au pire le
  // report tient jusqu'à la fermeture du navigateur.
  try {
    (await cookies()).delete({ name: DEFER_COOKIE, path: "/" });
  } catch (e) {
    console.error("[sites] defer cookie clear failed:", e);
  }
  revalidatePath("/", "layout");
  return { ok: true, site: { id, name, plan: url, primaryLanguage } };
}

export type MutateSiteResult = { ok: true } | { ok: false; error: string };

/** Update an existing site, scoped to the active org (validated via Zod). */
export async function updateSiteAction(
  id: string,
  raw: SiteInput,
): Promise<MutateSiteResult> {
  const parsed = siteInputSchema.safeParse(raw);
  if (!parsed.success) {
    return {
      ok: false,
      error: parsed.error.issues[0]?.message ?? "Données invalides",
    };
  }
  // Owners/admins manage org projects; members are confined to granted sites.
  if (!(await hasFullSiteAccess())) {
    throw new Error("Forbidden");
  }
  const orgId = await getActiveOrgId();
  const { name, url, primaryLanguage, languages, targetCountries } =
    parsed.data;
  const updated = await db
    .update(sitesTable)
    .set({ name, url, primaryLanguage, languages, targetCountries })
    .where(and(eq(sitesTable.id, id), eq(sitesTable.orgId, orgId)))
    .returning({ id: sitesTable.id });
  if (updated.length === 0) return { ok: false, error: "Projet introuvable" };
  revalidatePath("/", "layout");
  return { ok: true };
}

/** Delete a site (cascades to its keywords & backlinks), scoped to active org. */
export async function deleteSiteAction(id: string): Promise<MutateSiteResult> {
  // Owners/admins manage org projects; members are confined to granted sites.
  if (!(await hasFullSiteAccess())) {
    throw new Error("Forbidden");
  }
  const orgId = await getActiveOrgId();
  const deleted = await db
    .delete(sitesTable)
    .where(and(eq(sitesTable.id, id), eq(sitesTable.orgId, orgId)))
    .returning({ id: sitesTable.id });
  if (deleted.length === 0) return { ok: false, error: "Projet introuvable" };
  revalidatePath("/", "layout");
  return { ok: true };
}
