/**
 * POST /api/admin/backfill-dedup
 *
 * Retroactively marks historical SiteConversion duplicates using the
 * same dedup keys the live write path now uses. Never deletes rows --
 * sets isDuplicateOf + invalidatedReason = 'backfill_dedup' on
 * duplicates so analytics queries (WHERE isDuplicateOf IS NULL) exclude
 * them without data loss. Idempotent: rows already having isDuplicateOf
 * IS NOT NULL are excluded from candidates; re-running yields the same
 * result.
 *
 * Dedup keys:
 *   phone_call         primary: (visitorId, normalizedPhoneDigits, +-60s)
 *                      fallback when digits absent: (visitorId, +-60s)
 *   appointment_booked (visitorId, formProvider, +-90s)
 *   form_submitted     primary: submissionId exact
 *                      fallback: (visitorId, normalizePageUrl, +-180s)
 *
 * mode: "form_cross_stream" (separate opt-in)
 *   Pairs each submit_event row (form-phone.js native listener) to the
 *   nearest t.js:form_submit row (t.source.js) on the same
 *   normalizePageUrl(pageUrl) within +-2s. 1:1, never reusing a t.js
 *   row already paired. Does NOT key on visitorId or sessionId because
 *   a first-visit race mints separate _meseo_vid values per script.
 *   Marks submit_event as isDuplicateOf the t.js row.
 *
 * mode: "sweep"
 *   No brandId required. Discovers all brands with qualifying rows and
 *   runs the selected dedupMode across all of them.
 *   dedupMode "form_cross_stream" (default): cross-stream pairer per brand.
 *   dedupMode "default": standard conversionType dedup per brand.
 *   No ratio gate -- cross-stream dedup is deterministic.
 *   since defaults to 2025-01-01 in sweep mode (full history).
 *
 * Body (single-brand):
 *   brandId         string    required
 *   mode            string    optional; "form_cross_stream"
 *   conversionTypes string[]  optional; default ["form_submitted","phone_call","appointment_booked"]
 *   since           string    ISO date; default 90 days ago
 *   until           string    ISO date; default now
 *   dryRun          boolean   default true
 *
 * Body (sweep):
 *   mode            "sweep"   required
 *   dedupMode       string    optional; "form_cross_stream" (default) | "default"
 *   since           string    ISO date; default 2025-01-01
 *   until           string    ISO date; default now
 *   dryRun          boolean   default true
 *
 * dryRun sweep response: { ok, dryRun, mode, brands[], totalScanned, totalWouldMark }
 * live sweep response:   { ok, dryRun:false, mode, marked[], totals }
 *
 * Gated by requireSuperadmin() -- callable from the browser console at
 * app.meseoapp.com while logged in as a superadmin.
 */

import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireSuperadmin } from "@/lib/admin-auth";
import {
  canonicalConversionType,
  CONVERSION_TYPES,
  type CanonicalConversionType,
} from "@/lib/conversion-taxonomy";
import { normalizePageUrl } from "@/lib/url-utils";

export const dynamic = "force-dynamic";
export const maxDuration = 300;

// Dedup window constants.
// PHONE/BOOKING match conversion-resolver.ts for the primary (digits/provider) key.
// FORM uses a same-UTC-calendar-day window (keyed by day string in makeWinKey)
// matching the live write-path and CRM per-lead dedup semantics.
const PHONE_WINDOW_MS    = 60_000;       // matches findPhoneDuplicateId
const BOOKING_WINDOW_MS  = 90_000;       // matches findBookingDuplicateId
const FORM_WINDOW_MS     = 86_400_000;   // 1 day -- same UTC calendar day (key includes date)

function utcDateKey(ts: Date): string {
  return `${ts.getUTCFullYear()}-${String(ts.getUTCMonth() + 1).padStart(2, "0")}-${String(ts.getUTCDate()).padStart(2, "0")}`;
}

const BATCH_SIZE    = 1_000; // rows per DB fetch
const WRITE_CHUNK   = 100;   // updates per $transaction
const SAMPLE_LIMIT  = 20;    // max sample IDs per type in response
const DEFAULT_MS    = 90 * 24 * 60 * 60 * 1_000;

const SUPPORTED: CanonicalConversionType[] = [
  "form_submitted",
  "phone_call",
  "appointment_booked",
];

const SWEEP_DEFAULT_SINCE = new Date("2025-01-01T00:00:00.000Z");

interface BackfillBody {
  brandId?: string;
  mode?: "form_cross_stream" | "sweep";
  dedupMode?: "form_cross_stream" | "default";
  conversionTypes?: string[];
  since?: string;
  until?: string;
  dryRun?: boolean;
}

interface TypeResult {
  candidatesScanned: number;
  wouldMark: number;
  sampleIds: string[];
}

type Row = {
  id: string;
  visitorId: string;
  sessionId: string;
  pageUrl: string;
  conversionType: string;
  timestamp: Date;
  phoneNumber: string | null;
  formProvider: string | null;
  providerCategory: string | null;
  submissionId: string | null;
};

type Keeper = { id: string; timestamp: Date; providerCategory: string | null };

// All stored conversionType values that canonicalize to a given type.
// Includes the canonical name itself (for rows written after the taxonomy
// migration) plus every legacy/raw source string (for pre-migration rows).
function storedTypesFor(canonical: CanonicalConversionType): string[] {
  return Array.from(
    new Set([canonical, ...(CONVERSION_TYPES[canonical]?.sources ?? [])]),
  );
}

function winMsFor(canonical: CanonicalConversionType): number {
  if (canonical === "phone_call")         return PHONE_WINDOW_MS;
  if (canonical === "appointment_booked") return BOOKING_WINDOW_MS;
  return FORM_WINDOW_MS;
}

// Key used for time-window dedup. Returns null only when the row lacks
// any usable identifier.
//
// phone_call  -- primary key uses visitorId + normalizedDigits (matches live
//               helper); falls back to visitorId-only when historical rows
//               have no phone number stored. The 60s window prevents false
//               dedup across distinct calls from the same visitor.
//
// form_submitted -- keyed uniformly on visitorId + pageUrl. Both write paths
//               (t.source.js->collect and form-phone.js->conversion) share
//               visitorId (localStorage) but may write different sessionIds.
//               Using a single visitor-keyed bucket ensures both paths
//               collapse into one row per real submission within the 3-min
//               window regardless of which sessionId each path stored.
function makeWinKey(row: Row, canonical: CanonicalConversionType): string | null {
  if (canonical === "phone_call") {
    if (!row.visitorId || row.visitorId === "anon") return null;
    const digits = row.phoneNumber ?? "";
    if (digits.length >= 7) {
      // Primary: precise dedup -- same visitor, same number.
      return `${row.visitorId}\x00${digits}`;
    }
    // Fallback: historical rows often lack stored phone digits. Use
    // visitorId alone; the 60s window makes false dedup unlikely.
    return `${row.visitorId}`;
  }
  if (canonical === "appointment_booked") {
    if (!row.visitorId || row.visitorId === "anon") return null;
    return `${row.visitorId}\x00${row.formProvider ?? ""}`;
  }
  // form_submitted -- visitor + normalised path + UTC calendar day.
  // Matches CRM per-lead dedup: one counted lead per person per page per day.
  // The date is embedded in the key so different-day submissions from the same
  // visitor are never collapsed. winMs = FORM_WINDOW_MS = 1 day keeps keepers
  // in memory long enough to match all same-day rows in chronological batches.
  if (!row.visitorId || row.visitorId === "anon") return null;
  return `${row.visitorId}\x00${normalizePageUrl(row.pageUrl)}\x00${utcDateKey(row.timestamp)}`;
}

// ── form_cross_stream ─────────────────────────────────────────────────────────
//
// submit_event rows are written by form-phone.js's native submit listener
// (detectionMethod = "submit_event"). t.js:form_submit rows are written by
// t.source.js's native submit listener (detectionMethod = "t.js:form_submit").
// Both listeners fire for the same DOM submit event, producing two rows within
// ~2s. They cannot be collapsed by visitorId because a first-visit race mints
// separate _meseo_vid values per script, and not by sessionId because the two
// scripts use different sessionStorage keys.
//
// The reliable pairing signal is: same normalizePageUrl(pageUrl) + |timeDiff| <= 2s.
// The submit_event row is marked as duplicate of the canonical t.js row.
const CROSS_STREAM_WINDOW_MS = 2_000;  // ±2s -- simultaneous dual-fire window
const CROSS_STREAM_ROW_CAP   = 20_000; // rows fetched per side; raise with cursor for larger brands

interface CrossStreamScore {
  brandId: string;
  scanned: number;
  toMark: Array<{ dupId: string; keeperId: string }>;
  cappedOut: boolean;
}

async function scoreCrossStream(brandId: string, since: Date, until: Date): Promise<CrossStreamScore> {
  // intentionally unfiltered: admin-only endpoint; needs raw or all-time counts
  const allRows = await prisma.siteConversion.findMany({
    where: {
      brandId,
      conversionType: { in: storedTypesFor("form_submitted") },
      isDuplicateOf: null,
      invalidatedReason: null,
      timestamp: { gte: since, lte: until },
      detectionMethod: { in: ["submit_event", "t.js:form_submit"] },
    },
    select: { id: true, pageUrl: true, timestamp: true, detectionMethod: true },
    orderBy: { timestamp: "asc" },
    take: CROSS_STREAM_ROW_CAP,
  });

  const cappedOut = allRows.length === CROSS_STREAM_ROW_CAP;
  const scanned = allRows.length;

  const submitEventRows = allRows.filter((r) => r.detectionMethod === "submit_event");
  const tjsRows         = allRows.filter((r) => r.detectionMethod === "t.js:form_submit");

  type TjsEntry = { id: string; ts: number; claimed: boolean };
  const tjsByPath = new Map<string, TjsEntry[]>();
  for (const r of tjsRows) {
    const key = normalizePageUrl(r.pageUrl);
    if (!tjsByPath.has(key)) tjsByPath.set(key, []);
    tjsByPath.get(key)!.push({ id: r.id, ts: r.timestamp.getTime(), claimed: false });
  }

  const toMark: Array<{ dupId: string; keeperId: string }> = [];
  for (const se of submitEventRows) {
    const key = normalizePageUrl(se.pageUrl);
    const bucket = tjsByPath.get(key);
    if (!bucket) continue;
    let best: TjsEntry | null = null;
    let bestDiff = Infinity;
    const seTs = se.timestamp.getTime();
    for (const c of bucket) {
      if (c.claimed) continue;
      const diff = Math.abs(seTs - c.ts);
      if (diff <= CROSS_STREAM_WINDOW_MS && diff < bestDiff) { best = c; bestDiff = diff; }
    }
    if (!best) continue;
    best.claimed = true;
    toMark.push({ dupId: se.id, keeperId: best.id });
  }

  return { brandId, scanned, toMark, cappedOut };
}

async function applyCrossStream(toMark: Array<{ dupId: string; keeperId: string }>): Promise<number> {
  let marked = 0;
  for (let i = 0; i < toMark.length; i += WRITE_CHUNK) {
    const chunk = toMark.slice(i, i + WRITE_CHUNK);
    await prisma.$transaction(
      chunk.map((p) =>
        prisma.siteConversion.update({
          where: { id: p.dupId },
          data: { isDuplicateOf: p.keeperId, invalidatedReason: "backfill_dedup" },
        }),
      ),
    );
    marked += chunk.length;
  }
  return marked;
}

async function processFormCrossStream(
  brandId: string,
  since: Date,
  until: Date,
  dryRun: boolean,
): Promise<TypeResult> {
  const score = await scoreCrossStream(brandId, since, until);
  if (!dryRun && score.toMark.length > 0) {
    await applyCrossStream(score.toMark);
  }
  return {
    candidatesScanned: score.scanned,
    wouldMark: score.toMark.length,
    sampleIds: score.toMark.slice(0, SAMPLE_LIMIT).map((p) => p.dupId),
  };
}

async function processType(
  brandId: string,
  canonical: CanonicalConversionType,
  since: Date,
  until: Date,
  dryRun: boolean,
): Promise<TypeResult> {
  const winMs = winMsFor(canonical);
  const storedTypes = storedTypesFor(canonical);

  let candidatesScanned = 0;
  let wouldMark = 0;
  const sampleIds: string[] = [];

  // submissionId dedup (form_submitted only, exact match, no time window).
  // Never pruned -- a submissionId collision is always a duplicate regardless
  // of how far apart the rows are.
  const subKeepers = new Map<string, Keeper>();
  // Time-window dedup. Keepers are evicted when they can no longer match
  // any row in the current or future batches (timestamp lag > winMs).
  const winKeepers = new Map<string, Keeper>();

  let cursor: string | undefined;

  while (true) {
    // intentionally unfiltered: admin-only endpoint; needs raw or all-time counts
    const rows = await prisma.siteConversion.findMany({
      where: {
        brandId,
        conversionType: { in: storedTypes },
        isDuplicateOf: null,
        invalidatedReason: null,
        timestamp: { gte: since, lte: until },
      },
      select: {
        id: true,
        visitorId: true,
        sessionId: true,
        pageUrl: true,
        conversionType: true,
        timestamp: true,
        phoneNumber: true,
        formProvider: true,
        providerCategory: true,
        submissionId: true,
      },
      orderBy: [{ timestamp: "asc" }, { id: "asc" }],
      take: BATCH_SIZE,
      ...(cursor ? { skip: 1, cursor: { id: cursor } } : {}),
    });

    if (rows.length === 0) break;
    cursor = rows[rows.length - 1].id;
    candidatesScanned += rows.length;

    // Evict keepers whose timestamp is farther than winMs before this
    // batch's earliest row -- they can't match anything remaining.
    const batchFloor = rows[0].timestamp.getTime();
    for (const [k, v] of winKeepers) {
      if (batchFloor - v.timestamp.getTime() > winMs) winKeepers.delete(k);
    }

    type PendingWrite = {
      dupId: string;
      keeperId: string;
      promotePC: boolean;
      reason: string;
    };
    const pending: PendingWrite[] = [];

    for (const row of rows) {
      let found: Keeper | null = null;
      let foundViaSubmissionId = false;

      // 1. submissionId exact match -- primary dedup for form_submitted.
      if (canonical === "form_submitted" && row.submissionId) {
        const sub = subKeepers.get(row.submissionId) ?? null;
        if (sub) { found = sub; foundViaSubmissionId = true; }
      }

      // 2. Time-window match -- fallback for form_submitted, sole path for
      //    phone_call and appointment_booked.
      if (!found) {
        const wk = makeWinKey(row, canonical);
        if (wk) {
          const candidate = winKeepers.get(wk);
          if (candidate) {
            const diff = row.timestamp.getTime() - candidate.timestamp.getTime();
            // diff >= 0: current row is at or after the keeper (ASC order guard).
            if (diff >= 0 && diff <= winMs) found = candidate;
          }
        }
      }

      if (found) {
        wouldMark++;
        if (sampleIds.length < SAMPLE_LIMIT) sampleIds.push(row.id);
        // If kept row has providerCategory null and this duplicate has "form",
        // promote the kept row's providerCategory. Update in-memory immediately
        // so subsequent duplicates in the same batch see the updated value.
        const promotePC = found.providerCategory === null && row.providerCategory === "form";
        if (promotePC) found.providerCategory = "form";
        // submissionId-matched dups keep the generic label; time-window form dups
        // get the CRM-style reason matching the live write-path invalidatedReason.
        const reason =
          canonical === "form_submitted" && !foundViaSubmissionId
            ? "same_visitor_same_path_day"
            : "backfill_dedup";
        pending.push({ dupId: row.id, keeperId: found.id, promotePC, reason });
      } else {
        // This row is canonical -- register as keeper.
        const entry: Keeper = {
          id: row.id,
          timestamp: row.timestamp,
          providerCategory: row.providerCategory,
        };
        if (canonical === "form_submitted" && row.submissionId) {
          subKeepers.set(row.submissionId, entry);
        }
        const wk = makeWinKey(row, canonical);
        if (wk) winKeepers.set(wk, entry);
      }
    }

    if (!dryRun && pending.length > 0) {
      for (let i = 0; i < pending.length; i += WRITE_CHUNK) {
        const chunk = pending.slice(i, i + WRITE_CHUNK);
        await prisma.$transaction(
          chunk.flatMap((p) => [
            prisma.siteConversion.update({
              where: { id: p.dupId },
              data: { isDuplicateOf: p.keeperId, invalidatedReason: p.reason },
            }),
            ...(p.promotePC
              ? [
                  prisma.siteConversion.update({
                    where: { id: p.keeperId },
                    data: { providerCategory: "form" },
                  }),
                ]
              : []),
          ]),
        );
      }
    }
  }

  return { candidatesScanned, wouldMark, sampleIds };
}

export async function POST(req: Request) {
  const denied = await requireSuperadmin();
  if (denied) return denied;

  const body = (await req.json().catch(() => ({}))) as BackfillBody;
  const dryRun = body.dryRun !== false;

  // ── Sweep mode ──────────────────────────────────────────────────────────────
  if (body.mode === "sweep") {
    const dedupMode = body.dedupMode ?? "form_cross_stream";
    const since = body.since ? new Date(body.since) : SWEEP_DEFAULT_SINCE;
    const until = body.until ? new Date(body.until) : new Date();

    if (isNaN(since.getTime()) || isNaN(until.getTime())) {
      return NextResponse.json({ ok: false, error: "Invalid since or until date" }, { status: 400 });
    }
    if (since >= until) {
      return NextResponse.json({ ok: false, error: "since must be before until" }, { status: 400 });
    }

    if (dedupMode === "form_cross_stream") {
      // Discover all brands that have qualifying cross-stream rows.
      // intentionally unfiltered: admin-only endpoint; needs raw or all-time counts
      const brandRows = await prisma.siteConversion.findMany({
        where: {
          conversionType: { in: storedTypesFor("form_submitted") },
          isDuplicateOf: null,
          invalidatedReason: null,
          timestamp: { gte: since, lte: until },
          detectionMethod: { in: ["submit_event", "t.js:form_submit"] },
        },
        select: { brandId: true },
        distinct: ["brandId"],
      });

      const brandIds = brandRows.map((r) => r.brandId);

      if (brandIds.length === 0) {
        return NextResponse.json({
          ok: true, dryRun, mode: "sweep", dedupMode,
          since: since.toISOString(), until: until.toISOString(),
          brands: [], totalScanned: 0, totalWouldMark: 0,
          hint: "No qualifying cross-stream rows found across any brand.",
        });
      }

      const scores = await Promise.all(brandIds.map((id) => scoreCrossStream(id, since, until)));

      type SweepEntry = { brandId: string; scanned: number; wouldMark: number; cappedOut: boolean };
      const brands: SweepEntry[] = scores
        .map((s) => ({ brandId: s.brandId, scanned: s.scanned, wouldMark: s.toMark.length, cappedOut: s.cappedOut }))
        .sort((a, b) => b.wouldMark - a.wouldMark);

      const totalScanned   = brands.reduce((n, b) => n + b.scanned, 0);
      const totalWouldMark = brands.reduce((n, b) => n + b.wouldMark, 0);

      if (dryRun) {
        return NextResponse.json({
          ok: true, dryRun: true, mode: "sweep", dedupMode,
          since: since.toISOString(), until: until.toISOString(),
          brands, totalScanned, totalWouldMark,
          hint: `Dry run: ${brandIds.length} brands scanned, ${totalScanned} rows total, would mark ${totalWouldMark} cross-stream duplicates. Pass dryRun: false to apply.`,
        });
      }

      type MarkedEntry = { brandId: string; scanned: number; marked: number; cappedOut: boolean };
      const markedEntries: MarkedEntry[] = [];
      for (const s of scores) {
        const marked = s.toMark.length > 0 ? await applyCrossStream(s.toMark) : 0;
        markedEntries.push({ brandId: s.brandId, scanned: s.scanned, marked, cappedOut: s.cappedOut });
      }
      const totalMarked = markedEntries.reduce((n, e) => n + e.marked, 0);

      return NextResponse.json({
        ok: true, dryRun: false, mode: "sweep", dedupMode,
        since: since.toISOString(), until: until.toISOString(),
        marked: markedEntries,
        totals: { brandsApplied: markedEntries.length, totalMarked },
        hint: `Applied: ${markedEntries.length} brands, marked ${totalMarked} cross-stream duplicates.`,
      });
    }

    // dedupMode "default" -- standard conversionType dedup across all brands.
    const convTypes = SUPPORTED;
    // intentionally unfiltered: admin-only endpoint; needs raw or all-time counts
    const defaultBrandRows = await prisma.siteConversion.findMany({
      where: {
        conversionType: { in: convTypes.flatMap(storedTypesFor) },
        isDuplicateOf: null,
        invalidatedReason: null,
        timestamp: { gte: since, lte: until },
      },
      select: { brandId: true },
      distinct: ["brandId"],
    });
    const defaultBrandIds = defaultBrandRows.map((r) => r.brandId);

    if (defaultBrandIds.length === 0) {
      return NextResponse.json({
        ok: true, dryRun, mode: "sweep", dedupMode,
        since: since.toISOString(), until: until.toISOString(),
        brands: [], totalScanned: 0, totalWouldMark: 0,
        hint: "No qualifying rows found across any brand.",
      });
    }

    type DefaultEntry = { brandId: string; scanned: number; wouldMark: number };
    const defaultBrands: DefaultEntry[] = [];
    for (const brandId of defaultBrandIds) {
      let scanned = 0;
      let wouldMark = 0;
      for (const canonical of convTypes) {
        const r = await processType(brandId, canonical, since, until, dryRun);
        scanned   += r.candidatesScanned;
        wouldMark += r.wouldMark;
      }
      defaultBrands.push({ brandId, scanned, wouldMark });
    }
    defaultBrands.sort((a, b) => b.wouldMark - a.wouldMark);
    const defTotalScanned   = defaultBrands.reduce((n, b) => n + b.scanned, 0);
    const defTotalWouldMark = defaultBrands.reduce((n, b) => n + b.wouldMark, 0);

    return NextResponse.json({
      ok: true, dryRun, mode: "sweep", dedupMode,
      since: since.toISOString(), until: until.toISOString(),
      brands: defaultBrands, totalScanned: defTotalScanned, totalWouldMark: defTotalWouldMark,
      hint: dryRun
        ? `Dry run: ${defaultBrandIds.length} brands, ${defTotalScanned} rows, would mark ${defTotalWouldMark}. Pass dryRun: false to apply.`
        : `Applied: ${defaultBrandIds.length} brands, marked ${defTotalWouldMark} duplicates.`,
    });
  }

  // ── Single-brand mode ───────────────────────────────────────────────────────
  if (!body.brandId) {
    return NextResponse.json(
      { ok: false, error: "brandId is required (or pass mode: 'sweep' to scan all brands)" },
      { status: 400 },
    );
  }

  const since = body.since ? new Date(body.since) : new Date(Date.now() - DEFAULT_MS);
  const until = body.until ? new Date(body.until) : new Date();

  if (isNaN(since.getTime()) || isNaN(until.getTime())) {
    return NextResponse.json({ ok: false, error: "Invalid since or until date" }, { status: 400 });
  }
  if (since >= until) {
    return NextResponse.json({ ok: false, error: "since must be before until" }, { status: 400 });
  }

  // form_cross_stream is a standalone mode: pairs submit_event rows to their
  // nearest t.js:form_submit row by normalised pageUrl + +-2s window.
  // Runs instead of the conversionTypes loop when mode is set.
  if (body.mode === "form_cross_stream") {
    const result = await processFormCrossStream(body.brandId, since, until, dryRun);
    return NextResponse.json({
      ok: true,
      dryRun,
      mode: "form_cross_stream",
      brandId: body.brandId,
      since: since.toISOString(),
      until: until.toISOString(),
      candidatesScanned: result.candidatesScanned,
      wouldMark: result.wouldMark,
      sampleIds: result.sampleIds,
      hint: dryRun
        ? `Dry run: scanned ${result.candidatesScanned} rows, would mark ${result.wouldMark} submit_event duplicates. Pass dryRun: false to apply.`
        : `Applied: scanned ${result.candidatesScanned} rows, marked ${result.wouldMark} submit_event rows as duplicates.`,
    });
  }

  const requestedTypes = (body.conversionTypes ?? SUPPORTED) as string[];
  const types = requestedTypes
    .map((t) => canonicalConversionType(t))
    .filter(
      (t): t is CanonicalConversionType =>
        t !== null && (SUPPORTED as CanonicalConversionType[]).includes(t),
    );

  if (types.length === 0) {
    return NextResponse.json(
      {
        ok: false,
        error: `conversionTypes must include at least one of: ${SUPPORTED.join(", ")}`,
      },
      { status: 400 },
    );
  }

  const results: Record<string, TypeResult> = {};
  for (const canonical of types) {
    results[canonical] = await processType(body.brandId, canonical, since, until, dryRun);
  }

  const totalScanned = Object.values(results).reduce((s, r) => s + r.candidatesScanned, 0);
  const totalWouldMark = Object.values(results).reduce((s, r) => s + r.wouldMark, 0);

  return NextResponse.json({
    ok: true,
    dryRun,
    brandId: body.brandId,
    since: since.toISOString(),
    until: until.toISOString(),
    types,
    totalScanned,
    totalWouldMark,
    results,
    hint: dryRun
      ? `Dry run: scanned ${totalScanned} rows, would mark ${totalWouldMark} as duplicates. Pass dryRun: false to apply.`
      : `Applied: scanned ${totalScanned} rows, marked ${totalWouldMark} as duplicates.`,
  });
}
