// src/lib/sites/access.ts — per-member site access control.
// Owners/admins manage the whole org (see ALL sites, connect GSC, assign access).
// A `member` sees ONLY the sites granted to them via `site_access` —
// secure by default (no row = no access).
import "server-only";
import { and, eq } from "drizzle-orm";
import { cache } from "react";
import { getActiveOrgId } from "@/lib/active-org";
import { auth } from "@/lib/auth";
import { member } from "@/lib/db/auth-schema";
import { db } from "@/lib/db/client";
import { siteAccess, sites } from "@/lib/db/domain-schema";
import type { Site } from "@/lib/keywords/types";
import { getSitesForOrg } from "./queries";

export type Caller = { userId: string; orgId: string; role: string | null };

// Test-only caller injection. Lets DB suites that exercise the real assert path
// supply an authenticated caller WITHOUT process-global mock.module (which leaks
// across sibling test files). No effect outside NODE_ENV==='test'.
let testCallerOverride: { caller: Caller | null } | null = null;
export function __setTestCaller(c: Caller | null): void {
  if (process.env.NODE_ENV !== "test") {
    throw new Error("__setTestCaller is test-only");
  }
  testCallerOverride = { caller: c };
}
export function __clearTestCaller(): void {
  testCallerOverride = null;
}

/** Current session user + active org + role in that org. Null when there's no
 *  request scope (tests / internal callers — `headers()` throws), no session, or
 *  no active org. Deduped per request. */
const currentCaller = cache(async (): Promise<Caller | null> => {
  if (process.env.NODE_ENV === "test" && testCallerOverride) {
    return testCallerOverride.caller;
  }
  let session: Awaited<ReturnType<typeof auth.api.getSession>>;
  try {
    // Dynamic import: outside a request scope (tests), or when a test has mocked
    // next/headers, this resolves to null instead of failing at module load.
    const { headers } = await import("next/headers");
    session = await auth.api.getSession({ headers: await headers() });
  } catch {
    return null;
  }
  if (!session) return null;
  let orgId: string;
  try {
    // getActiveOrgId() self-heals sessions born without an active org (fresh
    // signup race — see src/lib/active-org.ts). Reading
    // session.session.activeOrganizationId directly here would return null
    // during that window, making hasFullSiteAccess() report false for a
    // legit owner. It's react-`cache()`-wrapped, so calling it here costs
    // nothing extra when the same request already resolved it elsewhere
    // (the (app) layout does).
    orgId = await getActiveOrgId();
  } catch {
    return null;
  }
  const [m] = await db
    .select({ role: member.role })
    .from(member)
    .where(
      and(eq(member.organizationId, orgId), eq(member.userId, session.user.id)),
    )
    .limit(1);
  return { userId: session.user.id, orgId, role: m?.role ?? null };
});

const isFullAccess = (role: string | null): boolean =>
  role === "owner" || role === "admin";

/** True when the current user manages the whole org (owner/admin): sees every
 *  site, can connect GSC, can assign site access. */
export async function hasFullSiteAccess(): Promise<boolean> {
  const c = await currentCaller();
  return c != null && isFullAccess(c.role);
}

/** Site IDs granted to a user (rows in `site_access`). */
export const getGrantedSiteIds = cache(
  async (userId: string): Promise<string[]> => {
    const rows = await db
      .select({ siteId: siteAccess.siteId })
      .from(siteAccess)
      .where(eq(siteAccess.userId, userId));
    return rows.map((r) => r.siteId);
  },
);

/** Accès à un site pour un userId EXPLICITE — utilisable hors requête (worker,
 *  broker de jeton GSC), là où `currentUserCanAccessSite` ne peut pas aller
 *  puisqu'il lit `headers()`. L'organisation n'est pas l'org active : elle est
 *  déduite de `sites.org_id`, sinon un appel hors session n'aurait aucun
 *  contexte. Fail-closed : site inconnu ou non-membre → false. */
export async function userCanAccessSite(
  userId: string,
  siteId: string,
): Promise<boolean> {
  const [site] = await db
    .select({ orgId: sites.orgId })
    .from(sites)
    .where(eq(sites.id, siteId))
    .limit(1);
  if (!site) return false;
  const [m] = await db
    .select({ role: member.role })
    .from(member)
    .where(
      and(eq(member.organizationId, site.orgId), eq(member.userId, userId)),
    )
    .limit(1);
  if (!m) return false;
  if (isFullAccess(m.role)) return true;
  const [row] = await db
    .select({ siteId: siteAccess.siteId })
    .from(siteAccess)
    .where(and(eq(siteAccess.siteId, siteId), eq(siteAccess.userId, userId)))
    .limit(1);
  return Boolean(row);
}

/** Sites the CURRENT user may see in the active org: owner/admin → all, member →
 *  only granted. Use everywhere a user-facing site list is built. Deduped/req. */
export const getSitesForActiveOrg = cache(async (): Promise<Site[]> => {
  const orgId = await getActiveOrgId();
  const all = await getSitesForOrg(orgId);
  const c = await currentCaller();
  if (!c || isFullAccess(c.role)) return all;
  const granted = new Set(await getGrantedSiteIds(c.userId));
  return all.filter((s) => granted.has(s.id));
});

/** Fail-closed per-site access check pour la session courante. Délègue à
 *  `userCanAccessSite` : une seule règle d'accès dans la base de code.
 *  ⚠️ Ne vérifie PAS que le site est dans l'org ACTIVE — c'est le rôle de
 *  `assertSiteInActiveOrg` / du scope org de la requête. Le rôle est relu dans
 *  l'org PROPRIÉTAIRE du site (`sites.org_id`), pas dans l'org active : un
 *  utilisateur simple `member` de l'org active A mais `owner` d'une org B
 *  obtient `true` sur un site de B. Utilisé seul comme garde, ce helper
 *  laisserait donc fuiter les données de B dans une session A. */
export async function currentUserCanAccessSite(
  siteId: string,
): Promise<boolean> {
  const c = await currentCaller();
  // Fail closed: pas de caller résoluble = pas de session/rôle de confiance.
  if (!c) return false;
  return userCanAccessSite(c.userId, siteId);
}
