/**
 * POST /api/admin/backfill-site-tag-rollup
 *
 * Computes SiteTagDailyRollup rows for ONE brand over a date range,
 * ONE DAY AT A TIME from raw TrackedEvent. Each day's 8 queries are
 * bounded to a single UTC day and hit the (trackedSiteId, eventType,
 * timestamp) index, so they're fast even for high-volume brands
 * (366K events/day).
 *
 * Why one-day-at-a-time: querying 30 days in a single groupBy on a
 * brand with 10M+ rows causes the 60s Vercel timeout. One day at a
 * time keeps each query under ~500ms.
 *
 * Body (single-brand):
 *   brandId   string   required
 *   since     string   ISO date; default 90 days ago
 *   until     string   ISO date; default today (UTC)
 *   maxDays   number   max days to process per call; default 30, max 90
 *   force     boolean  re-compute days that already have a row; default false
 *   dryRun    boolean  default true -- lists days to process without writing
 *
 * Body (sweep):
 *   mode      "sweep"  required
 *   maxDays   number   max days per brand; default 90
 *   since     string   ISO date; default 90 days ago
 *   until     string   ISO date; default today (UTC)
 *   dryRun    boolean  default true
 *   cursor    object   optional resume cursor from a previous sweep response
 *                      { lastBrandId: string, lastDate: string }
 *
 * Sweep processes every brand that has at least one SiteTagDailyRollup row.
 * force is always true in sweep mode. Brands are processed in brandId order.
 * When the overall or per-brand budget is exceeded, returns a cursor so
 * the next call can resume without restarting.
 *
 * dryRun sweep response: { ok, dryRun, mode, brands[], totalBrands, estimatedDays }
 * live sweep response:   { ok, dryRun:false, mode, brandResults[], totals, cursor?, brandsNotStarted[] }
 *
 * Response (single-brand):
 *   { dryRun, brandId, since, until, written, skipped, failed, days[] }
 *
 * Days are processed in DESC order (most recent first) so the most
 * valuable rows appear first if the request times out early.
 *
 * Idempotent: without force=true, days that already have a row with
 * conversionsByType IS NOT NULL are skipped.
 *
 * Gated by requireSuperadmin(). No bearer token required -- callable
 * from the browser console at app.meseoapp.com while logged in.
 */

import { NextResponse } from "next/server";
import { Prisma } from "@prisma/client";
import { prisma } from "@/lib/prisma";
import { requireSuperadmin } from "@/lib/admin-auth";
import { computeOneDayRollup } from "@/lib/services/site-tag-rollup-compute";

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

const MAX_DAYS_CAP = 90;
const DEFAULT_RANGE_MS = 90 * 24 * 60 * 60 * 1_000;
// Overall budget per invocation (seconds). Leaves ~30 s of headroom before
// the 300 s Vercel maxDuration hard limit.
const OVERALL_BUDGET_MS = 260_000;
// Per-brand budget: a single slow brand cannot consume the whole function.
const PER_BRAND_BUDGET_MS = 20_000;

interface ResumeCursor {
  lastBrandId: string;
  lastDate: string | null;
}

interface BackfillBody {
  mode?: "sweep";
  brandId?: string;
  since?: string;
  until?: string;
  maxDays?: number;
  force?: boolean;
  dryRun?: boolean;
  cursor?: ResumeCursor;
}

function utcDayStart(d: Date): Date {
  return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
}

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 maxDays = Math.min(MAX_DAYS_CAP, Math.max(1, body.maxDays ?? 90));
    const sinceRaw = body.since ? new Date(body.since) : new Date(Date.now() - DEFAULT_RANGE_MS);
    const untilRaw = body.until ? new Date(body.until) : new Date();
    if (isNaN(sinceRaw.getTime()) || isNaN(untilRaw.getTime())) {
      return NextResponse.json({ ok: false, error: "Invalid since or until date" }, { status: 400 });
    }
    const since = utcDayStart(sinceRaw);
    const until = utcDayStart(untilRaw);

    // Find all brands with at least one rollup row, sorted for determinism.
    const rollupBrands = await prisma.siteTagDailyRollup.findMany({
      select: { brandId: true },
      distinct: ["brandId"],
    });
    const allBrandIds = rollupBrands.map((r) => r.brandId).sort();

    if (dryRun) {
      // Estimate: count existing rollup rows per brand within the window (no writes).
      type DryBrandEntry = { brandId: string; estimatedDays: number };
      const brands: DryBrandEntry[] = [];
      for (const brandId of allBrandIds) {
        const count = await prisma.siteTagDailyRollup.count({
          where: { brandId, date: { gte: since, lte: until } },
        });
        brands.push({ brandId, estimatedDays: Math.min(maxDays, count) });
      }
      const estimatedDays = brands.reduce((n, b) => n + b.estimatedDays, 0);
      return NextResponse.json({
        ok: true, dryRun: true, mode: "sweep",
        since: since.toISOString().slice(0, 10),
        until: until.toISOString().slice(0, 10),
        maxDays, totalBrands: allBrandIds.length, estimatedDays, brands,
        hint: `Dry run: ${allBrandIds.length} brands, ~${estimatedDays} days to recompute. Pass dryRun: false to apply.`,
      });
    }

    // Live sweep — resume from cursor if provided.
    const cursor = body.cursor ?? null;
    const startIdx = cursor
      ? allBrandIds.findIndex((id) => id > cursor.lastBrandId)
      : 0;
    const startFrom = startIdx < 0 ? allBrandIds.length : startIdx;

    // If cursor has a lastDate for a partially-processed brand, include that brand first.
    const partialBrandId = cursor?.lastDate ? cursor.lastBrandId : null;
    const partialResumeDate = cursor?.lastDate ? new Date(cursor.lastDate) : null;

    const functionStart = Date.now();
    type BrandResult = { brandId: string; daysWritten: number; cappedOut: number };
    const brandResults: BrandResult[] = [];
    let finalCursor: ResumeCursor | null = null;
    const brandsNotStarted: string[] = [];

    // Helper: process one brand, returns whether we ran out of budget mid-brand.
    async function processBrand(
      brandId: string,
      resumeFromDate: Date | null,
    ): Promise<"done" | "budget_exceeded"> {
      const site = await prisma.trackedSite.findUnique({
        where: { brandId },
        select: { id: true, requireSuccessConfirmed: true },
      });
      if (!site) return "done"; // brand has no tracked site — skip gracefully

      // Build list of days to recompute: existing rollup days in DESC order.
      const existingRows = await prisma.siteTagDailyRollup.findMany({
        where: {
          brandId,
          date: { gte: since, lte: until },
          ...(resumeFromDate ? { date: { lt: resumeFromDate } } : {}),
        },
        select: { date: true },
        orderBy: { date: "desc" },
        take: maxDays,
      });

      let daysWritten = 0;
      let cappedOut = 0;
      const brandStart = Date.now();

      for (const row of existingRows) {
        const overallElapsed = Date.now() - functionStart;
        const brandElapsed = Date.now() - brandStart;
        if (overallElapsed > OVERALL_BUDGET_MS || brandElapsed > PER_BRAND_BUDGET_MS) {
          cappedOut = existingRows.length - daysWritten;
          brandResults.push({ brandId, daysWritten, cappedOut });
          finalCursor = { lastBrandId: brandId, lastDate: row.date.toISOString().slice(0, 10) };
          return "budget_exceeded";
        }
        try {
          await computeOneDayRollup(site.id, brandId, utcDayStart(row.date), site.requireSuccessConfirmed);
          daysWritten++;
        } catch (err) {
          console.warn("[backfill-site-tag-rollup/sweep] day failed", {
            brandId, date: row.date.toISOString(),
            err: err instanceof Error ? err.message : "unknown",
          });
        }
      }
      brandResults.push({ brandId, daysWritten, cappedOut });
      return "done";
    }

    // Process partial brand from cursor first.
    if (partialBrandId && partialResumeDate) {
      const outcome = await processBrand(partialBrandId, partialResumeDate);
      if (outcome === "budget_exceeded") {
        brandsNotStarted.push(...allBrandIds.slice(startFrom));
        const totalDaysWritten = brandResults.reduce((n, b) => n + b.daysWritten, 0);
        return NextResponse.json({
          ok: true, dryRun: false, mode: "sweep",
          since: since.toISOString().slice(0, 10),
          until: until.toISOString().slice(0, 10),
          brandResults, brandsNotStarted,
          totals: { brandsProcessed: brandResults.length, totalDaysWritten },
          cursor: finalCursor,
          hint: `Budget exceeded. Resume with cursor: ${JSON.stringify(finalCursor)}.`,
        });
      }
    }

    // Process remaining brands.
    for (let bi = startFrom; bi < allBrandIds.length; bi++) {
      if (Date.now() - functionStart > OVERALL_BUDGET_MS) {
        brandsNotStarted.push(...allBrandIds.slice(bi));
        finalCursor = { lastBrandId: allBrandIds[bi - 1] ?? allBrandIds[bi], lastDate: null };
        break;
      }
      const outcome = await processBrand(allBrandIds[bi], null);
      if (outcome === "budget_exceeded") {
        brandsNotStarted.push(...allBrandIds.slice(bi + 1));
        break;
      }
    }

    const totalDaysWritten = brandResults.reduce((n, b) => n + b.daysWritten, 0);
    return NextResponse.json({
      ok: true, dryRun: false, mode: "sweep",
      since: since.toISOString().slice(0, 10),
      until: until.toISOString().slice(0, 10),
      brandResults, brandsNotStarted,
      totals: { brandsProcessed: brandResults.length, totalDaysWritten },
      ...(finalCursor ? { cursor: finalCursor } : {}),
      hint: finalCursor
        ? `Budget exceeded. Resume with cursor: ${JSON.stringify(finalCursor)}. ${brandsNotStarted.length} brands not started.`
        : `Sweep complete. ${brandResults.length} brands, ${totalDaysWritten} days recomputed.`,
    });
  }

  // ── Single-brand mode ───────────────────────────────────────────────────────
  if (!body.brandId) {
    return NextResponse.json({ ok: false, error: "brandId is required" }, { status: 400 });
  }

  const force = body.force === true;
  const maxDays = Math.min(MAX_DAYS_CAP, Math.max(1, body.maxDays ?? 30));

  const sinceRaw = body.since ? new Date(body.since) : new Date(Date.now() - DEFAULT_RANGE_MS);
  const untilRaw = body.until ? new Date(body.until) : new Date();

  if (isNaN(sinceRaw.getTime()) || isNaN(untilRaw.getTime())) {
    return NextResponse.json({ ok: false, error: "Invalid since or until date" }, { status: 400 });
  }

  const since = utcDayStart(sinceRaw);
  const until = utcDayStart(untilRaw);

  if (since > until) {
    return NextResponse.json({ ok: false, error: "since must be before until" }, { status: 400 });
  }

  const site = await prisma.trackedSite.findUnique({
    where: { brandId: body.brandId },
    select: { id: true, requireSuccessConfirmed: true },
  });
  if (!site) {
    return NextResponse.json(
      { ok: false, error: `No TrackedSite found for brandId ${body.brandId}` },
      { status: 404 },
    );
  }

  // Build DESC date list: most recent first so the most impactful rows are
  // written first if the request times out partway through.
  const allDays: Date[] = [];
  for (let d = until; d >= since; d = new Date(d.getTime() - 86_400_000)) {
    allDays.push(new Date(d));
  }

  // Skip days that already have a fully-populated row unless force=true.
  let daysToProcess = allDays;
  if (!force) {
    const existingFull = await prisma.siteTagDailyRollup.findMany({
      where: {
        brandId: body.brandId,
        date: { gte: since, lte: until },
        conversionsByType: { not: Prisma.DbNull },
      },
      select: { date: true },
    });
    const existingDates = new Set(existingFull.map((r) => r.date.toISOString()));
    daysToProcess = allDays.filter((d) => !existingDates.has(d.toISOString()));
  }

  // Apply maxDays cap after filtering -- cap refers to days actually worked, not the range span.
  const capped = daysToProcess.slice(0, maxDays);
  const cappedOut = daysToProcess.length - capped.length;

  type DayResult = { date: string; status: "written" | "would_overwrite" | "failed"; ms?: number; error?: string };
  const days: DayResult[] = [];
  let written = 0;
  let failed = 0;

  if (dryRun) {
    // When force=true, still identify pre-existing rows so the dry-run
    // report distinguishes "new day" from "would overwrite stale rollup".
    let overwriteDates = new Set<string>();
    if (force) {
      const existingFull = await prisma.siteTagDailyRollup.findMany({
        where: {
          brandId: body.brandId,
          date: { gte: since, lte: until },
          conversionsByType: { not: Prisma.DbNull },
        },
        select: { date: true },
      });
      overwriteDates = new Set(existingFull.map((r) => r.date.toISOString()));
    }

    for (const d of capped) {
      const status = overwriteDates.has(d.toISOString()) ? "would_overwrite" : "written";
      days.push({ date: d.toISOString().slice(0, 10), status });
    }
    const wouldOverwrite = days.filter((d) => d.status === "would_overwrite").length;

    return NextResponse.json({
      ok: true,
      dryRun: true,
      brandId: body.brandId,
      since: since.toISOString().slice(0, 10),
      until: until.toISOString().slice(0, 10),
      wouldWrite: capped.length - wouldOverwrite,
      wouldOverwrite,
      alreadyPopulated: allDays.length - daysToProcess.length,
      cappedOut,
      days,
    });
  }

  for (const date of capped) {
    const t = Date.now();
    try {
      await computeOneDayRollup(site.id, body.brandId, date, site.requireSuccessConfirmed);
      written++;
      days.push({ date: date.toISOString().slice(0, 10), status: "written", ms: Date.now() - t });
    } catch (err) {
      failed++;
      days.push({
        date: date.toISOString().slice(0, 10),
        status: "failed",
        ms: Date.now() - t,
        error: err instanceof Error ? err.message.slice(0, 120) : "unknown",
      });
      console.error("[backfill-site-tag-rollup] day failed", {
        brandId: body.brandId,
        date: date.toISOString(),
        err: err instanceof Error ? err.message : "unknown",
      });
    }
  }

  return NextResponse.json({
    ok: true,
    dryRun: false,
    brandId: body.brandId,
    since: since.toISOString().slice(0, 10),
    until: until.toISOString().slice(0, 10),
    written,
    failed,
    alreadyPopulated: allDays.length - daysToProcess.length,
    cappedOut,
    days,
    hint: cappedOut > 0
      ? `${cappedOut} days remain beyond maxDays=${maxDays}. Run again to continue.`
      : "All days in range processed.",
  });
}
