import { afterAll, beforeEach, expect, mock, test } from "bun:test";
import { eq } from "drizzle-orm";
import { assertTestDb } from "@/lib/db/assert-test-db";
import { account, member, organization, user } from "@/lib/db/auth-schema";
import { db } from "@/lib/db/client";
import { sites as sitesTable } from "@/lib/db/domain-schema";
import { WEBMASTERS_READONLY_SCOPE } from "./scope";

// L'accès au SITE remplace le rôle dans l'org comme porte d'entrée : une seule
// variable pilote les deux vérifications (celle de l'appelant et celle de
// l'autorité, que la dérivation refait de son côté).
let CAN_ACCESS = true;
// Google est simulé PAR COMPTE : un jeton distinct par compte Google, et une
// réponse `sites.list` distincte par jeton. C'est la seule façon de prouver
// qu'un compte est bien interrogé avec SON jeton — un stub qui rend toujours
// la même chose laisse passer l'agrégation multi-comptes.
const PROP_SHARED = {
  siteUrl: "sc-domain:example.com",
  permissionLevel: "siteOwner",
};
type GscProps = { siteUrl: string; permissionLevel: string }[] | "throw";
let TOKEN_BY_ACCOUNT: Record<string, string | null> = {};
let PROPS_BY_TOKEN: Record<string, GscProps> = {};
// Arguments du dernier `auth.api.getAccessToken` : sert à verrouiller
// l'invariant « body seul, jamais headers ».
let LAST_TOKEN_ARGS: unknown = null;

mock.module("@/lib/active-org", () => ({
  getActiveOrgId: async () => "gsc-test-org",
  getActiveUserId: async () => "gsc-test-user",
}));
mock.module("@/lib/auth", () => ({
  auth: {
    api: {
      getAccessToken: async (args: { body: { accountId: string } }) => {
        LAST_TOKEN_ARGS = args;
        const token = TOKEN_BY_ACCOUNT[args.body.accountId] ?? null;
        return token ? { accessToken: token } : null;
      },
    },
  },
}));
mock.module("@/lib/sites/access", () => ({
  currentUserCanAccessSite: async () => CAN_ACCESS,
  userCanAccessSite: async () => CAN_ACCESS,
  getSitesForActiveOrg: async () => [
    {
      id: "gsc-test-site",
      name: "S",
      plan: "https://example.com",
      primaryLanguage: "fr",
    },
  ],
}));
mock.module("./client", () => ({
  fetchGscProperties: async (token: string) => {
    const res = PROPS_BY_TOKEN[token];
    if (res === "throw") throw new Error("403 insufficient scopes");
    return res ?? [];
  },
}));

const { getGscStatusAction, setSiteGscPropertyAction, syncGscKeywordsAction } =
  await import("./actions");

/** Un compte Google de l'utilisateur de test. `createdAt` explicite : l'ordre
 *  d'agrégation (createdAt puis accountId) est ce que les tests multi-comptes
 *  verrouillent, il ne doit pas dépendre de la vitesse des insertions. */
async function insertGoogleAccount(
  accountId: string,
  scope: string | null,
  createdAt: Date,
): Promise<void> {
  await db.insert(account).values({
    id: `gsc-test-account-${accountId}`,
    accountId,
    providerId: "google",
    userId: "gsc-test-user",
    scope,
    createdAt,
    updatedAt: new Date(),
  });
}

const T0 = new Date("2026-01-01T00:00:00Z");
const T1 = new Date("2026-02-01T00:00:00Z");
const T2 = new Date("2026-03-01T00:00:00Z");

beforeEach(async () => {
  await assertTestDb();
  CAN_ACCESS = true;
  TOKEN_BY_ACCOUNT = { "google-uid-1": "tok-1" };
  PROPS_BY_TOKEN = { "tok-1": [PROP_SHARED] };
  LAST_TOKEN_ARGS = null;
  await db.delete(sitesTable).where(eq(sitesTable.orgId, "gsc-test-org"));
  await db.delete(sitesTable).where(eq(sitesTable.orgId, "gsc-other-org"));
  await db.delete(member).where(eq(member.organizationId, "gsc-test-org"));
  await db.delete(organization).where(eq(organization.id, "gsc-test-org"));
  await db.delete(organization).where(eq(organization.id, "gsc-other-org"));
  await db.delete(account).where(eq(account.userId, "gsc-test-user"));
  await db.delete(user).where(eq(user.id, "gsc-test-user"));
  await db.delete(user).where(eq(user.id, "gsc-other-user"));
  await db.insert(user).values({
    id: "gsc-test-user",
    name: "U",
    email: `gsc-${Date.now()}@example.com`,
    emailVerified: true,
  });
  await insertGoogleAccount("google-uid-1", WEBMASTERS_READONLY_SCOPE, T0);
  await db.insert(organization).values([
    {
      id: "gsc-test-org",
      name: "O",
      slug: `gsc-${Date.now()}`,
      createdAt: new Date(),
    },
    {
      id: "gsc-other-org",
      name: "O2",
      slug: `gsc-other-${Date.now()}`,
      createdAt: new Date(),
    },
  ]);
  await db.insert(sitesTable).values([
    {
      id: "gsc-test-site",
      orgId: "gsc-test-org",
      ownerUserId: "gsc-test-user",
      name: "S",
      url: "https://example.com",
    },
    {
      id: "gsc-test-site-2",
      orgId: "gsc-test-org",
      ownerUserId: "gsc-test-user",
      name: "S2",
      url: "https://example2.com",
    },
    {
      id: "gsc-foreign-site",
      orgId: "gsc-other-org",
      ownerUserId: "gsc-test-user",
      name: "SF",
      url: "https://foreign.test",
    },
  ]);
});

afterAll(() => {
  // Undo the module mocks so they don't leak into other test files (e.g.
  // client.test.ts imports the real ./client) when the directory is run together.
  // NB: do NOT client.end() — the shared postgres singleton is reused by sibling
  // DB test files in the same pass.
  mock.restore();
});

async function readSite(id: string) {
  const [row] = await db
    .select({
      p: sitesTable.gscProperty,
      a: sitesTable.gscAuthorizedUserId,
      acc: sitesTable.gscAuthorizedAccountId,
    })
    .from(sitesTable)
    .where(eq(sitesTable.id, id));
  return row;
}

// --- Statut : MON compte Google, MES sites ---------------------------------

test("statut nominal : compte scopé → linked, propriétés porteuses du compte", async () => {
  const status = await getGscStatusAction();
  expect(status.myGoogle).toBe("linked");
  expect(status.propertiesError).toBe(false);
  expect(status.properties).toEqual([
    {
      siteUrl: "sc-domain:example.com",
      permissionLevel: "siteOwner",
      accountId: "google-uid-1",
    },
  ]);
});

test("statut : le jeton est résolu par body seul, jamais par headers", async () => {
  await getGscStatusAction();
  expect(LAST_TOKEN_ARGS).toEqual({
    body: {
      providerId: "google",
      userId: "gsc-test-user",
      accountId: "google-uid-1",
    },
  });
});

test("aucun compte Google → myGoogle = none, pas de propriétés", async () => {
  await db.delete(account).where(eq(account.userId, "gsc-test-user"));
  const status = await getGscStatusAction();
  expect(status.myGoogle).toBe("none");
  expect(status.properties).toEqual([]);
  expect(status.propertiesError).toBe(false);
});

test("compte Google sans scope webmasters → myGoogle = missing-scope", async () => {
  await db.delete(account).where(eq(account.userId, "gsc-test-user"));
  await insertGoogleAccount(
    "google-uid-1",
    "https://www.googleapis.com/auth/userinfo.email",
    T0,
  );
  const status = await getGscStatusAction();
  expect(status.myGoogle).toBe("missing-scope");
  expect(status.properties).toEqual([]);
  expect(status.propertiesError).toBe(false);
});

test("échec de sites.list (fetchGscProperties) → propertiesError exposé, pas d'exception", async () => {
  PROPS_BY_TOKEN["tok-1"] = "throw";
  const status = await getGscStatusAction();
  expect(status.myGoogle).toBe("linked");
  expect(status.propertiesError).toBe(true);
  expect(status.properties).toEqual([]);
});

test("jeton irrésolvable (refresh révoqué) → propertiesError, pas un silence", async () => {
  // Le compte porte toujours le scope en base : sans ce drapeau l'écran dirait
  // « connecté, 0 propriété » et enverrait chercher la panne côté Google.
  TOKEN_BY_ACCOUNT["google-uid-1"] = null;
  const status = await getGscStatusAction();
  expect(status.myGoogle).toBe("linked");
  expect(status.properties).toEqual([]);
  expect(status.propertiesError).toBe(true);
});

// --- Multi-comptes Google : la raison d'être de l'agrégation ---------------

test("un compte en échec ne masque pas les propriétés des autres", async () => {
  // Trois comptes, l'échec AU MILIEU : l'ordre compte. Un `catch` qui repart
  // d'une liste vide (l'ancien comportement) détruirait le compte 1 mais pas
  // le compte 3 — ne tester que « le suivant survit » le laisserait passer.
  await insertGoogleAccount("google-uid-2", WEBMASTERS_READONLY_SCOPE, T1);
  await insertGoogleAccount("google-uid-3", WEBMASTERS_READONLY_SCOPE, T2);
  TOKEN_BY_ACCOUNT["google-uid-2"] = "tok-2";
  TOKEN_BY_ACCOUNT["google-uid-3"] = "tok-3";
  PROPS_BY_TOKEN["tok-1"] = [PROP_SHARED];
  PROPS_BY_TOKEN["tok-2"] = "throw";
  PROPS_BY_TOKEN["tok-3"] = [
    { siteUrl: "sc-domain:client3.test", permissionLevel: "siteOwner" },
  ];
  const status = await getGscStatusAction();
  expect(status.propertiesError).toBe(true);
  expect(status.properties).toEqual([
    { ...PROP_SHARED, accountId: "google-uid-1" },
    {
      siteUrl: "sc-domain:client3.test",
      permissionLevel: "siteOwner",
      accountId: "google-uid-3",
    },
  ]);
});

test("propriété vue par deux comptes : le premier de l'ordre stable gagne", async () => {
  await insertGoogleAccount("google-uid-2", WEBMASTERS_READONLY_SCOPE, T1);
  TOKEN_BY_ACCOUNT["google-uid-2"] = "tok-2";
  PROPS_BY_TOKEN["tok-1"] = [PROP_SHARED];
  PROPS_BY_TOKEN["tok-2"] = [
    PROP_SHARED,
    { siteUrl: "sc-domain:client2.test", permissionLevel: "siteFullUser" },
  ];
  const status = await getGscStatusAction();
  expect(status.propertiesError).toBe(false);
  // Dédupliquée (une seule ligne pour la propriété partagée) ET épinglée sur le
  // compte le plus ancien — pas sur le dernier qui la voit.
  expect(status.properties).toEqual([
    { ...PROP_SHARED, accountId: "google-uid-1" },
    {
      siteUrl: "sc-domain:client2.test",
      permissionLevel: "siteFullUser",
      accountId: "google-uid-2",
    },
  ]);

  // Et le mapping épingle ce même compte : statut et mapping lisent la même
  // définition de « mes propriétés ».
  await setSiteGscPropertyAction("gsc-test-site", PROP_SHARED.siteUrl);
  expect((await readSite("gsc-test-site")).acc).toBe("google-uid-1");
});

test("statut : la ligne du site porte son autorité, son nom et isMine", async () => {
  await setSiteGscPropertyAction("gsc-test-site", "sc-domain:example.com");
  const status = await getGscStatusAction();
  expect(status.sites).toEqual([
    {
      id: "gsc-test-site",
      name: "S",
      url: "https://example.com",
      gscProperty: "sc-domain:example.com",
      authorityUserId: "gsc-test-user",
      authorityName: "U",
      isMine: true,
      state: "ok",
    },
  ]);
});

test("statut : site sans mapping → state no-property, sans autorité", async () => {
  const status = await getGscStatusAction();
  expect(status.sites).toHaveLength(1);
  expect(status.sites[0].state).toBe("no-property");
  expect(status.sites[0].authorityUserId).toBeNull();
  expect(status.sites[0].authorityName).toBeNull();
  expect(status.sites[0].isMine).toBe(false);
});

// --- Mapping : gate par accès au site + tamponnage de l'autorité -----------

test("mapping refusé sans accès au site", async () => {
  CAN_ACCESS = false;
  const res = await setSiteGscPropertyAction(
    "gsc-test-site",
    "sc-domain:example.com",
  );
  expect(res).toEqual({ ok: false, error: "Accès refusé à ce site." });
  expect((await readSite("gsc-test-site")).p).toBeNull();
});

test("mapping réussi tamponne l'appelant comme autorité et épingle son compte", async () => {
  const res = await setSiteGscPropertyAction(
    "gsc-test-site",
    "sc-domain:example.com",
  );
  expect(res).toEqual({ ok: true });
  const row = await readSite("gsc-test-site");
  expect(row.p).toBe("sc-domain:example.com");
  expect(row.a).toBe("gsc-test-user");
  expect(row.acc).toBe("google-uid-1");
});

test("effacer la propriété efface l'autorité", async () => {
  await setSiteGscPropertyAction("gsc-test-site", "sc-domain:example.com");
  const res = await setSiteGscPropertyAction("gsc-test-site", null);
  expect(res).toEqual({ ok: true });
  const row = await readSite("gsc-test-site");
  expect(row.p).toBeNull();
  expect(row.a).toBeNull();
  expect(row.acc).toBeNull();
});

test("rejects an unverified property", async () => {
  const res = await setSiteGscPropertyAction(
    "gsc-test-site",
    "sc-domain:evil.com",
  );
  expect(res).toEqual({
    ok: false,
    error: "Propriété GSC non vérifiée ou inconnue.",
  });
});

test("sites.list en panne → message distinct, jamais « non vérifiée »", async () => {
  // Un 429/503 de Google ne doit pas s'entendre « cette propriété n'est pas à
  // toi » : la réaction naturelle serait de démapper, donc de perdre l'autorité
  // et le compte épinglé sur une panne transitoire.
  await setSiteGscPropertyAction("gsc-test-site", "sc-domain:example.com");
  PROPS_BY_TOKEN["tok-1"] = "throw";
  const res = await setSiteGscPropertyAction(
    "gsc-test-site",
    "sc-domain:example.com",
  );
  expect(res).toEqual({
    ok: false,
    error:
      "Impossible de vérifier tes propriétés Search Console pour l'instant — réessaie dans un moment.",
  });
  // La configuration existante est intacte.
  const row = await readSite("gsc-test-site");
  expect(row.p).toBe("sc-domain:example.com");
  expect(row.a).toBe("gsc-test-user");
  expect(row.acc).toBe("google-uid-1");
});

test("chaîne vide = effacer, jamais une propriété '' en base", async () => {
  // L'argument vient du client : deux sites « vidés » avec '' violeraient
  // l'index unique en exception brute au lieu d'une erreur métier.
  await setSiteGscPropertyAction("gsc-test-site", "sc-domain:example.com");
  expect(await setSiteGscPropertyAction("gsc-test-site", "  ")).toEqual({
    ok: true,
  });
  expect(await setSiteGscPropertyAction("gsc-test-site-2", "")).toEqual({
    ok: true,
  });
  expect((await readSite("gsc-test-site")).p).toBeNull();
  expect((await readSite("gsc-test-site-2")).p).toBeNull();
});

test("rejects mapping a property already used by another site (S3)", async () => {
  const first = await setSiteGscPropertyAction(
    "gsc-test-site",
    "sc-domain:example.com",
  );
  expect(first.ok).toBe(true);

  const second = await setSiteGscPropertyAction(
    "gsc-test-site-2",
    "sc-domain:example.com",
  );
  expect(second).toEqual({
    ok: false,
    error: "Cette propriété GSC est déjà mappée à un autre site.",
  });
  const row = await readSite("gsc-test-site-2");
  expect(row.p).toBeNull();
  expect(row.a).toBeNull();
});

test("re-mapper la même propriété = reprise en main, pas une collision", async () => {
  // Pas d'action « Reprendre » séparée : re-poser la propriété déjà en place
  // suffit à redevenir l'autorité. Ici l'autorité de départ est un tiers.
  await db.insert(user).values({
    id: "gsc-other-user",
    name: "Autre",
    email: `gsc-other-${Date.now()}@example.com`,
    emailVerified: true,
  });
  await db
    .update(sitesTable)
    .set({
      gscProperty: "sc-domain:example.com",
      gscAuthorizedUserId: "gsc-other-user",
      gscAuthorizedAccountId: "google-uid-other",
    })
    .where(eq(sitesTable.id, "gsc-test-site"));

  // Avant la reprise : la ligne appartient à un TIERS, et son nom est résolu.
  const before = await getGscStatusAction();
  expect(before.sites[0].isMine).toBe(false);
  expect(before.sites[0].authorityUserId).toBe("gsc-other-user");
  expect(before.sites[0].authorityName).toBe("Autre");

  const again = await setSiteGscPropertyAction(
    "gsc-test-site",
    "sc-domain:example.com",
  );
  expect(again).toEqual({ ok: true });
  const row = await readSite("gsc-test-site");
  expect(row.a).toBe("gsc-test-user");
  expect(row.acc).toBe("google-uid-1");
});

test("site hors de l'organisation active → introuvable, même avec accès", async () => {
  // `currentUserCanAccessSite` relit le rôle dans l'org PROPRIÉTAIRE du site :
  // le filtre orgId de l'update reste la garde qui empêche d'écrire ailleurs.
  const res = await setSiteGscPropertyAction(
    "gsc-foreign-site",
    "sc-domain:example.com",
  );
  expect(res).toEqual({
    ok: false,
    error: "Site introuvable dans cette organisation.",
  });
});

// --- Sync -------------------------------------------------------------------

test("sync refusé sans accès au site", async () => {
  CAN_ACCESS = false;
  const res = await syncGscKeywordsAction("gsc-test-site");
  expect(res).toEqual({ ok: false, error: "Accès refusé à ce site." });
});

test("sync sans propriété mappée → message issu de gscFailureMessage", async () => {
  const res = await syncGscKeywordsAction("gsc-test-site");
  expect(res).toEqual({
    ok: false,
    error: "Mappe d'abord une propriété GSC pour ce site.",
  });
});
