// Static tenancy guard. Heuristic backstop: flags entry-point files (Server
// Actions + route handlers) that query a per-site table without a tenancy gate.
// Catches "the gate is entirely missing" (the B2/B3 class), NOT subtly-wrong
// scoping. See docs/superpowers/specs/2026-06-22-db-tenancy-hardening-design.md.

export const PER_SITE_TABLES = [
  "sites",
  "keywords",
  "sitePages",
  "keywordThemes",
  "keywordTargets",
  "keywordExclusions",
  "keywordShortlist",
  "backlinks",
  "backlinkOutreach",
  "domainBacklinks",
  "contentItems",
  "contentKeywords",
  "siteAudits",
  "analyticsJobs",
  "gscQuerySnapshots",
  "gscPageSnapshots",
] as const;

export const GATE_HELPERS = [
  "assertSiteInActiveOrg",
  "assertKeywordInActiveOrg",
  "assertContentItemInActiveOrg",
  // Org-scoped filter primitive (used with an eq(org_id, …) WHERE).
  "getActiveOrgId",
  // Member-scoped visible-sites membership check: code resolves the user's
  // accessible sites and verifies the target belongs to them (owner/admin → all,
  // member → granted) before touching the row. A first-class tenancy gate.
  "getSitesForActiveOrg",
  // ⚠️ `currentUserCanAccessSite` (src/lib/sites/access.ts) n'est PAS dans
  // cette liste, et c'est délibéré : il autorise l'accès AU SITE sans vérifier
  // l'organisation ACTIVE (cf. son docstring), il ne satisfait donc pas le
  // contrat de ce backstop. C'est pourtant le gate canonique des actions GSC,
  // où l'autorité est portée par le site et non par l'org. Si tu écris une
  // action per-site gardée par lui et que le scan rougit : n'ajoute PAS
  // `getActiveOrgId` en décoration pour faire taire l'outil — soit tu portes un
  // vrai scope d'organisation (`assertSiteInActiveOrg` / `getSitesForActiveOrg`
  // / un `eq(org_id, …)` réel), soit tu ajoutes une entrée motivée dans
  // ALLOWLIST ci-dessous.
] as const;

// Relative path -> reason it legitimately queries a per-site table without an
// assert helper. Adding an entry is a deliberate, reviewed decision (seeded by
// triage in Step 5). Use forward slashes; match the path as produced by the scan.
export const ALLOWLIST: Record<string, string> = {
  // Cœur du job « site-crawl » : s'exécute dans le WORKER analytics (aucune
  // session, assertSiteInActiveOrg impossible). La tenancy est portée en
  // amont : l'enqueue passe par startSiteCrawlAction (assertSiteInActiveOrg)
  // et le worker ne traite que des jobs déjà scellés avec leur siteId. Toutes
  // les requêtes du runner sont bornées à ce siteId.
  "src/lib/pages/crawl-runner.ts":
    "worker analytics sans session — tenancy garantie à l'enqueue (startSiteCrawlAction), requêtes bornées au siteId du job",
};

export type SourceFile = { path: string; source: string };
export type Violation = { path: string; tables: string[] };

const word = (id: string): RegExp => new RegExp(`\\b${id}\\b`);

export function isEntryPoint(file: SourceFile): boolean {
  if (/[\\/]app[\\/].*[\\/]route\.tsx?$/.test(file.path)) return true;
  return /["']use server["']/.test(file.source);
}

// A per-site table is "queried" when it appears as the argument of a Drizzle
// query/join call: `.from(T)`, `.insert(T)`, `.update(T)`, `.delete(T)`,
// `.into(T)`, or a `.*Join(T, …)`. This matches across line breaks (e.g.
// `db\n  .select()\n  .from(keywords)`), unlike a bare `db.` proximity check.
const queryCall = (id: string): RegExp =>
  new RegExp(
    `\\.(from|into|insert|update|delete|innerJoin|leftJoin|rightJoin|fullJoin)\\(\\s*${id}\\b`,
  );

export function findTenancyViolations(files: SourceFile[]): Violation[] {
  const out: Violation[] = [];
  for (const f of files) {
    if (!isEntryPoint(f)) continue;
    if (f.path in ALLOWLIST) continue;
    const tables = PER_SITE_TABLES.filter((t) => queryCall(t).test(f.source));
    if (tables.length === 0) continue;
    const gated = GATE_HELPERS.some((g) => word(g).test(f.source));
    if (!gated) out.push({ path: f.path, tables: [...tables] });
  }
  return out;
}
