// src/lib/gsc/site-state.ts — état GSC d'un site, dérivé SANS appel réseau.
import "server-only";
import { and, eq } from "drizzle-orm";
import { account } from "@/lib/db/auth-schema";
import { db } from "@/lib/db/client";
import { sites } from "@/lib/db/domain-schema";
import { userCanAccessSite } from "@/lib/sites/access";
import { hasWebmastersScope } from "./scope";

export type SiteGscFailure =
  | "no-property"
  | "no-authority"
  | "authority-lost"
  | "no-google-account"
  | "missing-scope"
  | "token-refused";

/** `token-refused` n'est pas dérivable : il n'existe qu'après une tentative
 *  réelle auprès de Google. D'où l'exclusion. */
export type SiteGscState = Exclude<SiteGscFailure, "token-refused"> | "ok";

export type SiteStateDeps = {
  /**
   * Revalidation de l'accès de l'AUTORITÉ au site — pas de l'appelant.
   * Nommé au long parce que le raccourci coûte cher : injecter
   * `async () => true` désarme la seule frontière d'autorisation du module et
   * rend un jeton résoluble pour n'importe quel site.
   * @internal Réservé aux tests. Aucun appelant de production ne doit le
   * fournir — surtout pas pour « optimiser » une liste de sites : le contrôle
   * porte sur l'autorité, pas sur l'utilisateur courant.
   */
  canAuthorityAccessSite?: (userId: string, siteId: string) => Promise<boolean>;
};

/** Union DISCRIMINÉE : sur "ok", propriété/utilisateur/compte sont garantis
 *  non-null par le type, ce qui supprime les `as string` côté appelant. */
export type SiteGscStateResult =
  | {
      state: "ok";
      property: string;
      authorityUserId: string;
      authorityAccountId: string;
    }
  | {
      state: Exclude<SiteGscState, "ok">;
      property: string | null;
      authorityUserId: string | null;
      authorityAccountId: string | null;
    };

/** Diagnostic complet SANS réseau : lister dix sites dans l'UI ne doit pas
 *  déclencher dix appels Google. `resolveSiteGscToken` s'appuie dessus puis
 *  ajoute le seul appel qui reste.
 *
 *  ⚠️ Ne vérifie RIEN sur l'appelant : décrit l'état d'un site pour SON
 *  autorité. Tout appelant porteur d'une session doit être gaté séparément par
 *  `currentUserCanAccessSite`. */
export async function deriveSiteGscState(
  siteId: string,
  deps?: SiteStateDeps,
): Promise<SiteGscStateResult> {
  const canAuthorityAccessSite =
    deps?.canAuthorityAccessSite ?? userCanAccessSite;
  const [site] = await db
    .select({
      property: sites.gscProperty,
      authorityUserId: sites.gscAuthorizedUserId,
      pinnedAccountId: sites.gscAuthorizedAccountId,
    })
    .from(sites)
    .where(eq(sites.id, siteId))
    .limit(1);
  const property = site?.property ?? null;
  const authorityUserId = site?.authorityUserId ?? null;
  const pinnedAccountId = site?.pinnedAccountId ?? null;
  /** Un échec porte l'état constaté, jamais une garantie de non-nullité. Il
   *  propage par défaut le compte épinglé : l'interface doit pouvoir dire « le
   *  compte X n'a plus le scope », pas seulement « ça ne marche pas ». */
  const failure = (
    state: Exclude<SiteGscState, "ok">,
    authorityAccountId: string | null = pinnedAccountId,
  ): SiteGscStateResult => ({
    state,
    property,
    authorityUserId,
    authorityAccountId,
  });
  if (!property) return failure("no-property");
  if (!authorityUserId) return failure("no-authority");
  // Revalidation à l'usage : /organization/leave ne déclenche aucun hook
  // Better Auth, donc l'autorité stockée ne peut pas faire foi à elle seule.
  if (!(await canAuthorityAccessSite(authorityUserId, siteId))) {
    return failure("authority-lost", pinnedAccountId);
  }
  // Ordre total (createdAt puis accountId) : jamais de ligne arbitraire. Le
  // multi-compte Google par utilisateur est un flux ENCOURAGÉ ici —
  // `accountLinking.allowDifferentEmails` est activé pour le cas agence.
  const googleAccounts = await db
    .select({ accountId: account.accountId, scope: account.scope })
    .from(account)
    .where(
      and(
        eq(account.userId, authorityUserId),
        eq(account.providerId, "google"),
      ),
    )
    .orderBy(account.createdAt, account.accountId);
  if (googleAccounts.length === 0) return failure("no-google-account");
  // DEUX cas, à ne surtout pas confondre — un `find(...) ?? scoped[0]` les
  // écraserait l'un sur l'autre, `find` rendant `undefined` aussi bien pour un
  // pin absent que pour un pin disparu.
  if (pinnedAccountId !== null) {
    // Pin posé : ce compte-là, ou rien. Retomber en silence sur un autre
    // compte rendrait `ok` un site que Google refusera — l'agence a délié le
    // compte du client 2, aucun autre ne voit `sc-domain:client2.test`. La
    // page de santé afficherait vert, le sync partirait, et l'échec sortirait
    // en 403 opaque dans search-analytics.ts, hors du type. C'est exactement
    // ce que la migration 0071 supprime ; on ne le réintroduit pas un cran
    // plus loin. Rouge tout de suite, sans réseau, réparable en un clic :
    // re-mapper la propriété ré-épingle le bon compte.
    const pinned = googleAccounts.find((a) => a.accountId === pinnedAccountId);
    if (!pinned) return failure("no-google-account");
    if (!hasWebmastersScope(pinned.scope)) return failure("missing-scope");
    return {
      state: "ok",
      property,
      authorityUserId,
      authorityAccountId: pinned.accountId,
    };
  }
  // Aucun pin : chemin légitime, pas une anomalie. Ce sont les mappings
  // antérieurs à l'épinglage et les lignes laissées à NULL par le backfill
  // (aucun compte scopé au moment de la migration). Ordre total, pas de ligne
  // arbitraire.
  const scoped = googleAccounts.filter((a) => hasWebmastersScope(a.scope));
  if (scoped.length === 0) return failure("missing-scope");
  return {
    state: "ok",
    property,
    authorityUserId,
    authorityAccountId: scoped[0].accountId,
  };
}
