/**
 * GET /api/dashboard?brandId=xxx&range=28d
 * --------------------------------
 * Returns dashboard data. If GA4 is connected, fetches LIVE from Google.
 * Range options: 7d, 28d, 90d (default: 28d)
 */

import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { getCredentials, getValidAccessToken, forceRefreshAccessToken } from "@/lib/services/integration-credentials";
import { apiCall } from "@/lib/integrations/types";
import { getSiteTagInstalledAt, siteTagAgeDays } from "@/lib/services/site-tag";
import { getGSCMetrics } from "@/lib/services/platform-metrics";
import { log } from "@/lib/logger";
import { getCurrentUser } from "@/lib/auth";
import { verifyBrandAccess } from "@/lib/brand-access";

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

interface DashboardDateRange {
  /** GA4-API-formatted start date (YYYY-MM-DD or "<N>daysAgo") */
  ga4StartDate: string;
  /** YYYY-MM-DD start date for GSC + DB queries */
  startDate: string;
  /** YYYY-MM-DD end date for GSC + DB queries */
  endDate: string;
  /** Number of calendar days in the current period (for coverage display) */
  totalDays: number;
  /** Previous-period start date, same length as the current period */
  prevStartDate: string;
  /** Previous-period end date (one day before startDate) */
  prevEndDate: string;
  /** GA4-formatted previous-period start (YYYY-MM-DD) */
  prevGa4StartDate: string;
}

function getDateRange(range: string, customStart?: string, customEnd?: string): DashboardDateRange {
  const today = new Date();
  const endDateStr = today.toISOString().split("T")[0];

  let startDate: string;
  let endDate = endDateStr;
  let totalDays: number;

  if (range === "custom" && customStart && customEnd) {
    startDate = customStart;
    endDate = customEnd;
    totalDays = Math.max(1, Math.round((new Date(customEnd).getTime() - new Date(customStart).getTime()) / 86400000) + 1);
  } else if (range === "month") {
    // First day of the current month in UTC, to match the UTC endDateStr above.
    // Using new Date(year, month, 1) computes midnight in the SERVER's local
    // timezone, which drifts from UTC and produces off-by-one totalDays when
    // the server isn't in UTC. Use Date.UTC() to lock to UTC explicitly.
    const firstOfMonthMs = Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), 1);
    startDate = new Date(firstOfMonthMs).toISOString().split("T")[0];
    // Days from the 1st through today inclusive. getUTCDate() is the day-of-
    // month number, which equals the count of days elapsed (Apr 1 → 1, Apr 11 → 11).
    totalDays = today.getUTCDate();
  } else {
    const daysMap: Record<string, number> = { "1d": 1, "7d": 7, "90d": 90 };
    totalDays = daysMap[range] ?? 28;
    startDate = new Date(Date.now() - (totalDays - 1) * 86400000).toISOString().split("T")[0];
  }

  // GA4 accepts ISO YYYY-MM-DD dates directly — no need for relative format.
  // Using ISO for both GA4 and GSC keeps the two queries aligned and avoids
  // property-timezone vs server-timezone drift that plagued the old
  // "${totalDays}daysAgo" format.
  const ga4StartDate = startDate;

  // Previous period: same length, immediately preceding the current period
  const currentStartMs = new Date(startDate).getTime();
  const prevEndMs = currentStartMs - 86400000;
  const prevStartMs = prevEndMs - (totalDays - 1) * 86400000;
  const prevStartDate = new Date(prevStartMs).toISOString().split("T")[0];
  const prevEndDate = new Date(prevEndMs).toISOString().split("T")[0];

  return {
    ga4StartDate, startDate, endDate, totalDays,
    prevStartDate, prevEndDate, prevGa4StartDate: prevStartDate,
  };
}

/** Percentage delta between current and previous values. Returns null when
 *  the previous value is zero (no baseline to compare against). */
function computeDelta(current: number, previous: number): { pct: number; direction: "up" | "down" | "flat" } | null {
  if (previous === 0) return null;
  const pct = Math.round(((current - previous) / previous) * 1000) / 10;
  return { pct: Math.abs(pct), direction: pct > 0 ? "up" : pct < 0 ? "down" : "flat" };
}

/**
 * Race a promise against a timeout. When the timer wins, resolve with
 * `fallback` instead of hanging forever — lets the dashboard respond
 * with partial data (GSC only, GA4 "temporarily_unavailable") when
 * one provider is slow. AbortController is not used here because
 * fetch() inside apiCall() doesn't plumb it through; we let the
 * underlying request complete in the background and just discard its
 * late result.
 */
async function withTimeout<T>(promise: Promise<T>, ms: number, fallback: T, label?: string): Promise<T> {
  return Promise.race([
    promise,
    new Promise<T>((resolve) => setTimeout(() => {
      if (label) console.warn(`[dashboard] timeout (${ms}ms) hit: ${label}`);
      resolve(fallback);
    }, ms)),
  ]);
}

/** Default per-call timeout for external Google APIs. 6s is enough for
 *  a healthy Google response (<500ms typical, 2-3s p99) and short
 *  enough that two parallel calls staying just under the function
 *  deadline still leaves ~6s for tail queries + response render
 *  before the 15s client-side abort fires. Was 10s; lowering it
 *  matters most when a Google endpoint is slow but not hung — the
 *  caller fails over to DB fallback that much sooner. */
const GOOGLE_API_TIMEOUT_MS = 6_000;

/** Hard timeout for an OAuth refresh round-trip. Google's token endpoint
 *  is usually <500ms but has been observed hanging indefinitely when the
 *  refresh_token is in a weird state (revoked-but-not-rejected, account
 *  disabled, etc). Without a bound, a single stuck refresh could hang
 *  the dashboard for 60s+ per call, and we have up to 5 refresh calls
 *  per request on the worst path — that's how the function used to
 *  burn Vercel's 300s cap. 5s is enough for a healthy refresh and
 *  short enough that a stuck one surfaces quickly to DB fallback. */
const REFRESH_TIMEOUT_MS = 5_000;

/** Hard deadline for the entire request. After this we cut loose
 *  any pending provider work and populate ga4/gsc from the DB so
 *  the function always returns within the budget. 12s sits comfortably
 *  above the typical 2-4s happy path (preflight ~1s + 6s API + tail
 *  3s) and well below the client's 15s abort. Was 22s — the bigger
 *  budget meant a slow path could eat the entire client window
 *  before any response arrived. */
const FUNCTION_DEADLINE_MS = 18_000;

/** Refresh an access_token with a hard upper bound. Returns the new
 *  token on success, null on timeout or throw. Callers treat null as
 *  "refresh didn't work — fall through to DB fallback" and never
 *  block on it. */
async function boundedRefresh(brandId: string, integrationId: string): Promise<string | null> {
  return Promise.race([
    forceRefreshAccessToken(brandId, integrationId).catch(() => null),
    new Promise<null>((resolve) => setTimeout(() => {
      console.warn(`[dashboard] boundedRefresh ${integrationId} timed out after ${REFRESH_TIMEOUT_MS}ms`, { brandId });
      resolve(null);
    }, REFRESH_TIMEOUT_MS)),
  ]);
}

/**
 * Last-known-good GA4 fallback. When the in-range MetricSnapshot
 * aggregate is empty — e.g., the GA4 sync cron hasn't run for the
 * last 7 days but did backfill data 30 days ago — find the most
 * recent snapshot date and aggregate the 28 days ending there.
 * Returns the range end date too so the UI can show "Last synced
 * through 2025-10-12". Returns null when the brand has zero GA4
 * snapshots in history.
 */
async function aggregateGa4StaleFallback(
  brandId: string,
): Promise<{ sessions: number; users: number; newUsers: number; pageViews: number; conversions: number; windowEnd: string } | null> {
  const latest = await prisma.metricSnapshot.findFirst({
    where: { brandId, source: "ga4" },
    orderBy: { date: "desc" },
    select: { date: true },
  }).catch(() => null);
  if (!latest?.date) return null;
  const end = latest.date;
  const start = new Date(end.getTime() - 27 * 86400000);
  const startIso = start.toISOString().split("T")[0];
  const endIso = end.toISOString().split("T")[0];
  const agg = await aggregateGa4FromDb(brandId, startIso, endIso);
  if (!agg) return null;
  return { ...agg, windowEnd: endIso };
}

/**
 * Last-known-good GSC fallback. Mirror of aggregateGa4StaleFallback
 * — finds the most recent gscPage date for the brand and aggregates
 * the 28 days ending there. Used when both the live GSC API and the
 * in-range DB query return zero rows, so the dashboard can still
 * show numbers on a brand whose GSC cron hasn't synced today.
 */
async function aggregateGscStaleFallback(
  brandId: string,
): Promise<{ clicks: number; impressions: number; avgPosition: number; ctr: number; windowEnd: string } | null> {
  const latest = await prisma.gscPage.findFirst({
    where: { brandId, clicks: { gt: 0 } },
    orderBy: { date: "desc" },
    select: { date: true },
  }).catch(() => null);
  if (!latest?.date) return null;
  const end = latest.date;
  const start = new Date(end.getTime() - 27 * 86400000);
  const agg = await prisma.gscPage.aggregate({
    where: { brandId, date: { gte: start, lte: end } },
    _sum: { clicks: true, impressions: true },
    _avg: { position: true },
  }).catch(() => null);
  const clicks = agg?._sum.clicks ?? 0;
  const imps = agg?._sum.impressions ?? 0;
  if (clicks === 0 && imps === 0) return null;
  return {
    clicks,
    impressions: imps,
    avgPosition: Math.round((agg?._avg.position ?? 0) * 10) / 10,
    ctr: imps > 0 ? Math.round((clicks / imps) * 10000) / 100 : 0,
    windowEnd: end.toISOString().split("T")[0],
  };
}

/**
 * Preflight token refresh for GA4 + GSC. Runs in parallel before the
 * provider tasks so both blocks start their fetches with known-fresh
 * tokens — avoids the 401-retry round-trip that used to eat 10-20s
 * on expired-token loads.
 *
 * Uses a 5-minute buffer: any token whose `tokenExpiresAt` is less
 * than 5 minutes from now gets force-refreshed. The regular
 * `getCredentials({ autoRefresh: true })` path only buffers 60s,
 * which is fine when the subsequent fetch is instant but isn't
 * enough headroom when the GA4 query itself can take 8-10s (a token
 * with 90s remaining at call time can expire mid-request).
 *
 * Best-effort — individual failures are logged and swallowed, and
 * the downstream provider tasks still run with whatever credentials
 * we have. The goal is to shave latency on the common "token
 * recently expired" case, not to guarantee a fresh token.
 */
async function ensureTokensFresh(brandId: string): Promise<void> {
  const integrations = await prisma.brandIntegration.findMany({
    where: { brandId, integrationId: { in: ["ga4", "gsc"] }, connected: true },
    select: { integrationId: true, tokenExpiresAt: true, status: true },
  }).catch(() => []);

  const now = Date.now();
  const cutoff = now + 5 * 60_000;

  await Promise.all(
    integrations.map(async (integ) => {
      const expiresAtMs = integ.tokenExpiresAt ? new Date(integ.tokenExpiresAt).getTime() : 0;
      const expired = !expiresAtMs || expiresAtMs <= cutoff;
      const expiredStatus = integ.status === "expired";
      if (!expired && !expiredStatus) return;

      await boundedRefresh(brandId, integ.integrationId);
    }),
  );
}

/** Aggregate cached GA4 data from the MetricSnapshot table for a date range.
 *  Used as a fallback when the live GA4 API returns null or empty results
 *  (typically due to token timing or transient API issues). Returns null
 *  when no cached data exists in the requested range so callers can fall
 *  through to a friendlier "Temporarily unavailable" message.
 *
 *  newUsers isn't stored in MetricSnapshot — we approximate as 30% of
 *  sessions, matching the typical ratio. This is only ever shown when the
 *  live API is unavailable, so the approximation is acceptable. */
async function aggregateGa4FromDb(
  brandId: string,
  startDate: string,
  endDate: string,
): Promise<{ sessions: number; users: number; newUsers: number; pageViews: number; conversions: number } | null> {
  const agg = await prisma.metricSnapshot.aggregate({
    where: { brandId, source: "ga4", date: { gte: new Date(startDate), lte: new Date(endDate) } },
    _sum: { sessions: true, users: true, pageviews: true, conversions: true },
  });
  const sessions = agg._sum.sessions ?? 0;
  if (sessions === 0) return null;
  return {
    sessions,
    users: agg._sum.users ?? 0,
    newUsers: Math.round(sessions * 0.3),
    pageViews: agg._sum.pageviews ?? 0,
    conversions: agg._sum.conversions ?? 0,
  };
}

/** Fetch GA4 metrics for a single date range. Returns null on error so the
 *  caller can gracefully degrade (show current-period numbers without deltas).
 *  Does a one-shot retry with a force-refreshed token on 401 so the dashboard
 *  doesn't show "0 sessions" whenever Google invalidates the access_token
 *  early (happens frequently when refresh_tokens rotate on the backend). */
async function fetchGa4Metrics(
  propertyId: string,
  token: string,
  startDate: string,
  endDate: string,
  brandId?: string,
  channel: "all" | "organic" = "all",
): Promise<{ sessions: number; users: number; newUsers: number; pageViews: number; conversions: number } | null> {
  const url = `https://analyticsdata.googleapis.com/v1beta/properties/${propertyId}:runReport`;

  // Base body — metrics-only query. For channel==="all" we ship this
  // exactly as-is (no dimensionFilter) and get the unrestricted
  // totals, matching the original behaviour byte-for-byte.
  //
  // For channel==="organic" we layer a dimensionFilter on top. The
  // GA4 Data API v1beta surfaces the default channel group as
  // `sessionDefaultChannelGroup` (renamed from UA's
  // `sessionDefaultChannelGrouping`). Most modern properties use
  // the new name, but some older ones still accept the legacy
  // name only — we try the modern field first, then retry on the
  // legacy name if that yields zero sessions. A further retry
  // uses matchType="CONTAINS" value="Organic" to catch properties
  // with custom channel-group naming where "Organic Search" was
  // renamed or localised.
  //
  // The three-stage cascade only kicks in when the previous stage
  // returned zero — a healthy property with real organic traffic
  // hits stage 1, returns, and skips the extra calls.
  const baseBody = {
    dateRanges: [{ startDate, endDate }],
    metrics: [
      { name: "sessions" },
      { name: "totalUsers" },
      { name: "newUsers" },
      { name: "screenPageViews" },
      { name: "conversions" },
    ],
  };

  /** Executes the runReport call with an optional dimensionFilter.
   *  Returns null on non-ok response OR on first 401 (the outer
   *  refresh-and-retry logic below handles auth recovery). */
  async function runReport(label: string, dimensionFilter?: object): Promise<{ sessions: number; users: number; newUsers: number; pageViews: number; conversions: number } | null> {
    const body: Record<string, unknown> = { ...baseBody };
    if (dimensionFilter) body.dimensionFilter = dimensionFilter;

    const timeoutFallback = { ok: false as const, status: 0, data: null, error: "client timeout" };
    let res = await withTimeout(
      apiCall(url, { method: "POST", bearerToken: token, body }),
      GOOGLE_API_TIMEOUT_MS,
      timeoutFallback,
      `ga4:${label}:${startDate}..${endDate}`,
    );

    if (!res.ok && res.status === 401 && brandId) {
      // Bounded refresh — a stuck Google OAuth response must NOT be
      // allowed to hang past REFRESH_TIMEOUT_MS. If the refresh fails
      // or times out, bail and let the caller use the DB fallback.
      const freshToken = await boundedRefresh(brandId, "ga4");
      if (!freshToken) return null;
      res = await withTimeout(
        apiCall(url, { method: "POST", bearerToken: freshToken, body }),
        GOOGLE_API_TIMEOUT_MS,
        timeoutFallback,
        `ga4-retry:${label}:${startDate}..${endDate}`,
      );
    }

    if (!res.ok) return null;
    const data = res.data as { rows?: Array<{ metricValues?: Array<{ value: string }> }>; rowCount?: number };


    const mets = data.rows?.[0]?.metricValues ?? [];
    return {
      sessions: parseInt(mets[0]?.value ?? "0", 10),
      users: parseInt(mets[1]?.value ?? "0", 10),
      newUsers: parseInt(mets[2]?.value ?? "0", 10),
      pageViews: parseInt(mets[3]?.value ?? "0", 10),
      conversions: parseInt(mets[4]?.value ?? "0", 10),
    };
  }

  if (channel === "all") {
    return runReport("all");
  }

  // Organic path — three-stage cascade. Stop as soon as we find
  // non-zero sessions; a brand that genuinely has zero Organic
  // Search traffic will hit all three stages + return zeros, which
  // the caller's "organic-live-only" branch already handles.

  // Stage 1: canonical v1beta field + exact value.
  const ORGANIC_EXACT = {
    filter: { fieldName: "sessionDefaultChannelGroup", stringFilter: { matchType: "EXACT", value: "Organic Search" } },
  };
  const stage1 = await runReport("organic:sessionDefaultChannelGroup/exact", ORGANIC_EXACT);
  if (stage1 && stage1.sessions > 0) return stage1;

  // Stage 2: legacy UA field name. Some properties created in the
  // UA era keep this name alive; newer properties 400 on it, which
  // runReport catches and returns null from — we skip to stage 3.
  const LEGACY_EXACT = {
    filter: { fieldName: "sessionDefaultChannelGrouping", stringFilter: { matchType: "EXACT", value: "Organic Search" } },
  };
  const stage2 = await runReport("organic:sessionDefaultChannelGrouping/exact", LEGACY_EXACT);
  if (stage2 && stage2.sessions > 0) return stage2;

  // Stage 3: CONTAINS "Organic" on the canonical field. Catches
  // custom-named channel groups + properties where the default
  // value was renamed to e.g. "Organic" (no "Search" suffix) by
  // the operator. Accepts collateral damage from Organic Social /
  // Organic Video / Organic Shopping — imperfect but strictly
  // better than reporting zero when the filter mechanism itself
  // works. Matches the spec's quick-fallback guidance.
  const ORGANIC_CONTAINS = {
    filter: { fieldName: "sessionDefaultChannelGroup", stringFilter: { matchType: "CONTAINS", value: "Organic" } },
  };
  const stage3 = await runReport("organic:sessionDefaultChannelGroup/contains-organic", ORGANIC_CONTAINS);
  if (stage3 && stage3.sessions > 0) return stage3;

  // All three stages returned zero OR null. Return the last non-
  // null response so the caller's "live-empty" branch fires
  // consistently, or null if every stage errored (the caller
  // treats null identically).
  return stage3 ?? stage2 ?? stage1;
}

/** Fetch GSC aggregated totals for a date range using the no-dimension call.
 *  This returns a single row with TRUE period totals (not limited to top-N
 *  queries) and GSC's own impression-weighted avgPosition. */
async function fetchGscTotals(
  siteUrl: string,
  token: string,
  startDate: string,
  endDate: string,
  brandId?: string,
): Promise<{ clicks: number; impressions: number; avgPosition: number; ctr: number } | null> {
  const url = `https://www.googleapis.com/webmasters/v3/sites/${encodeURIComponent(siteUrl)}/searchAnalytics/query`;
  const body = { startDate, endDate };

  const timeoutFallback = { ok: false as const, status: 0, data: null, error: "client timeout" };
  let res = await withTimeout(
    apiCall(url, { method: "POST", bearerToken: token, body }),
    GOOGLE_API_TIMEOUT_MS,
    timeoutFallback,
    `gsc:${startDate}..${endDate}`,
  );

  // Retry on 401 with fresh token — bounded so a hung OAuth call
  // can't stall the whole dashboard request. Null on refresh failure
  // lets the caller fall through to the DB/stale fallbacks.
  if (!res.ok && res.status === 401 && brandId) {
    console.warn(`[dashboard:gsc] 401 for ${startDate}–${endDate}, refreshing token`);
    const freshToken = await boundedRefresh(brandId, "gsc");
    if (freshToken) {
      res = await withTimeout(
        apiCall(url, { method: "POST", bearerToken: freshToken, body }),
        GOOGLE_API_TIMEOUT_MS,
        timeoutFallback,
        `gsc-retry:${startDate}..${endDate}`,
      );
    }
  }

  if (!res.ok) { console.warn(`[dashboard:gsc] fetchGscTotals failed: ${res.status} for ${startDate}–${endDate}`); return null; }
  const data = res.data as { rows?: Array<{ clicks: number; impressions: number; ctr: number; position: number }> };
  const row = data.rows?.[0];
  if (!row) { return { clicks: 0, impressions: 0, avgPosition: 0, ctr: 0 }; }
  return {
    clicks: row.clicks,
    impressions: row.impressions,
    // GSC returns ctr as a decimal fraction — convert to % with 2 decimals
    ctr: Math.round(row.ctr * 10000) / 100,
    avgPosition: Math.round(row.position * 10) / 10,
  };
}

/**
 * Tail queries fan out in parallel and run alongside the GA4 / GSC
 * provider race. Each query is wrapped in `timed()` so per-query
 * latency lands in Vercel logs — the next "tail-queries timed out"
 * report shows exactly which single query is the cost without a
 * redeploy.
 *
 * .catch() on every query so a single bad row can't reject the
 * whole Promise.all. Module-scope so the GET handler can kick the
 * promise off before the provider race begins.
 */
/** Result shape for fetchTail. Exported-like type used by the handler
 *  to pre-seed a defaults object and to narrow return types. */
type TailRow = {
  auditRow: {
    id: string; status: string; crawlHealth: number; pagesScanned: number;
    errorCount: number; warningCount: number; noticeCount: number; passedCount: number;
    completedAt: Date | null; createdAt: Date; _count: { issues: number };
  } | null;
  deadPageCount: number;
  perfAudit: Awaited<ReturnType<typeof prisma.performanceAudit.findFirst<{ where: { brandId: string; status: string }; orderBy: { createdAt: "desc" }; include: { pages: { where: { device: string } } } }>>>;
  overdueTasks: number;
  completedThisWeek: number;
  connectedCount: number;
  ga4Integration: { lastSynced: Date | null } | null;
  gscIntegration: { lastSynced: Date | null } | null;
  installedAtDate: Awaited<ReturnType<typeof getSiteTagInstalledAt>>;
  trackedSiteExists: { id: string } | null;
  aiVisQueriesCount: number;
  aiVisLastCheck: { checkedAt: Date } | null;
  competitorCount: number;
  signalsCount: number;
  totalTaskCount: number;
};

async function fetchTail(brandId: string, deadlineMs: number): Promise<TailRow> {
  // Pre-seed the result with the same defaults the handler used to
  // fall back to when the outer race timed out. Each query below
  // overwrites its own slot as it resolves — if the deadline fires
  // before a given query finishes, that slot stays at its default,
  // but every other resolved query's value is preserved. Previously
  // an outer Promise.race threw away the ENTIRE fetchTail return
  // value when one slow query pushed the bundle past the deadline,
  // even when 14 of 15 queries had already completed.
  const result: TailRow = {
    auditRow: null, deadPageCount: 0, perfAudit: null, overdueTasks: 0,
    completedThisWeek: 0, connectedCount: 0, ga4Integration: null,
    gscIntegration: null, installedAtDate: null, trackedSiteExists: null,
    aiVisQueriesCount: 0, aiVisLastCheck: null, competitorCount: 0,
    signalsCount: 0, totalTaskCount: 0,
  };

  // Wrapper: log per-query duration, swallow rejection, and commit the
  // resolved value into the caller-provided result object under the
  // named slot. Returns the query promise so Promise.all at the call
  // site can still race the batch against the deadline.
  const tailStart = Date.now();
  const commit = <K extends keyof TailRow>(label: string, slot: K, p: Promise<TailRow[K]>): Promise<void> =>
    p
      .then((value) => { result[slot] = value; })
      .catch((err) => {
        console.error(`[dashboard:tail] ${label} threw`, { brandId, err: err instanceof Error ? err.message : "unknown" });
      });

  const since30d = new Date(Date.now() - 30 * 86400000);

  const all: Array<Promise<void>> = [
    commit("technicalAudit", "auditRow", prisma.technicalAudit.findFirst({
      where: { brandId, status: "completed" },
      orderBy: { createdAt: "desc" },
      select: {
        id: true, status: true, crawlHealth: true, pagesScanned: true,
        errorCount: true, warningCount: true, noticeCount: true, passedCount: true,
        completedAt: true, createdAt: true,
        _count: { select: { issues: { where: { status: "open" } } } },
      },
    })),
    commit("deadPage.count", "deadPageCount", prisma.deadPage.count({ where: { brandId, status: "open" } })),
    commit("performanceAudit", "perfAudit", prisma.performanceAudit.findFirst({
      where: { brandId, status: "completed" },
      orderBy: { createdAt: "desc" },
      include: { pages: { where: { device: "mobile" } } },
    })),
    commit("task.overdue", "overdueTasks", prisma.task.count({ where: { brandId, status: { not: "completed" }, dueDate: { lt: new Date() } } })),
    commit("task.completedThisWeek", "completedThisWeek", prisma.task.count({ where: { brandId, status: "completed", completedAt: { gte: new Date(Date.now() - 7 * 86400000) } } })),
    commit("brandIntegration.count", "connectedCount", prisma.brandIntegration.count({ where: { brandId, connected: true } })),
    commit("brandIntegration.ga4", "ga4Integration", prisma.brandIntegration.findFirst({
      where: { brandId, integrationId: "ga4", connected: true },
      select: { lastSynced: true },
    })),
    commit("brandIntegration.gsc", "gscIntegration", prisma.brandIntegration.findFirst({
      where: { brandId, integrationId: "gsc", connected: true },
      select: { lastSynced: true },
    })),
    commit("siteTagInstalledAt", "installedAtDate", getSiteTagInstalledAt(brandId)),
    commit("trackedSite", "trackedSiteExists", prisma.trackedSite.findUnique({ where: { brandId }, select: { id: true } })),
    commit("aiVisQuery.count", "aiVisQueriesCount", prisma.aiVisibilityQuery.count({ where: { brandId, isActive: true } })),
    commit("aiVisCheck.latest", "aiVisLastCheck", prisma.aiVisibilityCheck.findFirst({
      where: { brandId },
      orderBy: { checkedAt: "desc" },
      select: { checkedAt: true },
    })),
    commit("competitor.count", "competitorCount", prisma.competitor.count({ where: { brandId, isActive: true } })),
    commit("siteEvent.signalCount", "signalsCount", prisma.siteEvent.count({
      where: { brandId, eventType: { startsWith: "signal_" }, timestamp: { gte: since30d } },
    })),
    commit("task.totalCount", "totalTaskCount", prisma.task.count({ where: { brandId } })),
  ];

  // Race the batch against the deadline. Using Promise.allSettled +
  // a timeout sentinel means even a single stuck query can't prevent
  // the others from committing — and we always return `result` with
  // whatever slots have populated by the time the race fires. Swapped
  // away from the outer-race pattern that threw away all 14 fast
  // results just because one slow query hit the ceiling.
  await Promise.race([
    Promise.allSettled(all),
    new Promise<void>((resolve) => setTimeout(resolve, deadlineMs)),
  ]);

  const totalMs = Date.now() - tailStart;

  // Defensive audit-status diagnostic. If the canonical query (status
  // = "completed") came back null — and the overall deadline HASN'T
  // expired — chase a status-filter-free lookup to surface what's
  // actually in the DB. Non-blocking; only fires when useful. Runs
  // strictly inside the deadline so we don't push past our budget.

  return result;
}

export async function GET(req: Request) {
  const { searchParams } = new URL(req.url);
  const brandId = searchParams.get("brandId");
  const range = searchParams.get("range") ?? "28d";
  const customStart = searchParams.get("start") ?? undefined;
  const customEnd = searchParams.get("end") ?? undefined;
  // GA4 channel filter. "organic" restricts the live GA4 query to
  // sessions whose default channel group is "Organic Search" so
  // users can compare SEO-driven traffic against all traffic via a
  // toggle on the dashboard. The filter only applies to the live
  // fetch — the DB fallback (MetricSnapshot aggregates) doesn't
  // store per-channel splits, so the fallback path bails out and
  // the response marks ga4.source="organic-live-only" so the client
  // can tell the user "Live GA4 sync required for organic split"
  // instead of misreading it as "GA4 disconnected".
  const channel: "all" | "organic" = searchParams.get("channel") === "organic" ? "organic" : "all";
  const {
    ga4StartDate, startDate, endDate, totalDays,
    prevStartDate, prevEndDate,
  } = getDateRange(range, customStart, customEnd);

  if (!brandId) {
    return NextResponse.json({ error: "brandId is required" }, { status: 400 });
  }

  // ── Cron-secret bypass ─────────────────────────────────────────
  // The precompute-dashboard cron calls this endpoint with
  // Authorization: Bearer <CRON_SECRET> to pre-warm DashboardSnapshot.
  // Clerk session auth is not available in a cron context, so we
  // accept the shared secret as an alternative identity. The bypass
  // is strictly scoped: brandId must be present (enforced above) and
  // CRON_SECRET must be non-empty. No unscoped access is possible.
  const cronSecret = process.env.CRON_SECRET;
  const isCronRequest =
    !!cronSecret &&
    req.headers.get("authorization") === `Bearer ${cronSecret}`;

  if (!isCronRequest) {
    // Standard Clerk path — all browser/user requests go here.
    const user = await getCurrentUser();
    if (!user) {
      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
    }
    if (!(await verifyBrandAccess(user.id, brandId, req))) {
      return NextResponse.json({ error: "You don't have access to this brand" }, { status: 403 });
    }
  }

  // [PERF] timing — stamps at the start, after each major phase, and at
  // total. Surfaces bottlenecks in Vercel logs without adding APM.
  const perfStart = Date.now();

  try {
    const brand = await prisma.brand.findUnique({
      where: { id: brandId },
    });

    if (!brand) {
      return NextResponse.json({ error: "Brand not found" }, { status: 404 });
    }

    log.info("api", `Dashboard loading for ${brand.name} (${brandId})`, {
      path: "/api/dashboard",
      metadata: { brandId },
    });

    // Per-request snapshot of every KPI's raw (current, previous,
    // pctChange) tuple. Populated inside the GA4 + GSC branches
    // below and logged in one line just before the final
    // NextResponse.json so the Vercel log surfaces every
    // comparison that drove the client-side deltas. Useful for
    // auditing "this percentage looks wrong" reports without
    // having to crawl the code path by hand.
    const metricDebug: Record<string, { current: number | null; previous: number | null; pctChange: number | null }> = {
      sessions:    { current: null, previous: null, pctChange: null },
      newUsers:    { current: null, previous: null, pctChange: null },
      impressions: { current: null, previous: null, pctChange: null },
      clicks:      { current: null, previous: null, pctChange: null },
      avgPosition: { current: null, previous: null, pctChange: null },
      avgCtr:      { current: null, previous: null, pctChange: null },
    };
    function recordMetricDebug(
      key: keyof typeof metricDebug,
      current: number | null | undefined,
      previous: number | null | undefined,
    ) {
      const cur = typeof current === "number" ? current : null;
      const prev = typeof previous === "number" ? previous : null;
      let pct: number | null = null;
      if (cur != null && prev != null && prev !== 0) {
        pct = Math.round(((cur - prev) / prev) * 1000) / 10;
      }
      metricDebug[key] = { current: cur, previous: prev, pctChange: pct };
    }

    // ── Integration state snapshot (diagnostic) ────────────────────
    // Logs the current GA4 + GSC state so Vercel logs make it obvious
    // whether a dashboard request is even attempting live fetches. A
    // brand whose tokens never get refreshed shows up here as
    // `status=expired` or `tokenExpiresAt` in the past on every
    // request — without this snapshot, you have to infer the state
    // from the absence of the provider logs that follow.
    const integStates = await prisma.brandIntegration.findMany({
      where: { brandId, integrationId: { in: ["ga4", "gsc"] } },
      select: {
        integrationId: true,
        connected: true,
        status: true,
        tokenExpiresAt: true,
        lastSynced: true,
        syncError: true,
        syncRetryCount: true,
      },
    }).catch(() => []);
    // ── Preflight: force-refresh any token that's within 5 minutes
    //    of expiry. Runs BEFORE the provider tasks kick off so both
    //    GA4 and GSC start their fetches against a fresh token. The
    //    existing `autoRefresh: true` inside getCredentials only
    //    buffers 60s, which isn't enough headroom on brands where
    //    the GA4 report itself runs 8-10s.
    await ensureTokensFresh(brandId);

    // ── GA4 + GSC run in parallel ──────────────────────────────────
    // Previously the two provider blocks ran sequentially — GA4 had
    // to fully finish (including its 10s timeout) before GSC even
    // started. Now both kick off at the same time and we Promise.all
    // them at the bottom. A stalled GA4 call no longer delays GSC
    // data from surfacing; the worst case is the slower of the two
    // (bounded at ~20s for the full refresh+retry chain on each).
    //
    // The block bodies are preserved verbatim — each is wrapped in
    // an async IIFE that mutates the outer `ga4`/`gsc` locals
    // instead of returning. Keeps the diff focused on the control
    // flow while leaving the 300 lines of fetch logic untouched.
    type Ga4Deltas = {
      sessions?: { pct: number; direction: "up" | "down" | "flat" } | null;
      users?: { pct: number; direction: "up" | "down" | "flat" } | null;
      newUsers?: { pct: number; direction: "up" | "down" | "flat" } | null;
      conversions?: { pct: number; direction: "up" | "down" | "flat" } | null;
    };
    let ga4: {
      connected: boolean;
      sessions?: number; users?: number; newUsers?: number; pageViews?: number; conversions?: number;
      revenue?: number; source?: string; deltas?: Ga4Deltas;
      /** Which GA4 channel slice this response represents. "all" =
       *  unrestricted traffic (historical default), "organic" = only
       *  sessions where sessionDefaultChannelGroup === "Organic Search".
       *  Echoed in the response so the client can render the correct
       *  toggle state and show source-specific copy (e.g. "organic
       *  split requires live GA4" when source === "organic-live-only"). */
      channel?: "all" | "organic";
    } = { connected: false };

    // ── Short-circuit GA4 when credentials are known-broken ────────
    // If the integration-state snapshot earlier in this handler shows
    // GA4 in `status=error` with a credentials-style syncError, the
    // live fetch will always fail — and on bad paths can eat our
    // entire function budget chasing a refresh token that no longer
    // exists. Jump straight to the stale DB fallback and skip the
    // ga4Task IIFE entirely. Saves 10-22s on an account that needs
    // reconnection.
    const ga4IntegState = integStates.find((s) => s.integrationId === "ga4");
    const ga4CredsBroken = !!ga4IntegState && (
      ga4IntegState.status === "error" &&
      (ga4IntegState.syncError ?? "").toLowerCase().includes("no credentials")
    );
    if (ga4CredsBroken) {
      // For channel=organic we can't honour the request without live
      // GA4 (MetricSnapshot is all-channel). Short-circuit to the
      // organic-live-only shape rather than returning stale total-
      // traffic numbers that would be misleading next to an
      // "Organic" toggle.
      if (channel === "organic") {
        ga4 = {
          connected: true,
          sessions: 0, users: 0, newUsers: 0, pageViews: 0, conversions: 0,
          source: "organic-live-only",
          channel,
        };
      } else {
        try {
          const stale = await aggregateGa4StaleFallback(brandId);
          if (stale) {
            ga4 = {
              connected: true,
              sessions: stale.sessions,
              users: stale.users,
              newUsers: stale.newUsers,
              pageViews: stale.pageViews,
              conversions: stale.conversions,
              revenue: 0,
              source: "stale",
              channel,
            };
          }
        } catch { /* leave ga4 at {connected:false} */ }
      }
    }

    const ga4Task = ga4CredsBroken ? Promise.resolve() : (async () => {
    try {
      // autoRefresh: true triggers refreshGoogleAccessToken inline if the
      // stored token is expired or expiring within 60 seconds. Without it,
      // a stale token would cascade through the GA4 path: fetchGa4Metrics
      // sees 401 → forceRefresh → retry, but the *previous* period query
      // running in parallel hits the same expired token at the same moment
      // and races on the refresh, sometimes returning null. Auto-refreshing
      // here means both parallel calls below start with a known-fresh token.
      const ga4Creds = await getCredentials(brandId, "ga4", { autoRefresh: true });

      if (ga4Creds?.accessToken && ga4Creds?.propertyId) {
        log.info("api", `GA4 credentials found. Property: ${ga4Creds.propertyId}`, {
          metadata: { brandId, propertyId: ga4Creds.propertyId },
        });

        // Previously this block did three more token dances before
        // fetching: a status="expired" recovery refresh, a
        // getValidAccessToken (which may refresh), and then later an
        // outer retry-with-refresh loop on sessions=0. With the
        // preflight ensureTokensFresh already handling both the 5min
        // buffer and status="expired" case, all three were redundant
        // — and each was an unbounded OAuth call that could stall
        // the function for 60s+ apiece on a bad refresh_token. We
        // now go straight to the fetch with whatever token
        // getCredentials({ autoRefresh }) returned. fetchGa4Metrics'
        // own bounded 401-retry still handles the edge case where
        // that token races Google's token-validity check.
        const ga4Token = ga4Creds.accessToken as string;
        const propertyId = ga4Creds.propertyId as string;

        // Fetch current + previous period in parallel for KPI deltas.
        // Use ISO dates for BOTH start and end so GA4 and GSC receive the
        // exact same query window regardless of the property's timezone.
        log.info("api", `GA4 query: ${startDate} to ${endDate} (${range})`, {
          metadata: { brandId, range, startDate, endDate, prevStartDate, prevEndDate },
        });
        // Only the CURRENT period hits the live API. The previous-
        // period numbers come from MetricSnapshot, populated by the
        // ga4-sync cron — that data is good for delta computation
        // and we save a full 6s timeout window per request. Was a
        // parallel pair of fetchGa4Metrics calls; doubling the live
        // calls also doubled the chance of a timeout cascading into
        // the function deadline.
        const [current, previous] = await Promise.all([
          fetchGa4Metrics(propertyId, ga4Token, ga4StartDate, endDate, brandId, channel),
          // Previous-period delta: only meaningful for "all" traffic
          // since MetricSnapshot doesn't store per-channel splits. For
          // organic, we skip the delta — the card shows current-period
          // numbers without a trend arrow, which is accurate given we
          // have no organic baseline to compare against.
          channel === "all" ? aggregateGa4FromDb(brandId, prevStartDate, prevEndDate) : Promise.resolve(null),
        ]);

        // No outer retry here. The previous implementation ran a
        // second forceRefresh + parallel-fetch pair whenever `current`
        // had sessions=0, which on a broken refresh_token added
        // 10-20s of wasted wall-clock time *after* the preflight
        // refresh had already failed. The DB + stale fallbacks below
        // cover the "live returned empty" case instantly; retrying
        // the live API almost never recovers and always blocks the
        // function from reaching the fallback path in time.

        if (current && current.sessions > 0) {
          ga4 = {
            connected: true,
            sessions: current.sessions,
            users: current.users,
            newUsers: current.newUsers,
            pageViews: current.pageViews,
            conversions: current.conversions,
            revenue: 0,
            source: "live",
            channel,
            deltas: previous ? {
              sessions: computeDelta(current.sessions, previous.sessions),
              users: computeDelta(current.users, previous.users),
              newUsers: computeDelta(current.newUsers, previous.newUsers),
              conversions: computeDelta(current.conversions, previous.conversions),
            } : undefined,
          };
          recordMetricDebug("sessions", current.sessions, previous?.sessions);
          recordMetricDebug("newUsers", current.newUsers, previous?.newUsers);
          log.info("api", `GA4 live data: ${ga4.sessions} sessions (${channel})`, {
            metadata: { brandId, channel, ...ga4 },
          });
        } else if (channel === "organic") {
          // Live path returned nothing AND we can't use the DB
          // fallback (MetricSnapshot has no per-channel columns).
          // Surface an explicit "live-only" status so the client
          // can render a helpful note instead of silently falling
          // back to all-traffic cached data and mislabelling it
          // as organic.
          ga4 = {
            connected: true,
            sessions: 0,
            users: 0,
            newUsers: 0,
            pageViews: 0,
            conversions: 0,
            source: "organic-live-only",
            channel,
          };
          log.info("api", "GA4 organic filter: live fetch returned no data; DB fallback skipped (per-channel not stored)", {
            metadata: { brandId, channel },
          });
        } else {
          // Live API came back empty even after the refresh+retry. Fall
          // back to MetricSnapshot rows written by the GA4 sync (mirrors
          // the GSC fromDb fallback above). Surfaces "Data may be delayed"
          // in the UI rather than a misleading 0 across a connected
          // property. cached fallback only kicks in when there's actually
          // cached data — otherwise we still report 0 but with source:"error"
          // so the UI knows to show a friendlier "Temporarily unavailable"
          // message.
          const cached = await aggregateGa4FromDb(brandId, startDate, endDate);
          const cachedPrev = await aggregateGa4FromDb(brandId, prevStartDate, prevEndDate);
          if (cached) {
            ga4 = {
              connected: true,
              sessions: cached.sessions,
              users: cached.users,
              newUsers: cached.newUsers,
              pageViews: cached.pageViews,
              conversions: cached.conversions,
              revenue: 0,
              source: "cached",
              channel,
              deltas: cachedPrev ? {
                sessions: computeDelta(cached.sessions, cachedPrev.sessions),
                users: computeDelta(cached.users, cachedPrev.users),
                newUsers: computeDelta(cached.newUsers, cachedPrev.newUsers),
                conversions: computeDelta(cached.conversions, cachedPrev.conversions),
              } : undefined,
            };
            recordMetricDebug("sessions", cached.sessions, cachedPrev?.sessions);
            recordMetricDebug("newUsers", cached.newUsers, cachedPrev?.newUsers);
            log.info("api", `GA4 cached fallback: ${ga4.sessions} sessions from MetricSnapshot`, {
              metadata: { brandId, source: "cached" },
            });
          } else {
            // Live fetch failed AND in-range DB had no data. Last
            // resort: look up the most recent MetricSnapshot we have
            // for this brand and aggregate the 28 days ending there.
            // Surfaces "last known good" numbers with source:"stale"
            // so the UI can show a freshness hint instead of pinning
            // every card on 0.
            const stale = await aggregateGa4StaleFallback(brandId);
            if (stale) {
              ga4 = {
                connected: true,
                sessions: stale.sessions,
                users: stale.users,
                newUsers: stale.newUsers,
                pageViews: stale.pageViews,
                conversions: stale.conversions,
                revenue: 0,
                source: "stale",
                channel,
              };
              log.warn("api", `GA4 stale fallback: ${stale.sessions} sessions, window ending ${stale.windowEnd}`, {
                metadata: { brandId, source: "stale", windowEnd: stale.windowEnd },
              });
            } else {
              log.warn("api", `GA4 API returned no data and no cached fallback available`, { metadata: { brandId } });
              ga4 = { connected: true, sessions: 0, users: 0, newUsers: 0, pageViews: 0, conversions: 0, source: "error", channel };
            }
          }
        }
      } else {
        log.info("api", `GA4 not connected for ${brandId}`, {
          metadata: { brandId, hasToken: !!ga4Creds?.accessToken, hasProperty: !!ga4Creds?.propertyId },
        });
      }
    } catch (err) {
      log.error("api", `GA4 fetch error: ${err instanceof Error ? err.message : "unknown"}`, {
        metadata: { brandId },
      });
      // Dump the stack separately so Vercel logs show the throw site.
      // log.error stringifies the metadata, so adding err there loses
      // the stack; console.error keeps the full Error object.
      console.error("[dashboard:ga4] caught error", err);
    }
    })();

    // ── GSC: fetch live data if connected ──
    // Uses THREE parallel calls:
    //   1. No-dimension aggregate for true period totals (clicks,
    //      impressions, weighted avgPosition, true CTR). The previous
    //      behaviour summed the top 10 queries only — that undercounts
    //      clicks by 50-90% for most brands.
    //   2. Top-10 query list for the "Top Queries" section.
    //   3. Top-5 page list for the "Top Pages" section.
    type GscDeltas = {
      clicks?: { pct: number; direction: "up" | "down" | "flat" } | null;
      impressions?: { pct: number; direction: "up" | "down" | "flat" } | null;
      avgPosition?: { pct: number; direction: "up" | "down" | "flat" } | null;
      ctr?: { pct: number; direction: "up" | "down" | "flat" } | null;
    };
    let gsc: {
      connected: boolean;
      clicks?: number; impressions?: number; avgPosition?: number; ctr?: number;
      topPages?: Array<{ pageUrl: string; clicks: number; impressions: number }>;
      topQueries?: Array<{ query: string; clicks: number; position: number }>;
      deltas?: GscDeltas;
      coverage?: { daysAvailable: number; daysRequested: number };
      fromDb?: boolean;
      /** Set when the numbers above came from the last-known-good
       *  stale fallback rather than the requested date range. */
      source?: string;
      staleWindowEnd?: string;
    } = { connected: false };

    // Same short-circuit as GA4: if GSC is in a known-broken state,
    // skip the live fetch and hydrate from the in-range DB data
    // (which can still be fine if the sync cron populated gscPage
    // recently). Saves a full ~10s timeout window on brands whose
    // GSC creds need manual reconnection.
    const gscIntegState = integStates.find((s) => s.integrationId === "gsc");
    const gscCredsBroken = !!gscIntegState && (
      gscIntegState.status === "error" &&
      (gscIntegState.syncError ?? "").toLowerCase().includes("no credentials")
    );
    if (gscCredsBroken) {
      try {
        const stale = await aggregateGscStaleFallback(brandId);
        if (stale) {
          gsc = {
            connected: true,
            clicks: stale.clicks,
            impressions: stale.impressions,
            avgPosition: stale.avgPosition,
            ctr: stale.ctr,
            topQueries: [],
            topPages: [],
            source: "stale",
            staleWindowEnd: stale.windowEnd,
          };
        }
      } catch { /* leave gsc at {connected:false} */ }
    }

    const gscTask = gscCredsBroken ? Promise.resolve() : (async () => {
    try {
      const gscCreds = await getCredentials(brandId, "gsc");
      if (gscCreds?.accessToken && (gscCreds?.siteUrl || gscCreds?.accountId)) {
        const siteUrl = (gscCreds.siteUrl as string) || (gscCreds.accountId as string);

        log.info("api", `GSC credentials found. Site: ${siteUrl}`, {
          metadata: { brandId, siteUrl },
        });

        // Use the stored access_token directly. Preflight
        // ensureTokensFresh already force-refreshed on the 5min
        // buffer, so the stored token is the freshest thing we
        // have — calling getValidAccessToken here just added a
        // 5s wait (confirmed in production logs:
        // "getValidAccessToken timed out after 5000ms") because
        // its own inline refresh path can hang on the OAuth
        // endpoint even when the token is valid. If the stored
        // token is somehow rejected, fetchGscTotals' bounded
        // 401-retry picks up the recovery path.
        const gscToken = gscCreds.accessToken as string;

        log.info("api", `GSC query: ${siteUrl}, ${startDate} to ${endDate}`, { metadata: { brandId, startDate, endDate } });

        // Two parallel reads only: live current-period totals from
        // GSC, and previous-period totals from the DB for delta
        // computation. Top queries / top pages used to ride along
        // here but moved to /api/dashboard/search-performance —
        // their two extra GSC calls were the slowest single thing
        // in the request, often >10s, and they're not in the user's
        // first viewport anyway.
        // Previous-period GSC from DB via shared getGSCMetrics —
        // uses the same aggregate path every other surface calls so
        // the "previous period" delta is computed against an identical
        // query shape.
        const [currentTotals, prevGsc] = await Promise.all([
          fetchGscTotals(siteUrl, gscToken, startDate, endDate, brandId),
          getGSCMetrics(brandId, new Date(prevStartDate), new Date(prevEndDate)).catch(() => null),
        ]);

        const previousTotals = prevGsc && (prevGsc.clicks > 0 || prevGsc.impressions > 0) ? prevGsc : null;

        // DB fallback: if GSC API returns empty (common for custom ranges before property was verified)
        let usedDbFallback = false;
        let effectiveCurrent = currentTotals;
        let effectivePrevious = previousTotals;

        // DB fallback via shared getGSCMetrics when GSC API returns
        // empty (common for custom ranges before the property was
        // verified). Uses the identical aggregate path every surface
        // calls so the numbers never drift.
        if (!effectiveCurrent || (effectiveCurrent.clicks === 0 && effectiveCurrent.impressions === 0)) {
          const dbCurrent = await getGSCMetrics(brandId, new Date(startDate), new Date(endDate));
          if (dbCurrent.clicks > 0) {
            effectiveCurrent = dbCurrent;
            usedDbFallback = true;

            const dbPrev = await getGSCMetrics(brandId, new Date(prevStartDate), new Date(prevEndDate));
            if (dbPrev.clicks > 0) {
              effectivePrevious = dbPrev;
            }
          }
        }

        if (effectiveCurrent && (effectiveCurrent.clicks > 0 || effectiveCurrent.impressions > 0)) {
          gsc = {
            connected: true,
            clicks: effectiveCurrent.clicks,
            impressions: effectiveCurrent.impressions,
            avgPosition: effectiveCurrent.avgPosition,
            ctr: effectiveCurrent.ctr,
            fromDb: usedDbFallback,
            deltas: effectivePrevious ? {
              clicks: computeDelta(effectiveCurrent.clicks, effectivePrevious.clicks),
              impressions: computeDelta(effectiveCurrent.impressions, effectivePrevious.impressions),
              avgPosition: (() => {
                const d = computeDelta(effectiveCurrent!.avgPosition, effectivePrevious!.avgPosition);
                if (!d) return d;
                return { pct: d.pct, direction: d.direction === "up" ? "down" : d.direction === "down" ? "up" : "flat" };
              })(),
              ctr: computeDelta(effectiveCurrent.ctr, effectivePrevious.ctr),
            } : undefined,
          };
          recordMetricDebug("clicks",      effectiveCurrent.clicks,      effectivePrevious?.clicks);
          recordMetricDebug("impressions", effectiveCurrent.impressions, effectivePrevious?.impressions);
          recordMetricDebug("avgPosition", effectiveCurrent.avgPosition, effectivePrevious?.avgPosition);
          recordMetricDebug("avgCtr",      effectiveCurrent.ctr,         effectivePrevious?.ctr);

          log.info("api", `GSC totals${usedDbFallback ? " (DB)" : ""}: ${effectiveCurrent.clicks} clicks, ${effectiveCurrent.impressions} impressions`, { metadata: { brandId, usedDbFallback } });
        } else {
          // Live API + in-range DB both empty. Try the last-known-good
          // fallback — look up the most recent gscPage date for this
          // brand and aggregate the 28 days ending there. Surfaces
          // source:"stale" so the UI can show "data may be delayed"
          // rather than pinning Impressions / Clicks / Avg Position
          // / CTR all on 0 for a brand with a backlog of real data.
          const stale = await aggregateGscStaleFallback(brandId);
          if (stale) {
            gsc = {
              connected: true,
              clicks: stale.clicks,
              impressions: stale.impressions,
              avgPosition: stale.avgPosition,
              ctr: stale.ctr,
              topQueries: [],
              topPages: [],
              source: "stale",
              staleWindowEnd: stale.windowEnd,
            };
            log.warn("api", `GSC stale fallback: ${stale.clicks} clicks, window ending ${stale.windowEnd}`, {
              metadata: { brandId, source: "stale", windowEnd: stale.windowEnd },
            });
          } else {
            log.warn("api", `GSC totals API returned no data and no stale fallback available`, { metadata: { brandId } });
            gsc = { connected: true, clicks: 0, impressions: 0, avgPosition: 0, ctr: 0, topQueries: [], topPages: [] };
          }
        }

        // topQueries / topPages now ship from the lazy
        // /api/dashboard/search-performance endpoint that the
        // frontend hits AFTER the main dashboard renders. Removed
        // here so the GSC block returns as soon as totals are in;
        // the fields stay on the response shape for back-compat
        // but always empty — the Search Performance card waits for
        // the lazy fetch to populate them.
        gsc.topQueries = [];
        gsc.topPages = [];

        // Data coverage: count distinct synced days in MetricSnapshot for
        // this brand's GSC source within the current range. If the user
        // requested 90 days but the backfill only pulled 28, this surfaces
        // the gap so the UI can render "GSC · 28 of 90 days available".
        const coveredDays = await prisma.metricSnapshot.count({
          where: {
            brandId,
            source: "gsc",
            date: { gte: new Date(startDate), lte: new Date(endDate) },
          },
        });
        gsc.coverage = { daysAvailable: coveredDays, daysRequested: totalDays };
      }
    } catch (err) {
      log.error("api", `GSC fetch error: ${err instanceof Error ? err.message : "unknown"}`, { metadata: { brandId } });
      console.error("[dashboard:gsc] caught error", err);
    }
    })();

    // Wait for both provider blocks to finish — but race them against
    // a hard function deadline (FUNCTION_DEADLINE_MS from request
    // start). The race returns a sentinel so we know whether both
    // tasks finished naturally or we cut them loose; on deadline we
    // explicitly populate the ga4/gsc locals from the DB so the
    // response carries real numbers instead of {connected: false}.
    //
    // Previously the race awaited without tracking the winner, so if
    // the deadline fired while a task was still hung mid-fetch the
    // outer locals stayed at their initial {connected: false} value
    // — the final response then looked like a clean "nothing
    // connected" state even for brands with 45k clicks sitting in
    // gscPage. Populating fallbacks here closes that gap.
    // ── KICK OFF tail queries IN PARALLEL with the provider race ──
    // Tail queries are pure local DB reads (audit row, dead pages,
    // tasks, AI Vis count, competitor count). They have no dependency
    // on the GA4/GSC race result, so making them wait for the
    // providers to finish was wasting the whole 12s function budget
    // on the tail. Now they start at the same time as the providers
    // and we await their result later, so they get whatever budget
    // they need instead of "12s minus whatever the providers ate".
    //
    // Deadline handling lives INSIDE fetchTail now. Previously an
    // outer Promise.race wrapped the whole function, so any slow
    // single query pushed fetchTail past the deadline and the race
    // discarded every completed query alongside the slow one — the
    // dashboard audit-debug log kept showing `exists: false` even
    // on brands whose audit query itself resolved in <50ms, because
    // a different tail query (e.g. siteTagInstalledAt on a heavy
    // pool-pressure minute) pushed the bundle past the 10s ceiling.
    // The rewritten fetchTail commits each resolved query's value to
    // a shared result object as it lands and races that pipeline
    // against the deadline — so even if one query hangs, every
    // other resolved query's value is preserved.
    const TAIL_DEADLINE_MS = 10_000;
    const tailStart = Date.now();
    const tailPromise = fetchTail(brandId, TAIL_DEADLINE_MS);

    const timeLeft = Math.max(1000, FUNCTION_DEADLINE_MS - (Date.now() - perfStart));
    const raceResult: "completed" | "deadline" = await Promise.race([
      Promise.all([ga4Task, gscTask]).then(() => "completed" as const),
      new Promise<"deadline">((resolve) => setTimeout(() => resolve("deadline"), timeLeft)),
    ]);

    if (raceResult === "deadline") {
      const elapsed = Date.now() - perfStart;
      console.warn(`[dashboard] function deadline hit at ${elapsed}ms — releasing provider tasks, populating DB fallbacks`, { brandId });

      // Populate GA4 + GSC from DB in parallel. Only overwrite locals
      // that are still in their initial "nothing" state — if a task
      // actually finished and wrote live/cached/stale data before the
      // deadline, we respect that. Fallbacks run in parallel since
      // they're pure DB reads bounded by Prisma pool latency.
      const [ga4Fallback, gscInRange, gscStale] = await Promise.all([
        // GA4 deadline fallback is skipped entirely when channel=organic —
        // the stale aggregate has no per-channel split, so using it
        // would silently show total-traffic numbers under the Organic
        // toggle. Leave ga4 at connected:false and let the organic-
        // live-only branch below set the correct shape.
        (!ga4.connected && channel === "all") ? aggregateGa4StaleFallback(brandId).catch(() => null) : Promise.resolve(null),
        // Deadline-fallback: in-range GSC from DB via shared helper.
        !gsc.connected ? getGSCMetrics(brandId, new Date(startDate), new Date(endDate)).catch(() => null) : Promise.resolve(null),
        !gsc.connected ? aggregateGscStaleFallback(brandId).catch(() => null) : Promise.resolve(null),
      ]);

      if (!ga4.connected && ga4Fallback) {
        ga4 = {
          connected: true,
          sessions: ga4Fallback.sessions,
          users: ga4Fallback.users,
          newUsers: ga4Fallback.newUsers,
          pageViews: ga4Fallback.pageViews,
          conversions: ga4Fallback.conversions,
          revenue: 0,
          source: "stale",
          channel,
        };
      } else if (!ga4.connected && channel === "organic") {
        // Same rationale as ga4CredsBroken above — when the deadline
        // fires and we can't use the DB fallback for organic, surface
        // "organic-live-only" so the client can render a helpful
        // message rather than the generic "Connect GA4" prompt.
        ga4 = {
          connected: true,
          sessions: 0, users: 0, newUsers: 0, pageViews: 0, conversions: 0,
          source: "organic-live-only",
          channel,
        };
      }

      if (!gsc.connected) {
        // Prefer in-range DB data (from shared getGSCMetrics) over the
        // stale fallback so the numbers match the requested range.
        // gscInRange now returns the GSCMetrics shape { clicks,
        // impressions, avgPosition, ctr } directly — no _sum/_avg.
        if (gscInRange && (gscInRange.clicks > 0 || gscInRange.impressions > 0)) {
          gsc = {
            connected: true,
            clicks: gscInRange.clicks,
            impressions: gscInRange.impressions,
            avgPosition: gscInRange.avgPosition,
            ctr: gscInRange.ctr,
            topQueries: [],
            topPages: [],
            source: "cached",
            fromDb: true,
          };
        } else if (gscStale) {
          gsc = {
            connected: true,
            clicks: gscStale.clicks,
            impressions: gscStale.impressions,
            avgPosition: gscStale.avgPosition,
            ctr: gscStale.ctr,
            topQueries: [],
            topPages: [],
            source: "stale",
            staleWindowEnd: gscStale.windowEnd,
          };
        }
      }
    }

    // ── Tail queries — kicked off before the provider race above.
    //    Awaiting the promise here returns whatever fetchTail's
    //    shared-result accumulator populated within TAIL_DEADLINE_MS.
    //    Unresolved slots remain at their pre-seeded defaults (null /
    //    0) rather than erasing every completed query alongside one
    //    slow one.
    const tail = await tailPromise;
    const { auditRow, deadPageCount, perfAudit, overdueTasks, completedThisWeek, connectedCount, ga4Integration, gscIntegration, installedAtDate, trackedSiteExists, aiVisQueriesCount, aiVisLastCheck, competitorCount, signalsCount, totalTaskCount } = tail;
    // Diagnostic — when the Technical Issues / Execution / Recs cards
    // render as "Run a technical audit" / "No tasks yet" the user has
    // no way to tell whether the data is missing because the brand
    // genuinely has no audit, the deadline fired, or the catch silently
    // swallowed a Prisma error. This log makes that distinction visible
    // in Vercel without redeploying.
    // Expanded audit-debug log. Previously we logged "auditRowPresent" as
    // a boolean, which hid a whole category of failure: a brand can have
    // a completed audit row whose counts are all 0 because the audit
    // ran but nothing was wrong. The dashboard then correctly showed
    // "0 warnings, 0 errors", but the user read that as "nothing is
    // working". Dumping the raw stored counts + status side-by-side
    // lets the next debug session go straight to Vercel logs for an
    // answer rather than into a code hunt.
    // ── Technical Audit ──
    // Read EVERY count from the stored fields on the audit row (not from
    // joined issues). These are the exact same fields the Technical Audit
    // page at /technical-audit renders — so if the Technical Audit page
    // shows "88/100, 0 errors, 1 warning, 0 notices, 175 passed", this
    // dashboard response emits the same numbers byte-for-byte. Previously
    // the dashboard derived errors/warnings by filtering joined issue
    // rows by severity, which diverged from the stored aggregates
    // whenever issue rows' severity values didn't line up with the
    // "critical|high|medium|low" canonical set.
    let audit: { connected: boolean; crawlHealth?: number; errors?: number; warnings?: number; notices?: number; passed?: number; totalIssues?: number; openIssues?: number; pagesScanned?: number; lastAuditAt?: string } = { connected: false };
    if (auditRow) {
      const totalIssues = auditRow.errorCount + auditRow.warningCount + auditRow.noticeCount;
      audit = {
        connected: true,
        crawlHealth: auditRow.crawlHealth,
        errors: auditRow.errorCount,
        warnings: auditRow.warningCount,
        notices: auditRow.noticeCount,
        passed: auditRow.passedCount,
        totalIssues,
        // Open issues can be 0 even when totalIssues > 0 (e.g. everything
        // has been acknowledged / fixed). Exposed separately so the
        // client can show "5 issues · 2 still open" instead of misleading
        // users with a total that doesn't match their resolution state.
        openIssues: auditRow._count.issues,
        // Surface the page count from the latest audit so the dashboard
        // Crawl Health card can render "88% · 200 pages audited" instead
        // of just the health percentage.
        pagesScanned: auditRow.pagesScanned,
        lastAuditAt: auditRow.completedAt?.toISOString() ?? auditRow.createdAt.toISOString(),
      };
    }

    const perfScore = perfAudit && perfAudit.pages.length > 0
      ? Math.round(perfAudit.pages.reduce((s, p) => s + p.performance, 0) / perfAudit.pages.length)
      : null;

    // ── Core Web Vitals pass rate ─────────────────────────────────────
    // A page is considered "passing" Core Web Vitals when ALL three of
    // LCP / CLS / INP are in Google's "good" band:
    //   LCP ≤ 2500ms  ·  CLS ≤ 0.1  ·  INP ≤ 200ms
    // We count one row per (URL, device) pair from the latest audit so
    // a brand with 25 URLs × 2 devices = 50 rows.
    //
    // The dashboard surfaces the pass rate alongside the existing
    // performance score so the user sees BOTH "average lighthouse
    // score" (perfScore, 0-100) AND "what fraction of pages clear the
    // CWV bar" (passRate, 0-1). They're complementary — a 70/100 site
    // can still have 0% CWV pass if every page narrowly misses the
    // LCP threshold.
    const cwv = perfAudit && perfAudit.pages.length > 0
      ? (() => {
          const total = perfAudit.pages.length;
          const passing = perfAudit.pages.filter((p) =>
            p.lcpMs > 0 && p.lcpMs <= 2500 &&
            p.clsVal <= 0.1 &&
            (p.inpMs === 0 || p.inpMs <= 200) // 0 = not measured; don't penalise
          ).length;
          return {
            total,
            passing,
            passRate: Math.round((passing / total) * 100),
            // Per-metric pass counts so the dashboard can render
            // "LCP: 4/25 pass" style breakdowns inline if it wants.
            lcpPassing: perfAudit.pages.filter((p) => p.lcpMs > 0 && p.lcpMs <= 2500).length,
            clsPassing: perfAudit.pages.filter((p) => p.clsVal <= 0.1).length,
            inpPassing: perfAudit.pages.filter((p) => p.inpMs === 0 || p.inpMs <= 200).length,
          };
        })()
      : null;

    const siteTagAge = siteTagAgeDays(installedAtDate);

    // Write-through: save to DashboardSnapshot so /api/dashboard/summary
    // can serve it instantly on the next load. Fire-and-forget — must
    // never block the response. Bootstraps the snapshot table before the
    // precompute cron's first run.
    if (range !== "custom") {
      prisma.dashboardSnapshot.upsert({
        where: { brandId_range: { brandId, range } },
        create: {
          brandId, range, computedAt: new Date(),
          payload: JSON.parse(JSON.stringify({
            ga4: { ...ga4, lastSynced: ga4Integration?.lastSynced?.toISOString() ?? null },
            gsc: { ...gsc, lastSynced: gscIntegration?.lastSynced?.toISOString() ?? null },
            audit,
            brand: { id: brand.id, name: brand.name, domain: brand.domain },
          })),
        },
        update: {
          computedAt: new Date(),
          payload: JSON.parse(JSON.stringify({
            ga4: { ...ga4, lastSynced: ga4Integration?.lastSynced?.toISOString() ?? null },
            gsc: { ...gsc, lastSynced: gscIntegration?.lastSynced?.toISOString() ?? null },
            audit,
            brand: { id: brand.id, name: brand.name, domain: brand.domain },
          })),
        },
      }).catch(() => null);
    }

    return NextResponse.json(
      {
        brand: { id: brand.id, name: brand.name, domain: brand.domain },
        range,
        ga4: { ...ga4, lastSynced: ga4Integration?.lastSynced?.toISOString() ?? null },
        gsc: { ...gsc, lastSynced: gscIntegration?.lastSynced?.toISOString() ?? null },
        audit,
        deadPages: { count: deadPageCount },
        performance: perfScore !== null ? { score: perfScore, cwv } : null,
        execution: { overdue: overdueTasks, completedThisWeek },
        integrations: { connected: connectedCount },
        siteTag: {
          installed: !!trackedSiteExists,
          installedAt: installedAtDate?.toISOString() ?? null,
          ageDays: siteTagAge,
        },
        // Authoritative source for the AI Visibility / Competitors
        // dashboard cards. They previously read from /api/source-status
        // via the brand-context — when that endpoint was slow or its
        // fetch failed, the cards rendered "Set up tracking" even on
        // brands with active queries / competitors. The dashboard now
        // ships these counts directly and the cards prefer this data
        // with the source-status entry as a fallback.
        aiVisibility: {
          tracked: aiVisQueriesCount,
          lastChecked: aiVisLastCheck?.checkedAt?.toISOString() ?? null,
        },
        competitors: { tracked: competitorCount },
        // Cross-source counts so the Technical Issues / Recommendations
        // / Execution Hub cards can render usefully even on brands
        // that haven't run a technical audit yet. Frontend prefers
        // audit-derived numbers when present, falls back to these.
        signals: { count: signalsCount },
        tasks: { total: totalTaskCount, overdue: overdueTasks, completedThisWeek },
        // Paid media summary — fetched inline from the shared service.
        // Returns null when the brand has no attribution paths yet so
        // the dashboard skips the section cleanly.
        paidMedia: await (async () => {
          try {
            const { getChannelSummary } = await import("@/lib/services/paid-media-metrics");
            const pmSince = new Date(Date.now() - 30 * 86_400_000);
            const ch = await getChannelSummary(brandId, pmSince);
            if (ch.totalConversions === 0 && ch.paid.sessions === 0 && ch.organic.sessions === 0) return null;
            return {
              paidSessions: ch.paid.sessions,
              organicSessions: ch.organic.sessions,
              paidConversions: ch.paid.conversions,
              organicConversions: ch.organic.conversions,
              assistedConversions: ch.assisted.total,
              channelBackfillTriggered: ch.channelBackfillTriggered,
            };
          } catch { return null; }
        })(),
      },
      {
        headers: {
          // Per-user cache: serves the same response for 60s without
          // re-fetching, which makes back/forward navigation and quick
          // refreshes feel instant. `private` keeps it out of any
          // shared CDN cache (the data is brand-scoped + auth-gated).
          // `stale-while-revalidate` lets the browser show the cached
          // value immediately while a fresh fetch runs in the
          // background — masks the next slow request behind a paint
          // of yesterday's numbers rather than a spinner.
          "Cache-Control": "private, max-age=60, stale-while-revalidate=300",
        },
      },
    );
  } catch (err) {
    const msg = err instanceof Error ? err.message : "Unknown";
    log.error("api", `Dashboard error: ${msg}`, { path: "/api/dashboard" });
    console.error("[dashboard] top-level error", err);
    return NextResponse.json({ error: msg }, { status: 500 });
  }
}
