// src/lib/pages/actions-core.ts — implémentation des actions du module Pages
// (crawl, inventaire, assignation manuelle) avec DI (`deps`) pour les tests.
// `server-only` (PAS "use server") : ce module n'est jamais exposé au réseau,
// donc `deps` (assertSite/enqueue) ne peut pas être injecté par un appelant
// malveillant. Les wrappers exportés par actions.ts ("use server") n'exposent
// AUCUN paramètre `deps` — les arguments d'une Server Action sont contrôlés par
// le client (surface réseau) ; injecter la garde de tenancy ou l'enqueue depuis
// là contournerait `assertSiteInActiveOrg` (leçon sécurité, monétisation Task 5).
import "server-only";
import { and, eq, inArray, or, type SQL, sql } from "drizzle-orm";
import { revalidatePath, revalidateTag } from "next/cache";
import {
  type EnqueueInput,
  enqueueAnalyticsJob,
} from "@/lib/analytics/enqueue";
import { db } from "@/lib/db/client";
import {
  analyticsJobs,
  keywords,
  sitePages,
  sites,
} from "@/lib/db/domain-schema";
import { keywordOverviewTag } from "@/lib/keywords/queries";
import { assertSiteInActiveOrg } from "@/lib/sites/assert-site";

/** Une ligne de l'inventaire (table Pages + tooltip réservoir). 100 %
 *  JSON-sérialisable (contrat RSC/action → client, `crawledAt` en ISO). */
export type SitePageRow = {
  id: string;
  url: string;
  status: number;
  title: string | null;
  titleLength: number;
  metaDescription: string | null;
  descriptionLength: number;
  h1: string | null;
  issues: string[];
  keywordCount: number;
  crawledAt: string;
};

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

export type PageDetail = {
  page: SitePageRow & {
    canonical: string | null;
    robots: string | null;
    lang: string | null;
    wordCount: number;
  };
  keywords: { id: string; term: string; source: string | null }[];
};

/** DI confinée à ce module server-only (jamais un argument de Server Action). */
type CoreDeps = {
  /** Garde de tenancy — défaut `assertSiteInActiveOrg` (fail-closed, jette). */
  assertSite?: (siteId: string) => Promise<void>;
  /** POST du job au service analytics — défaut `enqueueAnalyticsJob`. */
  enqueue?: (input: EnqueueInput) => Promise<string>;
};

const DEFAULT_PAGES_LIMIT = 200;
const MAX_PAGES_LIMIT = 1_000;
const PICKER_LIMIT = 20;

/** Échappe les wildcards ILIKE (`%`, `_`, `\`) — une recherche libre matche en
 *  sous-chaîne littérale, jamais en pattern. */
function escapeLike(raw: string): string {
  return raw.replace(/[\\%_]/g, (c) => `\\${c}`);
}

function toRow(r: {
  id: string;
  url: string;
  status: number | null;
  title: string | null;
  metaDescription: string | null;
  h1: string | null;
  issues: string[];
  keywordCount: number;
  crawledAt: Date;
}): SitePageRow {
  return {
    id: r.id,
    url: r.url,
    status: r.status ?? 0,
    title: r.title,
    titleLength: r.title?.length ?? 0,
    metaDescription: r.metaDescription,
    descriptionLength: r.metaDescription?.length ?? 0,
    h1: r.h1,
    issues: r.issues,
    keywordCount: r.keywordCount,
    crawledAt: r.crawledAt.toISOString(),
  };
}

/**
 * Enqueue un crawl du site. Gates : tenancy (`assertSite`), `site.url` présente,
 * aucun job site-crawl `queued|running` déjà en cours pour ce site.
 * Renvoie un code d'erreur stable (traduit côté UI T6) plutôt qu'un message.
 */
export async function startSiteCrawl(
  siteId: string,
  deps: CoreDeps = {},
): Promise<StartSiteCrawlResult> {
  const assertSite = deps.assertSite ?? assertSiteInActiveOrg;
  const enqueue = deps.enqueue ?? enqueueAnalyticsJob;
  await assertSite(siteId);

  const [site] = await db
    .select({ url: sites.url })
    .from(sites)
    .where(eq(sites.id, siteId))
    .limit(1);
  if (!site?.url || site.url.trim() === "") {
    return { ok: false, error: "missing-url" };
  }

  // Garde « 1 crawl actif par site » — lecture directe d'analytics_jobs (le
  // service refuse aussi côté enqueue, double garde côté app pour un message net).
  const [active] = await db
    .select({ id: analyticsJobs.id })
    .from(analyticsJobs)
    .where(
      and(
        eq(analyticsJobs.siteId, siteId),
        eq(analyticsJobs.type, "site-crawl"),
        inArray(analyticsJobs.status, ["queued", "running"]),
      ),
    )
    .limit(1);
  if (active) {
    return { ok: false, error: "already-running" };
  }

  const jobId = await enqueue({ type: "site-crawl", siteId });
  return { ok: true, jobId };
}

/**
 * Job site-crawl actif (`queued|running`) le plus récent pour ce site, ou null.
 * Sert à rattacher la barre de progression à un crawl déjà en cours (reload,
 * autre onglet, enqueue via API). Gate tenancy (`assertSite`) comme les autres.
 */
export async function getActiveSiteCrawl(
  siteId: string,
  deps: CoreDeps = {},
): Promise<{ jobId: string } | null> {
  const assertSite = deps.assertSite ?? assertSiteInActiveOrg;
  await assertSite(siteId);

  const [active] = await db
    .select({ id: analyticsJobs.id })
    .from(analyticsJobs)
    .where(
      and(
        eq(analyticsJobs.siteId, siteId),
        eq(analyticsJobs.type, "site-crawl"),
        inArray(analyticsJobs.status, ["queued", "running"]),
      ),
    )
    .orderBy(sql`${analyticsJobs.createdAt} desc`)
    .limit(1);
  return active ? { jobId: active.id } : null;
}

/** Inventaire d'un site : recherche (url/title), filtre issue, limit. Tri :
 *  pages avec le plus d'issues d'abord, puis URL. */
export async function fetchSitePages(
  siteId: string,
  opts: { q?: string; issue?: string; limit?: number } = {},
  deps: CoreDeps = {},
): Promise<SitePageRow[]> {
  const assertSite = deps.assertSite ?? assertSiteInActiveOrg;
  await assertSite(siteId);

  const conds: SQL[] = [eq(sitePages.siteId, siteId)];
  const q = opts.q?.trim();
  if (q) {
    const like = `%${escapeLike(q)}%`;
    conds.push(
      or(
        sql`${sitePages.url} ILIKE ${like}`,
        sql`${sitePages.title} ILIKE ${like}`,
      ) as SQL,
    );
  }
  if (opts.issue) {
    conds.push(sql`${sitePages.issues} @> ARRAY[${opts.issue}]::text[]`);
  }
  const limit = Math.min(
    MAX_PAGES_LIMIT,
    Math.max(
      1,
      Math.trunc(opts.limit ?? DEFAULT_PAGES_LIMIT) || DEFAULT_PAGES_LIMIT,
    ),
  );

  const rows = await db
    .select({
      id: sitePages.id,
      url: sitePages.url,
      status: sitePages.status,
      title: sitePages.title,
      metaDescription: sitePages.metaDescription,
      h1: sitePages.h1,
      issues: sitePages.issues,
      crawledAt: sitePages.crawledAt,
      keywordCount: sql<number>`count(${keywords.id})::int`,
    })
    .from(sitePages)
    .leftJoin(keywords, eq(keywords.targetPageId, sitePages.id))
    .where(and(...conds))
    .groupBy(sitePages.id)
    .orderBy(
      sql`coalesce(array_length(${sitePages.issues}, 1), 0) desc`,
      sql`${sitePages.url} asc`,
    )
    .limit(limit);
  return rows.map(toRow);
}

/** Détail d'une page (métas complètes + kw assignés). null si la page n'existe
 *  pas ou appartient à un autre site (tenancy). */
export async function fetchPageDetail(
  siteId: string,
  pageId: string,
  deps: CoreDeps = {},
): Promise<PageDetail | null> {
  const assertSite = deps.assertSite ?? assertSiteInActiveOrg;
  await assertSite(siteId);

  const [page] = await db
    .select()
    .from(sitePages)
    .where(and(eq(sitePages.id, pageId), eq(sitePages.siteId, siteId)))
    .limit(1);
  if (!page) return null;

  const kwRows = await db
    .select({
      id: keywords.id,
      term: keywords.term,
      source: keywords.primarySource,
    })
    .from(keywords)
    .where(and(eq(keywords.siteId, siteId), eq(keywords.targetPageId, pageId)));

  return {
    page: {
      ...toRow({ ...page, keywordCount: kwRows.length }),
      canonical: page.canonical,
      robots: page.robots,
      lang: page.lang,
      wordCount: page.wordCount,
    },
    keywords: kwRows,
  };
}

/**
 * Assigne (ou retire) manuellement la page ciblée d'un mot-clé.
 * - `pageId` non-null : la page doit appartenir au MÊME site (tenancy croisée
 *   refusée → `{ ok: false }`) ; source `manual`, score NULL.
 * - `pageId` null : tout NULL — le kw redevient éligible à l'auto-mapping du
 *   prochain crawl (seul un CHOIX manuel est protégé, pas un retrait manuel).
 */
export async function setKeywordTargetPage(
  siteId: string,
  keywordId: string,
  pageId: string | null,
  deps: CoreDeps = {},
): Promise<{ ok: boolean }> {
  const assertSite = deps.assertSite ?? assertSiteInActiveOrg;
  await assertSite(siteId);

  // Le kw doit appartenir au site.
  const [kw] = await db
    .select({ id: keywords.id })
    .from(keywords)
    .where(and(eq(keywords.id, keywordId), eq(keywords.siteId, siteId)))
    .limit(1);
  if (!kw) return { ok: false };

  if (pageId !== null) {
    // La page doit appartenir au MÊME site — sinon un id d'un autre site (autre
    // org) lierait une page hors tenancy.
    const [page] = await db
      .select({ id: sitePages.id })
      .from(sitePages)
      .where(and(eq(sitePages.id, pageId), eq(sitePages.siteId, siteId)))
      .limit(1);
    if (!page) return { ok: false };

    await db
      .update(keywords)
      .set({
        targetPageId: pageId,
        targetPageSource: "manual",
        targetPageScore: null,
      })
      .where(eq(keywords.id, keywordId));
  } else {
    await db
      .update(keywords)
      .set({
        targetPageId: null,
        targetPageSource: null,
        targetPageScore: null,
      })
      .where(eq(keywords.id, keywordId));
  }

  revalidateTag(keywordOverviewTag(siteId), { expire: 0 });
  revalidatePath("/keywords/reservoir");
  return { ok: true };
}

/** Picker : cherche des pages du site par url/title (limit 20). */
export async function searchSitePages(
  siteId: string,
  q: string,
  deps: CoreDeps = {},
): Promise<{ id: string; url: string; title: string | null }[]> {
  const assertSite = deps.assertSite ?? assertSiteInActiveOrg;
  await assertSite(siteId);

  const trimmed = q.trim();
  const conds: SQL[] = [eq(sitePages.siteId, siteId)];
  if (trimmed) {
    const like = `%${escapeLike(trimmed)}%`;
    conds.push(
      or(
        sql`${sitePages.url} ILIKE ${like}`,
        sql`${sitePages.title} ILIKE ${like}`,
      ) as SQL,
    );
  }
  return db
    .select({ id: sitePages.id, url: sitePages.url, title: sitePages.title })
    .from(sitePages)
    .where(and(...conds))
    .orderBy(sql`${sitePages.url} asc`)
    .limit(PICKER_LIMIT);
}
