/**
 * GET /api/admin/platform-metrics?brandId=all&range=30d
 *
 * Aggregates platform-wide engagement metrics (clicks, page views,
 * sessions, conversions, form fills, calls) for either:
 *   - brandId="all"  → roll up across every brand
 *   - brandId=<id>   → scope to one brand (verifyBrandAccess enforced)
 *
 * Each metric returns:
 *   current   — total over the selected window
 *   previous  — total over the immediately-prior window of equal length
 *   trend     — % change ((current - previous) / previous × 100)
 *   timeseries — daily counts for the selected window (sparkline source)
 *
 * range = "today" | "7d" | "28d" | "30d" | "90d" | "all"
 *   • 28d matches the app dashboard default so the two surfaces
 *     cite the same window.
 *   • 30d is retained for legacy URLs / bookmarks that still pass it.
 *
 * Cached server-side at the route via Next's `revalidate` (300s) so
 * the heavy aggregations only re-run every 5 minutes per query
 * shape. Superadmin-only.
 */

import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireSuperadmin, withAdminTiming } from "@/lib/admin-auth";
import type { Prisma } from "@prisma/client";
import {
  HARD_CONVERSION_TYPES, SOFT_CONVERSION_TYPES, canonicalConversionType,
} from "@/lib/conversion-taxonomy";
import { getSiteTagInstalledAt } from "@/lib/services/site-tag";
import { getConversionMetrics, summariseForLog } from "@/lib/services/conversion-metrics";
import { getSessionCount } from "@/lib/services/platform-metrics";

export const dynamic = "force-dynamic";
export const revalidate = 300; // 5min server-side cache by query string
export const maxDuration = 60;

type Range = "today" | "7d" | "28d" | "30d" | "90d" | "all";

function rangeToDates(range: Range): { start: Date; end: Date; prevStart: Date; prevEnd: Date; days: number } {
  const end = new Date();
  let days: number;
  switch (range) {
    case "today": days = 1; break;
    case "7d":    days = 7; break;
    // 28d matches the app dashboard default (`src/app/api/dashboard`).
    // Previously this was 30d — a 2-day-wider window that inflated
    // cross-surface numbers (30d GSC clicks came in ~7% higher than
    // the 28d dashboard number for the same brand, and operators
    // read it as "the admin is wrong"). Locked to 28d so the two
    // dashboards cite identical windows by default.
    case "28d":   days = 28; break;
    case "30d":   days = 30; break;
    case "90d":   days = 90; break;
    case "all":   days = 3650; break; // ~10y — effectively all-time
  }
  const start = new Date(end.getTime() - days * 86_400_000);
  const prevEnd = start;
  const prevStart = new Date(prevEnd.getTime() - days * 86_400_000);
  return { start, end, prevStart, prevEnd, days };
}

interface MetricResult {
  current: number;
  previous: number;
  trendPct: number | null; // null when baseline is 0 (suppresses fabricated +100%)
  timeseries: Array<{ date: string; value: number }>;
  /** For conversion metrics only: RAW row count (pre-dedup). When
   *  present and ≠ current, the UI renders "X (Y interactions)".
   *  9 phone clicks on the same page in one session = 1 conversion
   *  (in `current`) + 9 interactions (here). */
  interactions?: number;
  /** For conversion metrics only: previous-window interactions count
   *  — used to compute the interaction-rate trend separately from the
   *  deduped conversion-rate trend. */
  previousInteractions?: number;
}

function trend(current: number, previous: number): number | null {
  if (previous === 0) return null; // suppress fabricated +100% badges
  return Math.round(((current - previous) / previous) * 1000) / 10;
}

/** Roll an array of timestamps into a per-day count. Pads the
 *  series with zero days so the sparkline has the same number of
 *  points regardless of activity gaps. */
function bucketByDay(timestamps: Date[], start: Date, days: number): Array<{ date: string; value: number }> {
  const buckets = new Map<string, number>();
  for (const t of timestamps) {
    const key = t.toISOString().slice(0, 10);
    buckets.set(key, (buckets.get(key) ?? 0) + 1);
  }
  const out: Array<{ date: string; value: number }> = [];
  // Cap series length — for large windows (90d / "all") we'd return
  // hundreds of points which the sparkline doesn't need. Rebucket
  // to ~30 points max.
  const stepDays = Math.max(1, Math.ceil(days / 30));
  const startMs = start.getTime();
  for (let i = 0; i < days; i += stepDays) {
    const dayStart = new Date(startMs + i * 86_400_000);
    const dayEnd = new Date(startMs + Math.min(i + stepDays, days) * 86_400_000);
    let sum = 0;
    for (let d = new Date(dayStart); d < dayEnd; d = new Date(d.getTime() + 86_400_000)) {
      const key = d.toISOString().slice(0, 10);
      sum += buckets.get(key) ?? 0;
    }
    out.push({ date: dayStart.toISOString().slice(0, 10), value: sum });
  }
  return out;
}

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

  const { searchParams } = new URL(req.url);
  const brandIdParam = searchParams.get("brandId") ?? "all";
  // Default matches the app dashboard (28d) so cross-surface numbers
  // cite the same window. Callers can still request 30d / 90d / all
  // explicitly via the query param.
  const range = (searchParams.get("range") ?? "28d") as Range;
  const { start, end, prevStart, prevEnd, days } = rangeToDates(range);
  const brandId = brandIdParam === "all" ? null : brandIdParam;

  const data = await withAdminTiming("platform-metrics", async () => {
    // Resolve trackedSiteId(s) for the scope. TrackedEvent + TrackedSession
    // index off trackedSiteId, NOT brandId, so we have to translate.
    //
    // For the "all brands" path: resolve ONLY brands with active Site
    // Tags (firstEventAt not null) so the aggregation doesn't include
    // brands with zero events. Previously the all-brands path passed
    // no filter → scanned ALL rows → produced misleading numbers
    // ("Site Tag sessions: 0" because non-Site-Tag brands dragged
    // the average, "0% capture rate" because the session denominator
    // was zero).
    let trackedSiteIdFilter: string | null | undefined;
    let activeSiteIds: string[] = [];
    let activeBrandIds: string[] = [];
    if (brandId) {
      trackedSiteIdFilter = (await prisma.trackedSite.findUnique({ where: { brandId }, select: { id: true } }))?.id ?? null;
    } else {
      const activeSites = await prisma.trackedSite.findMany({
        where: { status: "active", firstEventAt: { not: null } },
        select: { id: true, brandId: true },
      });
      activeSiteIds = activeSites.map((s) => s.id);
      activeBrandIds = activeSites.map((s) => s.brandId);
      trackedSiteIdFilter = undefined; // queries below use activeSiteIds instead
    }

    // Build common WHERE shapes once. When in "all brands" mode and
    // we have active sites, scope to only those sites so zero-event
    // brands don't dilute the metrics.
    const eventWhere = (eventType: string | string[], from: Date, to: Date): Prisma.TrackedEventWhereInput => ({
      ...(brandId
        ? (trackedSiteIdFilter ? { trackedSiteId: trackedSiteIdFilter } : { trackedSiteId: "__none__" })
        : (activeSiteIds.length > 0 ? { trackedSiteId: { in: activeSiteIds } } : {})),
      eventType: typeof eventType === "string" ? eventType : { in: eventType },
      timestamp: { gte: from, lt: to },
    });
    const sessionWhere = (from: Date, to: Date): Prisma.TrackedSessionWhereInput => ({
      ...(brandId
        ? (trackedSiteIdFilter ? { trackedSiteId: trackedSiteIdFilter } : { trackedSiteId: "__none__" })
        : (activeSiteIds.length > 0 ? { trackedSiteId: { in: activeSiteIds } } : {})),
      startedAt: { gte: from, lt: to },
    });
    // The "conversions" KPI is a STRICT count of TIER-1 conversions
    // per the canonical taxonomy in src/lib/conversion-taxonomy.ts:
    //   appointment_booked, form_submitted, phone_call, purchase
    // tier-2 events (appointment_attempted / form_started /
    // appointment_intent / chat_initiated) feed the new "engagement"
    // metric below — they're surfaced separately so the headline
    // doesn't inflate when a user opens a booking widget but never
    // completes the booking.
    //
    // Both the new canonical type names AND the legacy storage names
    // are listed so rows written before commit fa7d14a (resolver
    // landing) still roll up under the right tier. canonicalConversionType
    // maps both directions and HARD_CONVERSION_TYPES is the source of
    // truth for the canonical names; the legacy list is kept inline so
    // the SQL filter is just one IN clause.
    const HARD_TYPES_FOR_FILTER = [
      // New canonical names (resolver writes these going forward).
      ...HARD_CONVERSION_TYPES,
      // Legacy SiteConversion.conversionType values still in the table.
      "form_submission", "form_submit",
      "phone_call", "phone_number_click",
      "booking_confirmed",
      "purchase",
      "calendly",
    ];
    const SOFT_TYPES_FOR_FILTER = [
      ...SOFT_CONVERSION_TYPES,
      // Legacy soft-conversion type names.
      "booking_attempt",
    ];
    const conversionWhere = (from: Date, to: Date): Prisma.SiteConversionWhereInput => ({
      ...(brandId ? { brandId } : {}),
      conversionType: { in: HARD_TYPES_FOR_FILTER },
      timestamp: { gte: from, lt: to },
    });
    const engagementWhere = (from: Date, to: Date): Prisma.SiteConversionWhereInput => ({
      ...(brandId ? { brandId } : {}),
      conversionType: { in: SOFT_TYPES_FOR_FILTER },
      timestamp: { gte: from, lt: to },
    });
    // Phone calls — every stored conversionType that canonicalizes to
    // the Tier-1 "phone_call" taxonomy entry. Previously the admin
    // dashboard counted TrackedEvent.eventType="click_to_call" alone
    // (~70 rows for the canary brand), missing the 246 SiteConversion
    // rows written by form-phone.js's tel-link + phone-text-pattern
    // detectors (`phone_call` + `phone_number_click`). The deduper
    // downstream collapses all three variants per (session, page,
    // canonical) so a single user clicking three phone numbers + one
    // text pattern match counts as one phone call.
    const phoneWhere = (from: Date, to: Date): Prisma.SiteConversionWhereInput => ({
      ...(brandId ? { brandId } : {}),
      conversionType: { in: ["phone_call", "phone_number_click", "click_to_call"] },
      timestamp: { gte: from, lt: to },
    });
    // Form submits — SiteConversion deduped, same source the app
    // dashboard + Site Tag Funnel use. Previously this was
    // eventMetric("form_submit") which counted TrackedEvent raw rows
    // (10 for the canary brand vs 341 SiteConversion rows — a 34×
    // under-count because form-phone.js writes SiteConversion directly
    // and the gtag interceptor creates additional rows).
    const formSubmitWhere = (from: Date, to: Date): Prisma.SiteConversionWhereInput => ({
      ...(brandId ? { brandId } : {}),
      conversionType: { in: ["form_submit", "form_submitted", "form_submission"] },
      timestamp: { gte: from, lt: to },
    });
    // Booking confirmed — SiteConversion deduped. Previously used
    // eventMetric("booking_confirmed") which returned TrackedEvent
    // raw count (2 vs 1 SiteConversion deduped for the canary brand).
    const bookingConfirmedWhere = (from: Date, to: Date): Prisma.SiteConversionWhereInput => ({
      ...(brandId ? { brandId } : {}),
      conversionType: { in: ["appointment_booked", "booking_confirmed"] },
      timestamp: { gte: from, lt: to },
    });
    const gscWhere = (from: Date, to: Date): Prisma.GscPageWhereInput => ({
      ...(brandId ? { brandId } : {}),
      date: { gte: from, lt: to },
    });

    // Window-totals helper. Each metric runs current + previous in
    // parallel; the timeseries pass needs the raw timestamps which is
    // a slightly larger fetch — capped at 100K rows per metric to
    // keep memory bounded for very large windows. The COUNT is the
    // authoritative current value; the timeseries is just for the
    // sparkline shape.

    async function eventMetric(eventTypes: string | string[]): Promise<MetricResult> {
      const [currentRows, previousCount] = await Promise.all([
        prisma.trackedEvent.findMany({
          where: eventWhere(eventTypes, start, end),
          select: { timestamp: true },
          take: 100_000,
        }),
        prisma.trackedEvent.count({ where: eventWhere(eventTypes, prevStart, prevEnd) }),
      ]);
      return {
        current: currentRows.length,
        previous: previousCount,
        trendPct: trend(currentRows.length, previousCount),
        timeseries: bucketByDay(currentRows.map((r) => r.timestamp), start, days),
      };
    }

    async function sessionMetric(): Promise<MetricResult> {
      const [currentRows, previousCount] = await Promise.all([
        prisma.trackedSession.findMany({
          where: sessionWhere(start, end),
          select: { startedAt: true },
          take: 100_000,
        }),
        prisma.trackedSession.count({ where: sessionWhere(prevStart, prevEnd) }),
      ]);
      return {
        current: currentRows.length,
        previous: previousCount,
        trendPct: trend(currentRows.length, previousCount),
        timeseries: bucketByDay(currentRows.map((r) => r.startedAt), start, days),
      };
    }

    // ── Deduped conversion helper ───────────────────────────────
    // Pulls (sessionId, pageUrl, conversionType, timestamp) over the
    // window and collapses duplicate rows per (session, page,
    // canonicalType) into one conversion. The raw-row count stays
    // available as `interactions` so the UI can render "237 conversions
    // (579 interactions)".
    //
    // The sparkline timeseries uses the DEDUPED timestamps (first-seen
    // row per unique tuple) so a spike in the chart reflects actual
    // unique conversions, not a user hammering the same button.
    async function dedupedConversionMetric(
      where: (f: Date, t: Date) => Prisma.SiteConversionWhereInput,
    ): Promise<MetricResult> {
      const [currentRows, previousRows] = await Promise.all([
        // intentionally unfiltered: admin-only endpoint; needs raw or all-time counts
        prisma.siteConversion.findMany({
          where: where(start, end),
          select: { sessionId: true, pageUrl: true, conversionType: true, timestamp: true },
          take: 100_000,
          orderBy: { timestamp: "asc" },
        }),
        // intentionally unfiltered: admin-only endpoint; needs raw or all-time counts
        prisma.siteConversion.findMany({
          where: where(prevStart, prevEnd),
          select: { sessionId: true, pageUrl: true, conversionType: true, timestamp: true },
          take: 100_000,
        }),
      ]);
      // Dedupe current window — keep the first-seen timestamp per tuple
      // so the sparkline buckets align with unique conversions.
      const seen = new Set<string>();
      const uniqueTimestamps: Date[] = [];
      for (const r of currentRows) {
        const canon = canonicalConversionType(r.conversionType) ?? r.conversionType;
        const key = `${r.sessionId}|${r.pageUrl}|${canon}`;
        if (seen.has(key)) continue;
        seen.add(key);
        uniqueTimestamps.push(r.timestamp);
      }
      // Dedupe previous window (count only — no sparkline).
      const seenPrev = new Set<string>();
      for (const r of previousRows) {
        const canon = canonicalConversionType(r.conversionType) ?? r.conversionType;
        seenPrev.add(`${r.sessionId}|${r.pageUrl}|${canon}`);
      }
      const current = uniqueTimestamps.length;
      const previous = seenPrev.size;
      return {
        current,
        previous,
        trendPct: trend(current, previous),
        timeseries: bucketByDay(uniqueTimestamps, start, days),
        interactions: currentRows.length,
        previousInteractions: previousRows.length,
      };
    }

    async function conversionMetric(): Promise<MetricResult> {
      return dedupedConversionMetric(conversionWhere);
    }

    // engagementMetric — counts TIER-2 SiteConversion rows
    // (appointment_attempted / form_started / appointment_intent /
    // chat_initiated + the legacy booking_attempt). Surfaces "Engagement"
    // separately from "Conversions" so the headline KPI never inflates
    // with intent-only signals. Uses the same deduper — a user who
    // fires three appointment_intent events on the same page counts
    // as one engagement event.
    async function engagementMetric(): Promise<MetricResult> {
      return dedupedConversionMetric(engagementWhere);
    }

    async function gscImpressionsMetric(): Promise<MetricResult> {
      // GscPage stores per-day aggregates so we sum impressions
      // directly rather than counting rows. Timeseries comes from
      // groupBy on date.
      const [current, previous, byDay] = await Promise.all([
        prisma.gscPage.aggregate({ where: gscWhere(start, end), _sum: { impressions: true } }),
        prisma.gscPage.aggregate({ where: gscWhere(prevStart, prevEnd), _sum: { impressions: true } }),
        prisma.gscPage.groupBy({
          by: ["date"],
          where: gscWhere(start, end),
          _sum: { impressions: true },
          orderBy: { date: "asc" },
        }),
      ]);
      const cur = current._sum.impressions ?? 0;
      const prev = previous._sum.impressions ?? 0;
      // Reuse the bucketing logic by replaying the per-day sums as
      // synthetic timestamps weighted by count — but for impressions
      // we want the actual sum, not a count. Build the series
      // directly here.
      const map = new Map<string, number>();
      for (const row of byDay) {
        map.set(row.date.toISOString().slice(0, 10), row._sum.impressions ?? 0);
      }
      const stepDays = Math.max(1, Math.ceil(days / 30));
      const series: Array<{ date: string; value: number }> = [];
      for (let i = 0; i < days; i += stepDays) {
        const d = new Date(start.getTime() + i * 86_400_000);
        let sum = 0;
        for (let s = 0; s < stepDays && i + s < days; s++) {
          const key = new Date(start.getTime() + (i + s) * 86_400_000).toISOString().slice(0, 10);
          sum += map.get(key) ?? 0;
        }
        series.push({ date: d.toISOString().slice(0, 10), value: sum });
      }
      return { current: cur, previous: prev, trendPct: trend(cur, prev), timeseries: series };
    }

    // Fan everything out in parallel — biggest wall-clock saver.
    const [
      pageViews, sessions, conversions, engagement, formSubmits, bookings, formStarts,
      calls, outboundClicks, genericClicks, impressions,
    ] = await Promise.all([
      eventMetric("page_view"),
      sessionMetric(),
      conversionMetric(),
      engagementMetric(),
      // formSubmits — SiteConversion deduped. Same source as the app
      // dashboard's Conversion Breakdown Hard-tier "Form Submit" cell
      // and the Funnel tab's form_submit count. Previously used
      // eventMetric("form_submit") which counted TrackedEvent raw rows
      // (10 for canary brand vs 341 SiteConversion).
      dedupedConversionMetric(formSubmitWhere),
      // bookings — confirmed bookings from SiteConversion (deduped).
      // Previously eventMetric("booking_confirmed") — TrackedEvent raw
      // (2 vs 1 SiteConversion deduped for the canary brand).
      dedupedConversionMetric(bookingConfirmedWhere),
      // formStarts kept for the new "Forms: X submitted / Y started"
      // composite metric on the dashboard.
      eventMetric("form_start"),
      // Phone Calls pulled from SiteConversion (deduped, all three
      // stored variants — phone_call / phone_number_click /
      // click_to_call — collapsed into one "phone_call" canonical
      // per the platform taxonomy). Matches the Conversion Funnel's
      // Hard-tier phone count on the app side so both dashboards
      // show the same number. Previously eventMetric("click_to_call")
      // counted only TrackedEvent rows — ~70 for the canary brand vs
      // 246 in SiteConversion, a 3–4× under-count.
      dedupedConversionMetric(phoneWhere),
      eventMetric("outbound_click"),
      // Generic "clicks" — anything our tag classifies as a click
      // beyond the named ones above.
      eventMetric(["click", "internal_click"]),
      gscImpressionsMetric(),
    ]);

    // Total clicks = generic + outbound + click_to_call.
    const totalClicksCurrent = genericClicks.current + outboundClicks.current + calls.current;
    const totalClicksPrevious = genericClicks.previous + outboundClicks.previous + calls.previous;
    // Merge their timeseries point-wise (all three series share the
    // same date keys because they came out of the same bucketByDay).
    const totalClicksSeries = genericClicks.timeseries.map((p, i) => ({
      date: p.date,
      value: p.value + (outboundClicks.timeseries[i]?.value ?? 0) + (calls.timeseries[i]?.value ?? 0),
    }));

    // Conversion rate = conversions / sessions × 100. Trended off the
    // current vs previous rates rather than absolute conversion count
    // so it reflects actual rate movement.
    const cvRate = sessions.current > 0 ? (conversions.current / sessions.current) * 100 : 0;
    const cvRatePrev = sessions.previous > 0 ? (conversions.previous / sessions.previous) * 100 : 0;

    // Top 5 brands — only meaningful when scope is "all". For a
    // single-brand request return an empty list (the brand IS the
    // current row).
    let topBrands: Array<{ brandId: string; brandName: string; brandDomain: string; events: number; sessions: number; conversions: number; conversionRate: number }> = [];
    if (!brandId) {
      const eventGroups = await prisma.trackedEvent.groupBy({
        by: ["trackedSiteId"],
        where: { timestamp: { gte: start, lt: end } },
        _count: true,
        orderBy: { _count: { trackedSiteId: "desc" } },
        take: 5,
      });
      const trackedSiteIds = eventGroups.map((g) => g.trackedSiteId);
      if (trackedSiteIds.length > 0) {
        const sites = await prisma.trackedSite.findMany({
          where: { id: { in: trackedSiteIds } },
          select: { id: true, brandId: true, brand: { select: { name: true, domain: true } } },
        });
        const sessionCounts = await prisma.trackedSession.groupBy({
          by: ["trackedSiteId"],
          where: { trackedSiteId: { in: trackedSiteIds }, startedAt: { gte: start, lt: end } },
          _count: true,
        });
        const sessionMap = new Map(sessionCounts.map((s) => [s.trackedSiteId, s._count]));
        // intentionally unfiltered: admin-only endpoint; needs raw or all-time counts
        const conversionCounts = await prisma.siteConversion.groupBy({
          by: ["brandId"],
          where: { brandId: { in: sites.map((s) => s.brandId) }, timestamp: { gte: start, lt: end } },
          _count: true,
        });
        const conversionMap = new Map(conversionCounts.map((c) => [c.brandId, c._count]));

        topBrands = eventGroups.map((g) => {
          const site = sites.find((s) => s.id === g.trackedSiteId);
          if (!site || !site.brand) return null;
          const sessionCount = sessionMap.get(g.trackedSiteId) ?? 0;
          const conversionCount = conversionMap.get(site.brandId) ?? 0;
          return {
            brandId: site.brandId,
            brandName: site.brand.name,
            brandDomain: site.brand.domain,
            events: g._count,
            sessions: sessionCount,
            conversions: conversionCount,
            conversionRate: sessionCount > 0 ? Math.round((conversionCount / sessionCount) * 1000) / 10 : 0,
          };
        }).filter((b): b is NonNullable<typeof b> => b != null);
      }
    }

    // ── GA4 aggregation (MetricSnapshot rows where source = "ga4") ──
    // For "all brands" we sum every snapshot in the window; for a
    // single brand we scope to that brand. Same shape either way so
    // the dashboard can render with one component.
    const ga4Where: Prisma.MetricSnapshotWhereInput = {
      ...(brandId ? { brandId } : {}),
      source: "ga4",
      date: { gte: start, lt: end },
    };
    const ga4PrevWhere: Prisma.MetricSnapshotWhereInput = {
      ...(brandId ? { brandId } : {}),
      source: "ga4",
      date: { gte: prevStart, lt: prevEnd },
    };
    const ga4Connected = brandId
      ? !!(await prisma.brandIntegration.findFirst({
          where: { brandId, integrationId: "ga4", connected: true },
          select: { id: true },
        }))
      : ((await prisma.brandIntegration.count({
          where: { integrationId: "ga4", connected: true },
        })) > 0);

    const [ga4Cur, ga4Prev] = await Promise.all([
      prisma.metricSnapshot.aggregate({
        where: ga4Where,
        _sum: { sessions: true, users: true, pageviews: true, conversions: true, revenue: true },
        _avg: { bounceRate: true, avgSessionSec: true },
      }),
      prisma.metricSnapshot.aggregate({
        where: ga4PrevWhere,
        _sum: { sessions: true, users: true, pageviews: true, conversions: true, revenue: true },
        _avg: { bounceRate: true, avgSessionSec: true },
      }),
    ]);

    const ga4SessionsCur = ga4Cur._sum.sessions ?? 0;
    const ga4SessionsPrev = ga4Prev._sum.sessions ?? 0;
    const ga4UsersCur = ga4Cur._sum.users ?? 0;
    const ga4UsersPrev = ga4Prev._sum.users ?? 0;
    const ga4PageviewsCur = ga4Cur._sum.pageviews ?? 0;
    const ga4ConversionsCur = ga4Cur._sum.conversions ?? 0;
    const ga4ConversionsPrev = ga4Prev._sum.conversions ?? 0;
    const ga4RevenueCur = ga4Cur._sum.revenue ?? 0;
    const ga4RevenuePrev = ga4Prev._sum.revenue ?? 0;
    const ga4BounceCur = ga4Cur._avg.bounceRate ?? 0;
    const ga4AvgSessionSecCur = ga4Cur._avg.avgSessionSec ?? 0;
    const ga4ConvRateCur = ga4SessionsCur > 0 ? (ga4ConversionsCur / ga4SessionsCur) * 100 : 0;
    const ga4ConvRatePrev = ga4SessionsPrev > 0 ? (ga4ConversionsPrev / ga4SessionsPrev) * 100 : 0;
    const ga4PagesPerSession = ga4SessionsCur > 0 ? ga4PageviewsCur / ga4SessionsCur : 0;

    // ── GSC aggregation (GscPage / GscQuery) ────────────────────────
    // Sum clicks/impressions across the window; weighted-avg position
    // and CTR using impressions as the weight (a row with 1000 imps
    // at position 5 should outweigh a row with 1 imp at position 1).
    // Top queries / top pages: sum per-key over the window then take
    // top 5 by clicks.
    const gscPageWhere: Prisma.GscPageWhereInput = {
      ...(brandId ? { brandId } : {}),
      date: { gte: start, lt: end },
    };
    const gscPagePrevWhere: Prisma.GscPageWhereInput = {
      ...(brandId ? { brandId } : {}),
      date: { gte: prevStart, lt: prevEnd },
    };
    const gscQueryWhere: Prisma.GscQueryWhereInput = {
      ...(brandId ? { brandId } : {}),
      date: { gte: start, lt: end },
    };
    const gscConnected = brandId
      ? !!(await prisma.brandIntegration.findFirst({
          where: { brandId, integrationId: "gsc", connected: true },
          select: { id: true },
        }))
      : ((await prisma.brandIntegration.count({
          where: { integrationId: "gsc", connected: true },
        })) > 0);

    const [gscPageCur, gscPagePrev, gscTopQueriesRaw, gscTopPagesRaw] = await Promise.all([
      prisma.gscPage.aggregate({
        where: gscPageWhere,
        _sum: { clicks: true, impressions: true },
      }),
      prisma.gscPage.aggregate({
        where: gscPagePrevWhere,
        _sum: { clicks: true, impressions: true },
      }),
      // Top 5 queries — sum clicks/impressions across the window per
      // query, average position and CTR. Cap at 200 for the rollup
      // (top 5 are typically in the first ~20).
      prisma.gscQuery.groupBy({
        by: ["query"],
        where: gscQueryWhere,
        _sum: { clicks: true, impressions: true },
        _avg: { position: true, ctr: true },
        orderBy: { _sum: { clicks: "desc" } },
        take: 5,
      }),
      // Top 5 pages — same pattern.
      prisma.gscPage.groupBy({
        by: ["pageUrl"],
        where: gscPageWhere,
        _sum: { clicks: true, impressions: true },
        _avg: { position: true, ctr: true },
        orderBy: { _sum: { clicks: "desc" } },
        take: 5,
      }),
    ]);

    const gscClicksCur = gscPageCur._sum.clicks ?? 0;
    const gscClicksPrev = gscPagePrev._sum.clicks ?? 0;
    const gscImpressionsCur = gscPageCur._sum.impressions ?? 0;
    const gscImpressionsPrev = gscPagePrev._sum.impressions ?? 0;
    const gscAvgCtrCur = gscImpressionsCur > 0 ? (gscClicksCur / gscImpressionsCur) * 100 : 0;

    // Weighted-average position needs raw rows because Prisma's
    // aggregate doesn't support SUM(position * impressions) /
    // SUM(impressions). We do a single bounded findMany and weight
    // in-process — bounded at 50K rows which covers any realistic
    // brand window without memory pressure.
    const gscPosRows = await prisma.gscPage.findMany({
      where: gscPageWhere,
      select: { position: true, impressions: true },
      take: 50_000,
    });
    let weightedSum = 0;
    let weightTotal = 0;
    for (const row of gscPosRows) {
      const w = row.impressions > 0 ? row.impressions : 1;
      weightedSum += row.position * w;
      weightTotal += w;
    }
    const gscAvgPositionCur = weightTotal > 0 ? weightedSum / weightTotal : 0;

    const gscTopQueries = gscTopQueriesRaw.map((q) => ({
      query: q.query,
      clicks: q._sum.clicks ?? 0,
      impressions: q._sum.impressions ?? 0,
      position: q._avg.position ?? 0,
      ctr: (q._avg.ctr ?? 0) * 100, // schema stores 0-1, surface as 0-100
    }));
    const gscTopPages = gscTopPagesRaw.map((p) => ({
      pageUrl: p.pageUrl,
      clicks: p._sum.clicks ?? 0,
      impressions: p._sum.impressions ?? 0,
      position: p._avg.position ?? 0,
      ctr: (p._avg.ctr ?? 0) * 100,
    }));

    // ── Site Tag vs GA4 comparison (overlap-only window) ────────
    //
    // Comparing raw 30-day totals is misleading when the Site Tag
    // has only been active for a fraction of the window. MH&V hit
    // this: "15K vs 110K sessions" looked like a 14% capture rate
    // but the daily rate was actually 68% — the Site Tag just
    // hadn't been installed for the first 24 days of the range.
    //
    // The reconciliation uses the OVERLAP window:
    //   installedAt  = earliest real signal from the Site Tag
    //                  (first TrackedEvent timestamp — NOT the
    //                  TrackedSite row-creation timestamp, which
    //                  can pre-date the customer actually deploying
    //                  the snippet by days or weeks).
    //   effectiveFrom = max(rangeStart, installedAt)
    //
    // Both sides query the same window so the comparison is apples-
    // to-apples.
    //
    // Brand-scoped: delegates to getSiteTagInstalledAt which reads
    // the cached TrackedSite.firstEventAt (fast path) and self-heals
    // stale cache via a lt-timestamp lookup — same helper the app-
    // facing dashboard uses at /api/dashboard, so admin + brand
    // views never drift.
    //
    // Install date resolution. Brand-scoped uses the shared helper;
    // all-brands uses the earliest firstEventAt across active sites.
    // Previously the all-brands path scanned the entire TrackedEvent
    // table (33K+ rows per brand × 6 brands) for MIN(timestamp) —
    // slow and produced a misleading "active for 18 days" when only
    // 2 of 6 brands had events. Now it reads the cached firstEventAt
    // columns directly (millisecond lookup, no scan).
    let installedAt: Date | null = null;
    if (brandId) {
      installedAt = await getSiteTagInstalledAt(brandId).catch(() => null);
    } else if (activeSiteIds.length > 0) {
      const activeSitesWithDates = await prisma.trackedSite.findMany({
        where: { id: { in: activeSiteIds }, firstEventAt: { not: null } },
        select: { firstEventAt: true },
        orderBy: { firstEventAt: "asc" },
        take: 1,
      });
      installedAt = activeSitesWithDates[0]?.firstEventAt ?? null;
    }

    const effectiveFrom = installedAt && installedAt > start ? installedAt : start;
    const effectiveDays = Math.max(
      1,
      Math.ceil((end.getTime() - effectiveFrom.getTime()) / 86_400_000),
    );
    // True when the install date is inside the selected range —
    // signals the UI to caption "Site Tag has been active for N
    // days — full comparison available after <date>".
    const partialOverlap = installedAt != null && installedAt > start;

    // Re-run the comparison-specific queries over the effective window.
    // For "all brands": scope to only brands with active Site Tags so
    // the capture rate isn't diluted by brands that only have GA4 but
    // no Site Tag (those brands' GA4 sessions would inflate the
    // denominator while the Site Tag numerator stays at 0 → "0%
    // capture" even when the active brands are capturing at 90%).
    const comparisonSessionsSiteTag = brandId
      ? await getSessionCount(brandId, effectiveFrom, trackedSiteIdFilter ? { trackedSiteId: trackedSiteIdFilter } : {})
      : (activeSiteIds.length > 0
          ? await prisma.trackedSession.count({
              where: {
                trackedSiteId: { in: activeSiteIds },
                startedAt: { gte: effectiveFrom, lt: end },
              },
            }).catch(() => 0)
          : 0);
    // intentionally unfiltered: admin-only endpoint; needs raw or all-time counts
    const comparisonConversionsSiteTag = await prisma.siteConversion.count({
      where: {
        ...(brandId ? { brandId } : (activeBrandIds.length > 0 ? { brandId: { in: activeBrandIds } } : {})),
        conversionType: { in: HARD_TYPES_FOR_FILTER },
        timestamp: { gte: effectiveFrom, lt: end },
      },
    });

    // GA4 — aggregate MetricSnapshot rows over the overlap only.
    // When in "all brands" mode, scope to only the brands that have
    // active Site Tags so the comparison is apples-to-apples (GA4
    // sessions for the same brands whose Site Tag sessions we counted
    // above).
    const ga4OverlapAgg = await prisma.metricSnapshot.aggregate({
      where: {
        ...(brandId ? { brandId } : (activeBrandIds.length > 0 ? { brandId: { in: activeBrandIds } } : {})),
        source: "ga4",
        date: { gte: effectiveFrom, lt: end },
      },
      _sum: { sessions: true, conversions: true },
    });
    const comparisonSessionsGa4 = ga4OverlapAgg._sum.sessions ?? 0;
    const comparisonConversionsGa4 = ga4OverlapAgg._sum.conversions ?? 0;

    // Capture rates + daily rates.
    const sessionCaptureRate = comparisonSessionsGa4 > 0
      ? Math.round((comparisonSessionsSiteTag / comparisonSessionsGa4) * 1000) / 10
      : null;
    const conversionCaptureRate = comparisonConversionsGa4 > 0
      ? Math.round((comparisonConversionsSiteTag / comparisonConversionsGa4) * 1000) / 10
      : null;

    const sessionDelta = comparisonSessionsGa4 > 0
      ? Math.round(((comparisonSessionsSiteTag - comparisonSessionsGa4) / comparisonSessionsGa4) * 1000) / 10
      : null;
    const conversionDelta = comparisonConversionsGa4 > 0
      ? Math.round(((comparisonConversionsSiteTag - comparisonConversionsGa4) / comparisonConversionsGa4) * 1000) / 10
      : null;

    // Shared-metrics audit. Runs the same helper every other surface
    // uses so "do the admin and app numbers match?" is one grep across
    // Vercel logs. Single-brand only — the "all" roll-up is a
    // different query shape (cross-brand aggregation) and the helper
    // is brand-scoped by design. Fire-and-forget errors — the
    // response still ships even if the audit query fails.
    if (brandId) {
      try {
        const auditMetrics = await getConversionMetrics(
          brandId, start,
          trackedSiteIdFilter ? { sessionsTrackedSiteId: trackedSiteIdFilter } : {},
        );
        console.info("[PAGE_admin_dashboard] Conversion metrics:", JSON.stringify({
          brandId,
          range,
          ...summariseForLog(auditMetrics),
        }));
      } catch (err) {
        console.warn("[PAGE_admin_dashboard] audit metrics failed", err);
      }
    }

    return {
      scope: brandId ?? "all",
      range,
      windowDays: days,
      // ── New triple-source shape ──
      // The legacy `metrics` field below is kept verbatim for
      // backwards compatibility while the dashboard migrates to
      // siteTag / ga4 / gsc / comparison. Once the new dashboard
      // ships and old consumers are gone, `metrics` gets removed.
      siteTag: {
        connected: true, // we only return this row when there's a TrackedSite anyway
        pageViews,
        sessions,
        conversions,
        // engagement — TIER-2 SiteConversion rows (intents + form starts +
        // booking attempts). Surfaced as a separate KPI so the headline
        // Conversions number stays clean (tier-1 only).
        engagement,
        conversionRate: {
          current: Math.round(cvRate * 100) / 100,
          previous: Math.round(cvRatePrev * 100) / 100,
          trendPct: trend(cvRate, cvRatePrev),
          timeseries: conversions.timeseries.map((c, i) => {
            const s = sessions.timeseries[i]?.value ?? 0;
            return { date: c.date, value: s > 0 ? Math.round((c.value / s) * 1000) / 10 : 0 };
          }),
        },
        formStarts,
        formSubmits,
        bookings,
        phoneCalls: calls,
        outboundClicks,
        impressions,
      },
      ga4: {
        connected: ga4Connected,
        sessions:        { current: ga4SessionsCur,    previous: ga4SessionsPrev,    trendPct: trend(ga4SessionsCur, ga4SessionsPrev) },
        users:           { current: ga4UsersCur,       previous: ga4UsersPrev,       trendPct: trend(ga4UsersCur, ga4UsersPrev) },
        newUsers:        { current: ga4UsersCur,       previous: ga4UsersPrev,       trendPct: trend(ga4UsersCur, ga4UsersPrev) },
        pageviews:       { current: ga4PageviewsCur,   previous: 0,                  trendPct: 0 },
        conversions:     { current: ga4ConversionsCur, previous: ga4ConversionsPrev, trendPct: trend(ga4ConversionsCur, ga4ConversionsPrev) },
        revenue:         { current: ga4RevenueCur,     previous: ga4RevenuePrev,     trendPct: trend(ga4RevenueCur, ga4RevenuePrev) },
        bounceRate:      { current: Math.round(ga4BounceCur * 1000) / 10,            previous: 0, trendPct: 0 },
        avgSessionSec:   { current: Math.round(ga4AvgSessionSecCur),                 previous: 0, trendPct: 0 },
        pagesPerSession: { current: Math.round(ga4PagesPerSession * 100) / 100,      previous: 0, trendPct: 0 },
        conversionRate:  { current: Math.round(ga4ConvRateCur * 100) / 100, previous: Math.round(ga4ConvRatePrev * 100) / 100, trendPct: trend(ga4ConvRateCur, ga4ConvRatePrev) },
      },
      gsc: {
        connected: gscConnected,
        clicks:        { current: gscClicksCur,      previous: gscClicksPrev,      trendPct: trend(gscClicksCur, gscClicksPrev) },
        impressions:   { current: gscImpressionsCur, previous: gscImpressionsPrev, trendPct: trend(gscImpressionsCur, gscImpressionsPrev) },
        avgPosition:   { current: Math.round(gscAvgPositionCur * 100) / 100, previous: 0, trendPct: 0 },
        avgCtr:        { current: Math.round(gscAvgCtrCur * 100) / 100,      previous: 0, trendPct: 0 },
        topQueries:    gscTopQueries,
        topPages:      gscTopPages,
      },
      comparison: {
        // Site Tag tends to OVER-count vs GA4 because gtag interception
        // captures conversions GA4 itself routes through the same gtag
        // hook AND because Site Tag has plugin-specific paths (Calendly
        // postMessage, NexHealth iframe events) GA4 misses entirely.
        // sessionDelta closer to zero = healthy reconciliation.
        //
        // ALL FIGURES BELOW are computed over the OVERLAPPING window
        // (effectiveFrom = max(rangeStart, siteTagInstalledAt)) so the
        // comparison is apples-to-apples. The UI should render the
        // effective window string alongside these numbers so operators
        // understand why the comparison totals are smaller than the
        // headline KPIs.
        sessionDelta,
        conversionDelta,
        sessionCaptureRate,
        conversionCaptureRate,
        siteTagSessions: comparisonSessionsSiteTag,
        ga4Sessions: comparisonSessionsGa4,
        siteTagConversions: comparisonConversionsSiteTag,
        ga4Conversions: comparisonConversionsGa4,
        // Per-day rates — lets the UI render "2,529/day" subtitles
        // without re-deriving from the range on the client.
        siteTagSessionsPerDay: Math.round(comparisonSessionsSiteTag / effectiveDays),
        ga4SessionsPerDay: Math.round(comparisonSessionsGa4 / effectiveDays),
        siteTagConversionsPerDay: Math.round(comparisonConversionsSiteTag / effectiveDays * 10) / 10,
        ga4ConversionsPerDay: Math.round(comparisonConversionsGa4 / effectiveDays * 10) / 10,
        // Effective window metadata.
        effectiveFrom: effectiveFrom.toISOString(),
        effectiveTo: end.toISOString(),
        effectiveDays,
        installedAt: installedAt?.toISOString() ?? null,
        partialOverlap,
        // In "all brands" mode: how many brands have an active Site
        // Tag vs total brands. Used by the UI to render "Site Tag
        // active on 2 of 6 brands" instead of the misleading
        // "active for 18 days".
        activeSiteTagBrands: !brandId ? activeBrandIds.length : undefined,
        totalBrands: !brandId ? (await prisma.brand.count().catch(() => 0)) : undefined,
        fullComparisonAvailableAt: partialOverlap
          ? new Date(installedAt!.getTime() + days * 86_400_000).toISOString()
          : null,
      },
      // Legacy shape — preserved for backwards compat. The dashboard
      // redesign in the next commit reads from siteTag / ga4 / gsc.
      metrics: {
        totalClicks: {
          current: totalClicksCurrent,
          previous: totalClicksPrevious,
          trendPct: trend(totalClicksCurrent, totalClicksPrevious),
          timeseries: totalClicksSeries,
        },
        impressions,
        pageViews,
        sessions,
        conversions,
        // engagement — tier-2 SiteConversion rows (form_started,
        // appointment_intent, appointment_attempted, etc.).
        engagement,
        // formSubmits — counts form_submit events (the new KPI).
        formSubmits,
        // formStarts — kept so the dashboard can show "X submitted of
        // Y started" as a single composite metric (form completion rate).
        formStarts,
        // bookings — confirmed bookings only.
        bookings,
        // formFills — DEPRECATED alias of formSubmits. Existing
        // dashboard code that reads metrics.formFills continues to
        // work, but new surfaces should read formSubmits/bookings/
        // formStarts directly. Will be removed once all consumers
        // migrate.
        formFills: formSubmits,
        phoneCalls: calls,
        outboundClicks,
        conversionRate: {
          current: Math.round(cvRate * 100) / 100,
          previous: Math.round(cvRatePrev * 100) / 100,
          trendPct: trend(cvRate, cvRatePrev),
          // Sparkline of conversion rate per bucket — divide
          // conversion bucket by session bucket position-wise.
          timeseries: conversions.timeseries.map((c, i) => {
            const s = sessions.timeseries[i]?.value ?? 0;
            return { date: c.date, value: s > 0 ? Math.round((c.value / s) * 1000) / 10 : 0 };
          }),
        },
      },
      topBrands,
    };
  });
  // Browser cache: 2 minutes fresh, 5 minutes stale-while-revalidate.
  // The admin dashboard data doesn't need to be real-time — the range
  // picker already re-fetches on change, and the 2-minute fresh window
  // means rapid page refreshes don't re-run 29 Prisma queries. The
  // 5-minute swr keeps back/forward navigation instant while a fresh
  // fetch runs in the background.
  return NextResponse.json(data, {
    headers: {
      "Cache-Control": "private, max-age=120, stale-while-revalidate=300",
    },
  });
}
