// src/lib/gsc/actions.ts — statut GSC de MON compte + mapping par site.
"use server";
import { and, eq, inArray, ne } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { getActiveOrgId, getActiveUserId } from "@/lib/active-org";
import { auth } from "@/lib/auth";
import { account, user } from "@/lib/db/auth-schema";
import { db } from "@/lib/db/client";
import { sites as sitesTable } from "@/lib/db/domain-schema";
import {
  currentUserCanAccessSite,
  getSitesForActiveOrg,
} from "@/lib/sites/access";
import { fetchGscProperties } from "./client";
import { alpha2ToIso3 } from "./country-iso3";
import { gscFailureMessage } from "./failure-message";
import type { QueryCountryImpressions } from "./language";
import { topPageByQuery } from "./pages";
import { hasWebmastersScope } from "./scope";
import {
  querySearchAnalytics,
  querySearchAnalyticsByCountry,
  querySearchAnalyticsPages,
} from "./search-analytics";
import { deriveSiteGscState, type SiteGscState } from "./site-state";
import { resolveSiteGscToken } from "./site-token";
import { syncGscForSite } from "./sync";
import type { GscProperty, GscQueryRow } from "./types";

/** Une ligne de la table des sites sur la page GSC. Le site porte SON autorité :
 *  `isMine` dit si c'est moi, `authorityName` qui c'est sinon. */
export type GscSiteRow = {
  id: string;
  name: string;
  /** Jamais `null` : `getSitesForOrg` normalise l'URL absente en chaîne vide.
   *  Le type le dit, pour que l'UI n'écrive pas une branche morte. */
  url: string;
  gscProperty: string | null;
  authorityUserId: string | null;
  authorityName: string | null;
  isMine: boolean;
  state: SiteGscState;
};

/** Une propriété vérifiée, avec le compte Google qui la voit. C'est ce qui
 *  permet au mapping d'épingler le bon compte sans demander à l'utilisateur
 *  de le choisir : il choisit une propriété, on en déduit le compte. */
export type OwnedGscProperty = GscProperty & { accountId: string };

export type GscStatus = {
  /** État du compte Google de l'utilisateur COURANT — plus celui de l'org. */
  myGoogle: "linked" | "missing-scope" | "none";
  /** Mes propriétés vérifiées, tous comptes Google scopés confondus. */
  properties: OwnedGscProperty[];
  /** `fetchGscProperties` (sites.list) a échoué sur au moins un compte — la
   *  liste est incomplète, on le signale au lieu de l'avaler en silence. */
  propertiesError: boolean;
  /** Les sites auxquels J'AI accès, avec l'état GSC de chacun. */
  sites: GscSiteRow[];
};

/** Mes comptes Google, et parmi eux ceux qui portent le scope Search Console.
 *  Pluriel volontaire : `accountLinking.allowDifferentEmails` est activé pour
 *  le cas agence, où un consultant lie la Search Console de plusieurs clients.
 *  Une SEULE lecture rend les deux chiffres : le filtre de scope est en JS, une
 *  seconde requête ne pourrait que contredire la première. */
async function myGoogleAccounts(
  userId: string,
): Promise<{ scoped: { accountId: string }[]; total: number }> {
  const rows = await db
    .select({ accountId: account.accountId, scope: account.scope })
    .from(account)
    .where(and(eq(account.userId, userId), eq(account.providerId, "google")))
    .orderBy(account.createdAt, account.accountId);
  return {
    scoped: rows
      .filter((r) => hasWebmastersScope(r.scope))
      .map((r) => ({ accountId: r.accountId })),
    total: rows.length,
  };
}

/** Statut du compte Google de l'utilisateur COURANT. `linked` dès qu'AU MOINS
 *  un compte porte le scope — même règle que le backfill de la migration. */
function myGoogleState(
  scopedCount: number,
  totalCount: number,
): "linked" | "missing-scope" | "none" {
  if (scopedCount > 0) return "linked";
  return totalCount > 0 ? "missing-scope" : "none";
}

/** MES propriétés vérifiées, agrégées sur TOUS mes comptes Google scopés, en
 *  gardant pour chacune le compte qui la voit. Partagé par `getGscStatusAction`
 *  (ce que l'écran propose) et `setSiteGscPropertyAction` (ce que le mapping
 *  accepte, et le compte qu'il épingle) : une seule définition de « mes
 *  propriétés », donc aucune divergence possible entre les deux. */
async function myOwnedProperties(userId: string): Promise<{
  properties: OwnedGscProperty[];
  propertiesError: boolean;
  scopedCount: number;
  googleAccountCount: number;
}> {
  const { scoped, total } = await myGoogleAccounts(userId);
  const properties: OwnedGscProperty[] = [];
  const seen = new Set<string>();
  let propertiesError = false;
  for (const { accountId } of scoped) {
    try {
      // INVARIANT : `body` seul, jamais `headers`. Avec des headers, Better
      // Auth fait gagner la session de l'appelant sur `body.userId`
      // (account.mjs:239) ; et sans `accountId` il refait sa propre sélection
      // arbitraire parmi les comptes Google (account.mjs:270).
      const token = await auth.api.getAccessToken({
        body: { providerId: "google", userId, accountId },
      });
      if (!token?.accessToken) {
        // Jeton irrésolvable alors que le scope est en base : refresh token
        // révoqué côté Google (mot de passe changé, consentement retiré). Le
        // signaler comme un échec — sortir en silence afficherait « compte
        // connecté, 0 propriété », et enverrait l'utilisateur chercher la
        // panne dans Search Console. `site-token.ts` traite la même condition
        // en `token-refused` : les deux modules doivent dire la même chose.
        console.error("[gsc] getAccessToken sans accessToken", {
          userId,
          accountId,
        });
        propertiesError = true;
        continue;
      }
      for (const p of await fetchGscProperties(token.accessToken)) {
        // Deux comptes peuvent voir la même propriété : le premier gagne,
        // l'ordre est stable (createdAt puis accountId). La déduplication
        // ignore volontairement `permissionLevel` — un compte `siteOwner`
        // arrivé second ne détrône pas un `siteRestrictedUser` arrivé
        // premier. La stabilité prime : le compte épinglé par un mapping ne
        // doit pas changer parce qu'un autre compte a été lié entre-temps.
        if (seen.has(p.siteUrl)) continue;
        seen.add(p.siteUrl);
        properties.push({ ...p, accountId });
      }
    } catch (err) {
      // Un compte en échec ne doit pas masquer les propriétés des autres.
      console.error("[gsc] échec fetchGscProperties (sites.list)", {
        userId,
        accountId,
        error: err instanceof Error ? err.message : String(err),
      });
      propertiesError = true;
    }
  }
  return {
    properties,
    propertiesError,
    scopedCount: scoped.length,
    googleAccountCount: total,
  };
}

/** Statut de la page d'intégration. Ne parle plus de l'organisation : il parle
 *  de MON compte Google et des sites auxquels J'AI accès. */
export async function getGscStatusAction(): Promise<GscStatus> {
  const userId = await getActiveUserId();
  const [owned, visibleSites] = await Promise.all([
    myOwnedProperties(userId),
    getSitesForActiveOrg(),
  ]);
  const myGoogle = myGoogleState(owned.scopedCount, owned.googleAccountCount);

  const derivedStates = await Promise.all(
    visibleSites.map(async (s) => ({
      site: s,
      derived: await deriveSiteGscState(s.id),
    })),
  );
  // Un seul aller-retour pour tous les noms d'autorité : la dérivation coûte
  // déjà plusieurs requêtes par site, on n'en ajoute pas une par ligne.
  const authorityIds = [
    ...new Set(
      derivedStates
        .map((d) => d.derived.authorityUserId)
        .filter((id): id is string => id !== null),
    ),
  ];
  const authorityNames = new Map<string, string>();
  if (authorityIds.length > 0) {
    const rows = await db
      .select({ id: user.id, name: user.name })
      .from(user)
      .where(inArray(user.id, authorityIds));
    for (const r of rows) authorityNames.set(r.id, r.name);
  }

  const sites: GscSiteRow[] = derivedStates.map(({ site: s, derived }) => ({
    id: s.id,
    name: s.name,
    url: s.plan,
    gscProperty: derived.property,
    authorityUserId: derived.authorityUserId,
    authorityName: derived.authorityUserId
      ? (authorityNames.get(derived.authorityUserId) ?? null)
      : null,
    isMine: derived.authorityUserId === userId,
    state: derived.state,
  }));

  return {
    myGoogle,
    properties: owned.properties,
    propertiesError: owned.propertiesError,
    sites,
  };
}

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

/** Mappe (ou efface) la propriété GSC d'un site ET tamponne l'appelant comme
 *  autorité. C'est aussi le chemin de « reprise en main » : rappeler l'action
 *  avec la propriété déjà en place suffit à devenir la nouvelle autorité.
 *  Aucun accord de l'autorité précédente n'est requis — c'est le chemin de
 *  passation quand un consultant part. */
export async function setSiteGscPropertyAction(
  siteId: string,
  property: string | null,
): Promise<SetSiteGscResult> {
  if (!(await currentUserCanAccessSite(siteId))) {
    return { ok: false, error: "Accès refusé à ce site." };
  }
  const [orgId, userId] = await Promise.all([
    getActiveOrgId(),
    getActiveUserId(),
  ]);
  // Une chaîne vide (ou blanche) VEUT DIRE effacer. Écrite telle quelle, elle
  // s'écrirait `''` en base et deux sites vidés violeraient
  // `sites_gsc_property_uidx` en exception brute — exactement ce que la
  // vérification de collision ci-dessous promet d'éviter. L'argument vient du
  // client : la normalisation est ici, pas à l'écran.
  const prop = property?.trim() || null;
  let matchedAccountId: string | null = null;
  if (prop) {
    // Validée contre MES propriétés vérifiées : je ne peux tamponner mon
    // autorité que sur une propriété que je peux réellement lire. Le compte
    // qui la voit devient le compte épinglé — pas de sélecteur à l'écran.
    const { properties, propertiesError } = await myOwnedProperties(userId);
    const match = properties.find((p) => p.siteUrl === prop);
    if (!match) {
      // Distinguer « Google a répondu, cette propriété n'est pas à toi » de
      // « Google n'a pas répondu ». Confondre les deux pousse l'utilisateur en
      // reprise en main à démapper sa propriété pour « réparer » — donc à
      // effacer l'autorité et le compte épinglé sur une panne transitoire.
      if (propertiesError) {
        return {
          ok: false,
          error:
            "Impossible de vérifier tes propriétés Search Console pour l'instant — réessaie dans un moment.",
        };
      }
      return { ok: false, error: "Propriété GSC non vérifiée ou inconnue." };
    }
    matchedAccountId = match.accountId;
    // Une propriété GSC ne peut être mappée qu'à un seul site, globalement
    // (index unique sites_gsc_property_uidx). On vérifie d'abord pour
    // remonter une erreur métier propre au lieu d'un throw sur la contrainte.
    const [collision] = await db
      .select({ id: sitesTable.id })
      .from(sitesTable)
      .where(and(eq(sitesTable.gscProperty, prop), ne(sitesTable.id, siteId)));
    if (collision) {
      return {
        ok: false,
        error: "Cette propriété GSC est déjà mappée à un autre site.",
      };
    }
  }
  const updated = await db
    .update(sitesTable)
    .set({
      gscProperty: prop,
      // Effacer la propriété efface l'autorité : garder un tampon sans
      // propriété ne veut rien dire. Les deux colonnes bougent ensemble —
      // un utilisateur sans compte épinglé ne résoudrait aucun jeton.
      gscAuthorizedUserId: prop ? userId : null,
      gscAuthorizedAccountId: prop ? matchedAccountId : null,
    })
    .where(and(eq(sitesTable.id, siteId), eq(sitesTable.orgId, orgId)))
    .returning({ id: sitesTable.id });
  if (updated.length === 0) {
    return { ok: false, error: "Site introuvable dans cette organisation." };
  }
  try {
    // Requires a request/static-generation scope; skip outside one (e.g. tests).
    revalidatePath("/gsc");
  } catch {}
  return { ok: true };
}

export type SyncGscResult =
  | {
      ok: true;
      matched: number;
      created: number;
      queries: number;
      /** Per-target-country fetches: synced = countries with data, failed = fetch/mapping failures. */
      countries: { synced: number; failed: number };
      /** Did the query×page fetch (ranking page) succeed? */
      pages: boolean;
    }
  | { ok: false; error: string };

/** Pull the active site's GSC queries and sync them into the research stage:
 *  overlay + promote matching keywords, and auto-create research keywords for
 *  unmatched queries ranking within reach (≤ position 20). Réservé aux
 *  utilisateurs ayant accès AU SITE. */
export async function syncGscKeywordsAction(
  siteId: string,
): Promise<SyncGscResult> {
  if (!(await currentUserCanAccessSite(siteId))) {
    return { ok: false, error: "Accès refusé à ce site." };
  }
  const orgId = await getActiveOrgId();
  const [site] = await db
    .select({ targetCountries: sitesTable.targetCountries })
    .from(sitesTable)
    .where(and(eq(sitesTable.id, siteId), eq(sitesTable.orgId, orgId)));
  if (!site) return { ok: false, error: "Site introuvable." };
  // Propriété + autorité + compte épinglé + jeton frais : un seul appel, le
  // même que le worker. L'échec est typé, donc UI et worker disent la même
  // chose de la même situation.
  const resolved = await resolveSiteGscToken(siteId);
  if (!resolved.ok) {
    return { ok: false, error: gscFailureMessage(resolved.reason) };
  }
  const token = resolved.accessToken;
  const property = resolved.property;
  let rows: Awaited<ReturnType<typeof querySearchAnalytics>>;
  try {
    rows = await querySearchAnalytics(token, property, {
      days: 28,
      rowLimit: 25000,
    });
  } catch {
    return { ok: false, error: "Échec de la synchronisation GSC." };
  }
  // Dimensions supplémentaires — best-effort : un échec pays/pages ne bloque pas le sync global.
  const byCountry: Record<string, GscQueryRow[]> = {};
  let countriesFailed = 0;
  for (const alpha2 of site.targetCountries ?? []) {
    const iso3 = alpha2ToIso3(alpha2);
    if (!iso3) {
      countriesFailed++;
      continue;
    }
    try {
      byCountry[alpha2] = await querySearchAnalytics(token, property, {
        days: 28,
        rowLimit: 25000,
        countryIso3: iso3,
      });
    } catch {
      countriesFailed++;
    }
  }
  let pageMap: Map<string, string> | undefined;
  try {
    pageMap = topPageByQuery(
      await querySearchAnalyticsPages(token, property, {
        days: 28,
        rowLimit: 25000,
      }),
    );
  } catch {
    pageMap = undefined;
  }
  // Per-query country breakdown (all countries) → language attribution. Failure
  // is best-effort: without it the sync falls back to the primary language.
  let queryCountries: Map<string, QueryCountryImpressions[]> | undefined;
  try {
    queryCountries = new Map();
    for (const r of await querySearchAnalyticsByCountry(token, property, {
      days: 28,
      rowLimit: 25000,
    })) {
      const key = r.query.toLowerCase();
      const list = queryCountries.get(key) ?? [];
      list.push({ country: r.country, impressions: r.impressions });
      queryCountries.set(key, list);
    }
  } catch {
    queryCountries = undefined;
  }
  const { matched, created, queries } = await syncGscForSite(siteId, rows, {
    byCountry,
    topPageByQuery: pageMap,
    queryCountries,
  });
  revalidatePath("/keywords/recherche");
  return {
    ok: true,
    matched,
    created,
    queries,
    countries: {
      synced: Object.keys(byCountry).length,
      failed: countriesFailed,
    },
    pages: pageMap != null,
  };
}
