/**
 * meSEO Site Tag — Analytics Service
 * ------------------------------------
 * Summarizes tracked event data for use across the platform:
 *   - Conversion Intelligence: page views, conversions, channels
 *   - Performance Since Launch: traffic to launched pages
 *   - Signals Center: anomaly detection from event patterns
 *   - AI Avatar: real tracked data for context
 *
 * All functions take a brandId and return structured summaries.
 * These are the BRIDGE between raw tracked events and the rest
 * of the meSEO platform.
 */

import { prisma } from "@/lib/prisma";
import { canonicalConversionType, CONVERSION_TYPES } from "@/lib/conversion-taxonomy";
import { countedConversionWhere } from "@/lib/conversions/counted-where";
import {
  getPageViewCount,
  getPhoneMetrics,
} from "./platform-metrics";
import { getCached, setCached } from "@/lib/cache/redis-cache";

// ─── Types ───────────────────────────────────────────────────────────────────

export interface TrafficSummary {
  /** Total page views in the period */
  pageViews: number;
  /** Total unique sessions */
  sessions: number;
  /** Page views per session (engagement metric) */
  pagesPerSession: number;
  /** Top pages by view count */
  topPages: Array<{ url: string; views: number }>;
  /** Traffic by referrer source */
  byReferrer: Array<{ source: string; views: number }>;
  /** Traffic by UTM source */
  byUtmSource: Array<{ source: string; views: number }>;
}

export interface ConversionSummary {
  /** Total UNIQUE conversions after dedup.
   *  Unique = distinct (sessionId, pageUrl, canonicalType) tuple.
   *  A user clicking the same phone number 9 times on the homepage
   *  in one session = 1 phone_call conversion (not 9). The raw-row
   *  count is in `totalInteractions` below. */
  totalConversions: number;
  /** Raw row count across the same window — sums every interaction
   *  without dedup. Shown alongside totalConversions as context
   *  ("59 conversions (87 interactions)") so operators can see when
   *  users are hammering a button (UX / operational signal). */
  totalInteractions: number;
  /** Breakdown by type (canonical key — phone_call, form_submitted,
   *  appointment_booked, etc.). Each value is UNIQUE count (deduped). */
  byType: Record<string, number>;
  /** Breakdown by type, RAW interaction count (pre-dedup). Pair with
   *  byType to surface "5 phone calls (12 interactions)" on the UI. */
  byTypeInteractions: Record<string, number>;
  /** Conversion rate: UNIQUE conversions / sessions */
  conversionRate: number;
  /** Top converting pages */
  topConvertingPages: Array<{ url: string; conversions: number }>;
  /** Conversions by UTM source (attribution) */
  byUtmSource: Array<{ source: string; conversions: number }>;
}

export interface PagePerformance {
  url: string;
  pageViews: number;
  sessions: number;
  conversions: number;
  conversionRate: number;
  topReferrer: string | null;
}

export interface EventTrend {
  hour: number;
  count: number;
}

export interface DailyPoint {
  date: string; // YYYY-MM-DD for daily, YYYY-MM-DDTHH for hourly
  pageViews: number;
  sessions: number;
}

export interface RecentEvent {
  id: string;
  eventType: string;
  pageUrl: string;
  timestamp: string;
}

export interface OutboundClickDestination {
  /** Target hostname (e.g. "maps.google.com") */
  domain: string;
  /** Full target URL, truncated for display */
  url: string;
  /** First observed link text for this URL */
  text: string;
  /** Number of clicks to this URL in the period */
  count: number;
  /** The brand page (path only) the user clicked FROM most often
   *  when going to this destination. Populated from
   *  SiteEvent.pageUrl at aggregation time; shown as the "Source
   *  Page" column on the Top Outbound Destinations table so users
   *  see which of their pages drive the outbound traffic. */
  sourcePage?: string | null;
}

export interface OutboundClickSummary {
  /** Total outbound clicks in the period */
  totalClicks: number;
  /** Top destinations, sorted by count descending */
  topDestinations: OutboundClickDestination[];
}

export interface DetectedPlugin {
  /** Human-readable name from t.js (e.g. "Amelia", "Calendly"). */
  name: string;
  /** Stable lookup key (e.g. "amelia"). */
  key: string;
  /** When this plugin was first observed on the brand's site. */
  firstSeenAt: string;
  /** When this plugin was most recently observed (within the analytics
   *  range). Stale plugins (not seen recently) drop off the dashboard. */
  lastSeenAt: string;
  /** Tracking capability the plugin offers — drives the badge on the
   *  setup page ("✓ Tracking submissions" vs "⚠ Limited (iframe-based)"). */
  trackingLevel: "full" | "limited";
  /** Plain-language tracking caveat shown on the setup page. */
  trackingNote: string;
}

export interface FormBookingFunnel {
  /** Total appointment_intent + form_intent events in the range. */
  intent: number;
  /** Total form_start events. */
  started: number;
  /** Total form_submit events (native + plugin AJAX combined). */
  submitted: number;
  /** Total booking_confirmed events (hard conversion). */
  confirmed: number;
  /** Drop-off rate between intent → started, expressed as a 0-100
   *  percentage. null when the upstream stage has zero events. */
  dropoffStarted: number | null;
  /** Drop-off rate between started → submitted (form abandon rate). */
  dropoffSubmitted: number | null;
  /** Drop-off rate between submitted → confirmed (booking finish rate). */
  dropoffConfirmed: number | null;
  // ── Conversion-funnel rollup (revised 2026-04 to align with the
  // platform conversion taxonomy in src/lib/conversion-taxonomy.ts).
  // The three stages mirror Tier 0 (Engagement) → Tier 2 (Soft
  // Conversions) → Tier 1 (Hard Conversions) so every cell in the
  // Conversion Breakdown row maps to exactly one funnel stage and
  // the per-stage totals equal the sum of their channel cells.
  //
  // Legacy field names kept intact for wire-compat:
  //   engagement            = Top (intent signals)
  //   actions               = Soft Conversions (mid)
  //   conversions           = Hard Conversions (bottom)
  //   dropoffToActions      = engagement → soft
  //   dropoffToConversions  = soft → hard
  /** ENGAGEMENT (top) — appointment_intent + form_intent +
   *  booking_widget_opened + booking_step + form_start +
   *  email_click + product_view. Shows intent but no conversion
   *  action yet. */
  engagement?: number;
  /** SOFT CONVERSIONS (mid) — add_to_cart + checkout_started +
   *  file_download + newsletter_signup + waitlist_signup +
   *  booking_submit. Meaningful action, not a final conversion. */
  actions?: number;
  /** HARD CONVERSIONS (bottom) — form_submit + click_to_call +
   *  booking_confirmed + purchase_completed + purchase +
   *  sms_click + sms_submit. Definitive conversion events. */
  conversions?: number;
  /** Drop-off rate between engagement → soft conversions. */
  dropoffToActions?: number | null;
  /** Drop-off rate between soft → hard conversions. */
  dropoffToConversions?: number | null;
  /** Per-channel breakdown — every event type that feeds into the
   *  funnel has its own count so the stage totals reconcile
   *  exactly with the breakdown sums. `tier` tags each cell with
   *  its funnel stage (engagement | soft | hard) so the UI can
   *  group them under the correct header. Raw event volume from
   *  TrackedEvent — the SiteConversion dedup count (shown in the
   *  Conversions KPI card) is a separate metric. */
  channels?: {
    // Engagement tier — TrackedEvent intent signals that do NOT
    // write a SiteConversion side-effect. Keeping them TrackedEvent-
    // backed avoids double-counting against the Hard/Soft tiers,
    // which read from SiteConversion.
    appointmentIntents: number;
    bookingOpens: number;
    bookingSteps: number;
    formStarts: number;
    productViews: number;
    // Soft conversions tier — SiteConversion deduped counts.
    addToCarts: number;
    checkoutStarteds: number;
    fileDownloads: number;
    newsletterSignups: number;
    waitlistSignups: number;
    bookingSubmits: number;
    // Hard conversions tier — SiteConversion deduped counts.
    // email_click sits here (not in Engagement) because a mailto:
    // click writes a SiteConversion row with type email_contact
    // (Tier 1 per the platform taxonomy). Surfacing it as
    // Engagement as well would double-count against the KPI.
    formSubmits: number;
    phoneCalls: number;
    bookingConfirmed: number;
    purchases: number;
    smsClicks: number;
    emailClicks: number;
    // Per-type count of conversions created by form-phone.js
    // intelligence detection (detectionMethod = 'tel_link' or
    // 'phone_pattern'). These have NO matching TrackedEvent so they
    // inflate the SiteConversion count beyond the raw event count.
    // The UI needs this to compute accurate per-type duplicate counts:
    //   dupes = rawEventCount - (dedupedCount - aiDetectedCount)
    aiDetected?: {
      formSubmits: number;
      phoneCalls: number;
      bookingConfirmed: number;
      purchases: number;
      smsClicks: number;
      emailClicks: number;
      bookingSubmits: number;
    };
  };
}

export interface ConversionsBySource {
  /** form_submit events split by metadata.source — "native", "gtag_intercept",
   *  or one of the plugin keys (jotform, hubspot, gravityforms, etc.).
   *  Lets the dashboard caption the headline like "Form Submits: 85
   *  (74 via gtag, 11 via native)" so brands can see which detection
   *  paths their conversions are flowing through. */
  formSubmits: Record<string, number>;
  /** booking_submit + booking_confirmed split by source. Same shape. */
  bookings: Record<string, number>;
  /** SiteConversion rows grouped by formProvider — counts every recorded
   *  conversion that carried a provider key (hubspot, calendly, jotform,
   *  etc.) so the dashboard can show "X conversions via Calendly". */
  byProvider: Record<string, number>;
}

export interface SiteTagAnalytics {
  traffic: TrafficSummary;
  conversions: ConversionSummary;
  trends: EventTrend[];
  dailyPageViews: DailyPoint[];
  recentEvents: RecentEvent[];
  eventsToday: number;
  eventsThisWeek: number;
  activePagesCount: number;
  outboundClicks: OutboundClickSummary;
  /** Today's events grouped by eventType for the breakdown display. */
  todayByType: Array<{ type: string; count: number }>;
  /** Booking / form plugins detected on the site via plugin_detected
   *  events (Amelia, Calendly, NexHealth, etc.). Powers the
   *  "Detected Integrations" section on the setup page and the badge
   *  near the Site Tag Active indicator. */
  pluginsDetected: DetectedPlugin[];
  /** Intent → Started → Submitted → Confirmed funnel for the active
   *  range. Powers the "Form & Booking Funnel" card. */
  formFunnel: FormBookingFunnel;
  /** form_submit + booking_* counts grouped by metadata.source — lets
   *  the dashboard caption "X via gtag, Y via native" so brands can
   *  see which detection path their conversions are flowing through. */
  conversionsBySource: ConversionsBySource;
  period: { from: Date; to: Date };
}

// ─── Plugin tracking-capability map ──────────────────────────────────
//
// "full" = our tag can see every step of the user's interaction
//          (typically WordPress-rendered DOM elements: WPForms,
//          Gravity Forms, Contact Form 7, Amelia).
// "limited" = the plugin lives in a cross-origin iframe, so we can
//          only catch the postMessage-based open / submit / confirm
//          signals it chooses to expose. We never see field
//          interactions or partial completions.
//
// The trackingNote text is shown on the setup page so the brand
// understands WHY a particular plugin's tracking is limited.

const PLUGIN_TRACKING_CAPABILITIES: Record<string, { name: string; trackingLevel: "full" | "limited"; trackingNote: string }> = {
  amelia: {
    name: "Amelia",
    trackingLevel: "full",
    trackingNote: "Tracks every booking step, AJAX submission, and confirmation panel.",
  },
  calendly: {
    name: "Calendly",
    trackingLevel: "limited",
    trackingNote: "Iframe-based — we receive open / datetime-selected / scheduled signals via postMessage. Field interactions are not visible.",
  },
  nexhealth: {
    name: "NexHealth",
    trackingLevel: "limited",
    trackingNote: "Iframe-based — we capture widget opens and confirmation messages when the embed exposes them.",
  },
  acuity: {
    name: "Acuity Scheduling",
    trackingLevel: "limited",
    trackingNote: "Iframe-based — we capture the scheduled signal via postMessage.",
  },
  jotform: {
    name: "JotForm",
    trackingLevel: "limited",
    trackingNote: "Iframe-based — we receive the submission-completed event via postMessage.",
  },
  typeform: {
    name: "Typeform",
    trackingLevel: "limited",
    trackingNote: "Iframe-based — we receive the form-submit event via postMessage.",
  },
  wpforms: {
    name: "WPForms",
    trackingLevel: "full",
    trackingNote: "Tracks both native form submits and AJAX-mode submissions.",
  },
  gravityforms: {
    name: "Gravity Forms",
    trackingLevel: "full",
    trackingNote: "Tracks the gform_confirmation_loaded DOM event and AJAX submissions.",
  },
  contactform7: {
    name: "Contact Form 7",
    trackingLevel: "full",
    trackingNote: "Tracks the wpcf7mailsent confirmation event.",
  },
  hubspot: {
    name: "HubSpot Forms",
    trackingLevel: "limited",
    trackingNote: "Iframe-based — we receive the onFormSubmitted event via postMessage.",
  },
};

// ─── Helpers ─────────────────────────────────────────────────────────────────

/** Get the TrackedSite ID for a brand, or null if no record exists.
 * Drops the status filter so sites with legacy/whitespace status values
 * still return data — matches the /api/site-tag management endpoint. */
async function getSiteId(brandId: string): Promise<string | null> {
  const site = await prisma.trackedSite.findUnique({
    where: { brandId },
    select: { id: true },
  });
  return site?.id ?? null;
}

/** Get the active TrackedSite for a brand, verified against the brand's domain */
async function getVerifiedSite(brandId: string): Promise<{ id: string; allowedDomains: string[] } | null> {
  const brand = await prisma.brand.findUnique({
    where: { id: brandId },
    select: { domain: true },
  });
  if (!brand?.domain) return null;

  const site = await prisma.trackedSite.findFirst({
    where: { brandId, status: "active" },
    select: { id: true, allowedDomains: true },
  });
  if (!site) return null;

  // Verify the brand's domain is in the site's allowed domains
  const normalise = (d: string) => d.replace(/^www\./, "").toLowerCase();
  const brandDomain = normalise(brand.domain);
  const domainMatch = site.allowedDomains.some((d) => normalise(d) === brandDomain);
  if (!domainMatch) return null;

  return site;
}

/** Compute the start date for a given range, anchored to UTC midnight.
 *  DB timestamps are stored in UTC; using local-timezone setHours() shifts
 *  the cutoff by the server's UTC offset and causes range queries to
 *  misalign with GA4 (which also uses UTC). */
function rangeStart(range: string): Date {
  const d = new Date();
  if (range === "1d") d.setUTCHours(0, 0, 0, 0);
  else if (range === "7d") d.setUTCDate(d.getUTCDate() - 7);
  else if (range === "28d") d.setUTCDate(d.getUTCDate() - 28);
  else if (range === "30d") d.setUTCDate(d.getUTCDate() - 30);
  else if (range === "90d") d.setUTCDate(d.getUTCDate() - 90);
  else {
    const match = /^(\d+)d$/.exec(range);
    d.setUTCDate(d.getUTCDate() - (match ? parseInt(match[1], 10) : 90));
  }
  return d;
}

const CONVERSION_EVENTS = [
  "form_submit",
  "click_to_call",
  "sms_submit",
  "booking_confirmed",
  "booking_submit",
];

/**
 * Conversion types tracked by form-phone.js → /api/site-tag/conversion →
 * siteConversion table AND by t.js → /api/collect → siteConversion (via
 * the side-effect writer in src/lib/services/site-tag.ts:
 * writeConversionSideEffect). This is the PRIMARY conversion source
 * because the union of the two paths detects far more than t.js alone
 * (form-phone XHR/fetch intercepts, postMessage for HubSpot/Typeform/
 * Calendly, MutationObserver fallback) PLUS the booking-plugin registry.
 *
 * form_filled is registered (not excluded) as a Signal/intent-tier type.
 * form-phone.js emits BOTH form_submit AND form_filled per submit, but
 * form_filled maps to tier "intent" (isCounted: false) so it does NOT
 * contribute to the counted conversion total or conversion rate -- only
 * to the Signal-tier intent breakdown row.
 *
 * form_submission is the canonical type written by t.js's collect-side
 * effect (CONVERSION_EVENT_MAP in site-tag.ts maps form_submit →
 * form_submission). Both spellings are aggregated here under the same
 * canonical "form_submit" bucket.
 */
// Every SiteConversion type name the platform recognises — the
// union of Tier 1 + Tier 2 canonical names in conversion-taxonomy.ts
// plus the pre-resolver legacy names still sitting in the table.
// The funnel below splits this set by tier (CONVERSION_TYPES[canon].tier)
// so Hard / Soft counts reconcile exactly with the SiteConversion KPI.
//
// Previous behaviour filtered Tier 2 out (a POSTed-but-not-confirmed
// booking was shown as a funnel signal, never a conversion). That
// left the Tier-2 rows orphaned — they existed in SiteConversion but
// weren't surfaced anywhere — which is why the Conversion Funnel's
// raw-event counts diverged from the KPI card's deduped count. Now
// every tier appears in byType; the UI groups them by tier.
const SITE_CONVERSION_TYPES = [
  // ── Tier 1 canonical names ──
  "appointment_booked",
  "form_submitted",
  "phone_call",
  "email_contact",
  "sms_contact",
  "purchase",
  // ── Tier 2 canonical names ──
  "appointment_attempted",
  "form_started",
  "appointment_intent",
  "chat_initiated",
  "newsletter_signup",
  "add_to_cart",
  "checkout_started",
  "file_download",
  "waitlist_signup",
  // ── Form intent signals (t.js v2.x / form-phone.js) ──
  // form_filled: user filled form fields — intent signal, NOT counted toward
  // the conversion total (tier: "intent", isCounted: false) so it does NOT
  // double-count with form_submit from the same submission. Registered here
  // so drift warnings are suppressed and the Signal-tier breakdown shows it.
  "form_filled",
  // ── Legacy storage names — pre-resolver rows still in the table ──
  "form_submit",          // form-phone.js path
  "form_submission",      // t.js → /api/collect → side-effect writer (legacy)
  "phone_number_click",   // form-phone.js text-pattern match
  "click_to_call",        // legacy phone storage type (older /api/site-tag/conversion posts)
  "calendly",
  "booking_confirmed",    // booking_confirmed (legacy storage type)
  "booking_attempt",      // legacy tier-2 storage type
  "email_click",          // legacy email_contact storage type
  "sms_click",            // legacy sms_contact storage type
  // ── Phone intent signals (t.js v2.x) ──
  "phone_copy",           // user copied a phone number — intent signal, maps to click_to_call
  "phone_input",          // user focused/typed in a phone input — intent signal, maps to click_to_call
  // ── Structural page-state signals (t.js v2.x) ──
  // form_iframe_present is detection, not a user action. Included here so the
  // drift check does not flag it, but skipped in the conversion rollup loop
  // via NON_CONVERSION_STRUCTURAL_TYPES below.
  "form_iframe_present",
  // error_count_update is a diagnostic flush from t.js (not a user conversion).
  // Registered here to suppress drift warnings; skipped in all rollup loops.
  "error_count_update",
];

/** Map siteConversion.conversionType to the UI-canonical key used in
 *  byType. The byType bucket on the dashboard renders pills like
 *  "Form Submit", "Click to Call", "Booking Confirmed" — the keys
 *  below MATCH the existing UI labels rather than the new canonical
 *  taxonomy IDs (appointment_booked, form_submitted) so the rendering
 *  layer stays unchanged. canonicalConversionType in
 *  conversion-taxonomy.ts handles the deeper tier classification
 *  separately. */
// Defined in counted-where.ts; re-exported here so existing callers keep working.
import { EXCLUDED_CONVERSION_TYPES } from "@/lib/conversions/counted-where";
export { EXCLUDED_CONVERSION_TYPES };
const NON_CONVERSION_STRUCTURAL_TYPES = new Set<string>(EXCLUDED_CONVERSION_TYPES);

export function canonicalType(raw: string): string {
  // Tier-1 canonical names from the new taxonomy → UI-canonical keys.
  if (raw === "appointment_booked")    return "booking_confirmed";
  if (raw === "form_submitted")        return "form_submit";
  if (raw === "phone_call")            return "click_to_call";     // form-phone.js path
  if (raw === "email_contact")         return "email_click";
  if (raw === "sms_contact")           return "sms_click";
  if (raw === "sms_submit")            return "sms_click";         // legacy tracking-only variant
  // Tier-2 canonical names.
  if (raw === "appointment_attempted") return "booking_submit";
  // Legacy aliases.
  if (raw === "form_submission")       return "form_submit";       // t.js side-effect (pre-resolver)
  if (raw === "booking_attempt")       return "booking_submit";    // legacy tier-2 storage
  if (raw === "phone_number_click")    return "click_to_call";     // form-phone.js text-pattern
  // Phone intent signals (t.js v2.x) — aggregate with phone call conversions.
  if (raw === "phone_copy")            return "click_to_call";
  if (raw === "phone_input")           return "click_to_call";
  return raw;
}

// ─── User-friendly event-type labels ─────────────────────────────────
//
// Re-exported from the client-safe module so server-side callers that
// already import from this file keep working. The canonical definition
// lives in src/lib/event-type-labels.ts (no Prisma import) so "use
// client" components can use it without pulling the Prisma client into
// the browser bundle.
export { EVENT_TYPE_LABELS, eventTypeLabel } from "@/lib/event-type-labels";

// Legacy block kept as a comment for grep-ability — the actual map is
// now in src/lib/event-type-labels.ts. If you add a new event type,
// add it THERE, not here.
//
// Previously this was an inline Record<string, string> that lived in
// this file. Importing it from a "use client" component (overview-tab)
// pulled site-tag-analytics.ts → prisma → process.env.DATABASE_URL
// into the browser bundle, producing the "[env] Missing required
// variable: DATABASE_URL" console error on every page load.

// ─── Traffic Summary ─────────────────────────────────────────────────────────

async function _getTrafficSummaryUncached(
  brandId: string,
  range: "1d" | "7d" | "28d" | "30d" | "90d" = "30d"
): Promise<TrafficSummary | null> {
  const siteId = await getSiteId(brandId);
  if (!siteId) return null;

  // Fast path: for 30d, 28d, and 7d windows use the pre-aggregated rollup table
  // (SiteTagDailyRollup written hourly by the site-tag-rollup cron) which
  // turns a 1M+ row groupBy into a 7-30 row indexed lookup.
  // Falls back to the live TrackedEvent path when rollup data is absent or
  // insufficient (new brand, rollup cron hasn't run yet, etc.)
  if (range === "30d" || range === "28d" || range === "7d") {
    try {
      const expectedDays = range === "7d" ? 7 : range === "28d" ? 28 : 30;
      const rollupFrom = new Date(Date.now() - expectedDays * 86_400_000);
      const rollupRows = await prisma.siteTagDailyRollup.findMany({
        where: { brandId, date: { gte: rollupFrom } },
        orderBy: { date: "asc" },
      });

      if (rollupRows.length >= expectedDays - 1) {
        const pageViews = rollupRows.reduce((s, r) => s + r.pageViews, 0);
        const sessions = rollupRows.reduce((s, r) => s + r.sessions, 0) || 1;

        // Merge topPages across all days
        const pagePathCounts = new Map<string, number>();
        for (const row of rollupRows) {
          for (const p of row.topPages as Array<{ url: string; views: number }>) {
            pagePathCounts.set(p.url, (pagePathCounts.get(p.url) ?? 0) + p.views);
          }
        }
        const topPages = Array.from(pagePathCounts.entries())
          .sort(([, a], [, b]) => b - a)
          .slice(0, 10)
          .map(([url, views]) => ({ url, views }));

        const refDomainCounts = new Map<string, number>();
        for (const row of rollupRows) {
          for (const r of row.byReferrer as Array<{ source: string; views: number }>) {
            refDomainCounts.set(r.source, (refDomainCounts.get(r.source) ?? 0) + r.views);
          }
        }
        const byReferrer = Array.from(refDomainCounts.entries())
          .sort(([, a], [, b]) => b - a)
          .slice(0, 10)
          .map(([source, views]) => ({ source, views }));

        return {
          pageViews,
          sessions,
          pagesPerSession: Math.round((pageViews / sessions) * 10) / 10,
          topPages,
          byReferrer,
          byUtmSource: [],
        };
      }

      // Rollup insufficient -- fall through to live query.
      console.warn(`[traffic-summary] rollup insufficient for brand ${brandId}, range ${range}, rows=${rollupRows.length} expected>=${expectedDays - 1}, falling back to live`);
    } catch (err) {
      console.warn(`[traffic-summary] rollup query failed for brand ${brandId}, range ${range}, falling back to live:`, err instanceof Error ? err.message : err);
    }
  }

  const from = rangeStart(range);
  const t0 = Date.now();

  // Headline page-view + session counts via shared helpers so these
  // numbers match every other surface (Overview KPI cards, Funnel
  // tab, Conversions tab, UX tab, admin dashboard, AI Strategist).
  // The three groupBy queries below stay inline — they return
  // top-N breakdowns the shared layer doesn't encapsulate.
  const [pageViewCount, sessionCount, topPagesGroup, topReferrersGroup, topUtmGroup] = await Promise.all([
    getPageViewCount(brandId, from, { trackedSiteId: siteId }),
    // Use TrackedEvent session_start count (not TrackedSession.count)
    // so the number matches the "Session Start" row in Event Types
    // Firing and the AI Attribution "Sessions Scanned" counter. The
    // previous TrackedSession-based count diverged by a few percent
    // because TrackedSession rows can exist without a matching
    // session_start event (and vice versa).
    prisma.trackedEvent.count({
      where: { trackedSiteId: siteId, eventType: "session_start", timestamp: { gte: from } },
    }).catch(() => 0),
    prisma.trackedEvent.groupBy({
      by: ["pageUrl"],
      where: { trackedSiteId: siteId, eventType: "page_view", timestamp: { gte: from } },
      _count: { pageUrl: true },
      orderBy: { _count: { pageUrl: "desc" } },
      take: 50,
    }),
    prisma.trackedEvent.groupBy({
      by: ["referrer"],
      where: { trackedSiteId: siteId, eventType: "page_view", timestamp: { gte: from } },
      _count: { referrer: true },
      orderBy: { _count: { referrer: "desc" } },
      take: 25,
    }),
    prisma.trackedSession.groupBy({
      by: ["utmSource"],
      where: { trackedSiteId: siteId, startedAt: { gte: from }, utmSource: { not: null } },
      _count: { utmSource: true },
      orderBy: { _count: { utmSource: "desc" } },
      take: 10,
    }),
  ]);
  console.info(`[site-tag-analytics:perf] getTrafficSummary aggregations ${Date.now() - t0}ms`, { siteId, range });

  const pageViews = pageViewCount;
  const sessions = sessionCount || 1; // avoid divide-by-zero

  // Top pages — collapse by extracted path so /a?utm=x and /a count
  // together. The DB groupBy is by raw pageUrl which keeps query
  // strings separate, so we re-aggregate in memory across a bounded
  // 50-row top-N rather than the full table.
  const pagePathCounts = new Map<string, number>();
  for (const row of topPagesGroup) {
    const path = extractPath(row.pageUrl);
    pagePathCounts.set(path, (pagePathCounts.get(path) ?? 0) + row._count.pageUrl);
  }
  const topPages = Array.from(pagePathCounts.entries())
    .sort(([, a], [, b]) => b - a)
    .slice(0, 10)
    .map(([url, views]) => ({ url, views }));

  // By referrer — collapse to domain.
  const refDomainCounts = new Map<string, number>();
  for (const row of topReferrersGroup) {
    const source = row.referrer ? extractDomain(row.referrer) : "(direct)";
    refDomainCounts.set(source, (refDomainCounts.get(source) ?? 0) + row._count.referrer);
  }
  const byReferrer = Array.from(refDomainCounts.entries())
    .sort(([, a], [, b]) => b - a)
    .slice(0, 10)
    .map(([source, views]) => ({ source, views }));

  // By UTM source — already aggregated by Postgres.
  const byUtmSource = topUtmGroup
    .map((g) => ({ source: g.utmSource ?? "(none)", views: g._count.utmSource }));

  return {
    pageViews,
    sessions,
    pagesPerSession: Math.round((pageViews / sessions) * 10) / 10,
    topPages,
    byReferrer,
    byUtmSource,
  };
}

export async function getTrafficSummary(
  brandId: string,
  range: "1d" | "7d" | "28d" | "30d" | "90d" = "30d",
): Promise<TrafficSummary | null> {
  const key = `traffic-summary:${brandId}:${range}`;
  const cached = await getCached<TrafficSummary>(key);
  if (cached) return cached;
  const fresh = await _getTrafficSummaryUncached(brandId, range);
  if (fresh) await setCached(key, fresh, 300);
  return fresh;
}

// ─── Conversion Summary ──────────────────────────────────────────────────────

async function _getConversionSummaryUncached(
  brandId: string,
  range: "1d" | "7d" | "28d" | "30d" | "90d" = "30d"
): Promise<ConversionSummary | null> {
  const from = rangeStart(range);
  const to = new Date();

  // Per-brand gate: when requireSuccessConfirmed=true, only rows with
  // successConfirmed=true count. Default false preserves existing behaviour.
  const siteConfig = await prisma.trackedSite.findFirst({
    where: { brandId },
    select: { requireSuccessConfirmed: true },
  }).catch(() => null);
  const requireSuccessConfirmed = siteConfig?.requireSuccessConfirmed === true;
  const successFilter = requireSuccessConfirmed ? { successConfirmed: true } : {};

  // Fast path: for 30d, 28d, and 7d use the pre-aggregated rollup when available.
  // Skipped when requireSuccessConfirmed=true since rollup data does not carry
  // per-row successConfirmed — the live query handles it correctly.
  if (!requireSuccessConfirmed && (range === "30d" || range === "28d" || range === "7d")) {
    try {
      const expectedDays = range === "7d" ? 7 : range === "28d" ? 28 : 30;
      const rollupFrom = new Date(Date.now() - expectedDays * 86_400_000);
      const rollupRows = await prisma.siteTagDailyRollup.findMany({
        where: { brandId, date: { gte: rollupFrom } },
        select: { sessions: true, conversions: true, conversionsByType: true },
      });

      const fullyPopulated = rollupRows.filter((r) => r.conversionsByType !== null);
      if (fullyPopulated.length >= expectedDays - 1) {
        const byType: Record<string, number> = {};
        let sessions = 0;
        for (const row of fullyPopulated) {
          sessions += row.sessions;
          for (const [type, cnt] of Object.entries(row.conversionsByType as Record<string, number>)) {
            if (NON_CONVERSION_STRUCTURAL_TYPES.has(type)) continue;
            byType[type] = (byType[type] ?? 0) + cnt;
          }
        }
        const totalConversions = Object.values(byType).reduce((a, b) => a + b, 0);
        const safeSessionCount = sessions || 1;

        // topConvertingPages and byUtmSource still require live rows — bounded
        // to the 200 most recent conversions to keep the query fast.
        const recentConvs = await prisma.siteConversion.findMany({
          where: {
            brandId,
            timestamp: { gte: from, lte: to },
            isDuplicateOf: null,
            invalidatedReason: null,
            conversionType: { notIn: [...EXCLUDED_CONVERSION_TYPES] },
            ...successFilter,
          },
          select: { conversionType: true, pageUrl: true, firstTouchSource: true },
          orderBy: { timestamp: "desc" },
          take: 200,
        }).catch(() => []);

        const pageCounts: Record<string, number> = {};
        const utmCounts: Record<string, number> = {};
        for (const e of recentConvs) {
          if (NON_CONVERSION_STRUCTURAL_TYPES.has(e.conversionType)) continue;
          const path = extractPath(e.pageUrl);
          pageCounts[path] = (pageCounts[path] ?? 0) + 1;
          const src = e.firstTouchSource ?? "(organic/direct)";
          utmCounts[src] = (utmCounts[src] ?? 0) + 1;
        }
        const topConvertingPages = Object.entries(pageCounts)
          .sort(([, a], [, b]) => b - a).slice(0, 10)
          .map(([url, conversions]) => ({ url, conversions }));
        const byUtmSource = Object.entries(utmCounts)
          .sort(([, a], [, b]) => b - a).slice(0, 10)
          .map(([source, conversions]) => ({ source, conversions }));

        return {
          totalConversions,
          totalInteractions: totalConversions,
          byType,
          byTypeInteractions: { ...byType },
          conversionRate: Math.round((totalConversions / safeSessionCount) * 1000) / 10,
          topConvertingPages,
          byUtmSource,
        };
      }

      console.warn(`[conversion-summary] rollup insufficient for brand ${brandId}, range ${range}, populated=${fullyPopulated.length} expected>=${expectedDays - 1}, falling back to live`);
    } catch (err) {
      console.warn(`[conversion-summary] rollup query failed for brand ${brandId}, range ${range}, falling back to live:`, err instanceof Error ? err.message : err);
    }
  }

  const [siteConvEvents, unfilteredConvCount, siteId] = await Promise.all([
    prisma.siteConversion.findMany({
      where: {
        brandId,
        conversionType: { in: SITE_CONVERSION_TYPES },
        timestamp: { gte: from, lte: to },
        isDuplicateOf: null,
        invalidatedReason: null,
        ...successFilter,
      },
      select: { conversionType: true, pageUrl: true, sessionId: true, firstTouchSource: true },
    }),
    prisma.siteConversion.count({
      where: { brandId, timestamp: { gte: from, lte: to }, isDuplicateOf: null, invalidatedReason: null, conversionType: { notIn: [...EXCLUDED_CONVERSION_TYPES] }, ...successFilter },
    }).catch(() => -1),
    getSiteId(brandId),
  ]);

  if (unfilteredConvCount > 0 && unfilteredConvCount > siteConvEvents.length) {
    try {
      const missingTypes = await prisma.siteConversion.groupBy({
        by: ["conversionType"],
        where: {
          brandId,
          timestamp: { gte: from, lte: to },
          conversionType: { notIn: SITE_CONVERSION_TYPES },
        },
        _count: true,
      });
      console.warn(
        "[site-tag-analytics:drift] Unrecognised SiteConversion types dropped from rollup",
        JSON.stringify({
          brandId,
          range,
          unfilteredCount: unfilteredConvCount,
          filteredCount: siteConvEvents.length,
          dropped: unfilteredConvCount - siteConvEvents.length,
          missingTypes: Object.fromEntries(missingTypes.map((r) => [
            r.conversionType,
            typeof r._count === "number" ? r._count : 0,
          ])),
        }),
      );
    } catch { /* best effort */ }
  }

  // ── Secondary: sms_submit + session count — parallel now that siteId is resolved above
  let smsEvents: Array<{ eventType: string; pageUrl: string; sessionId: string | null }> = [];
  let sessionCount = 1;
  if (siteId) {
    const [smsRows, sessionTotal] = await Promise.all([
      prisma.trackedEvent.findMany({
        where: {
          trackedSiteId: siteId,
          eventType: "sms_submit",
          timestamp: { gte: from },
        },
        select: { eventType: true, pageUrl: true, sessionId: true },
      }),
      prisma.trackedEvent.count({
        where: { trackedSiteId: siteId, eventType: "session_start", timestamp: { gte: from } },
      }).catch(() => 0),
    ]);
    smsEvents = smsRows;
    sessionCount = sessionTotal || 1;
  }

  // ── Merge by canonical type — dedup per (session, page, canonical) ─
  // User hammering a phone button 9 times = ONE phone conversion, not
  // nine. We bucket every event into a (sessionId || page || canonical)
  // key so the same user action counts once regardless of how many
  // detection paths fired. Raw row count still surfaced as
  // byTypeInteractions for the UX / operational signal ("clicked 9
  // times in this session" might indicate the tel: link doesn't work
  // or the user got voicemail and retried).
  const byType: Record<string, number> = {};
  const byTypeInteractions: Record<string, number> = {};
  const seenKeys = new Set<string>();
  function recordConversion(canonical: string, sessionId: string | null, pageUrl: string) {
    byTypeInteractions[canonical] = (byTypeInteractions[canonical] ?? 0) + 1;
    const dedupKey = `${sessionId ?? "anon"}|${extractPath(pageUrl)}|${canonical}`;
    if (seenKeys.has(dedupKey)) return;
    seenKeys.add(dedupKey);
    byType[canonical] = (byType[canonical] ?? 0) + 1;
  }
  for (const e of siteConvEvents) {
    if (NON_CONVERSION_STRUCTURAL_TYPES.has(e.conversionType)) continue;
    recordConversion(canonicalType(e.conversionType), e.sessionId, e.pageUrl);
  }
  for (const e of smsEvents) {
    // sms_submit is the legacy tracking-only variant of sms_click —
    // collapse both under the same UI canonical so they reconcile
    // with the funnel's Hard-tier smsClicks cell rather than
    // bucketing into a separate sms_submit key.
    recordConversion(canonicalType("sms_submit"), e.sessionId, e.pageUrl);
  }
  const totalConversions = Object.values(byType).reduce((a, b) => a + b, 0);
  const totalInteractions = Object.values(byTypeInteractions).reduce((a, b) => a + b, 0);

  // ── Top converting pages ─────────────────────────────────────────
  const pageCounts: Record<string, number> = {};
  for (const e of siteConvEvents) {
    if (NON_CONVERSION_STRUCTURAL_TYPES.has(e.conversionType)) continue;
    const path = extractPath(e.pageUrl);
    pageCounts[path] = (pageCounts[path] ?? 0) + 1;
  }
  for (const e of smsEvents) {
    const path = extractPath(e.pageUrl);
    pageCounts[path] = (pageCounts[path] ?? 0) + 1;
  }
  const topConvertingPages = Object.entries(pageCounts)
    .sort(([, a], [, b]) => b - a)
    .slice(0, 10)
    .map(([url, conversions]) => ({ url, conversions }));

  // ── By UTM source ─────────────────────────────────────────────────
  // siteConversion rows already carry firstTouchSource (populated by the
  // attribution module on the client), so we read it directly — no join
  // needed. trackedEvent.sessionId is a FK to TrackedSession.id (a cuid),
  // while siteConversion.sessionId stores the raw client string, so they
  // can't share a single TrackedSession lookup.
  const utmCounts: Record<string, number> = {};
  for (const e of siteConvEvents) {
    const src = e.firstTouchSource ?? "(organic/direct)";
    utmCounts[src] = (utmCounts[src] ?? 0) + 1;
  }
  // smsEvents come from trackedEvent — look up TrackedSession for UTM.
  if (smsEvents.length > 0) {
    const smsSessionIds = [...new Set(smsEvents.map((e) => e.sessionId).filter(Boolean) as string[])];
    const smsSessions = smsSessionIds.length > 0
      ? await prisma.trackedSession.findMany({
          where: { id: { in: smsSessionIds } },
          select: { utmSource: true },
        })
      : [];
    for (const s of smsSessions) {
      const src = s.utmSource ?? "(organic/direct)";
      utmCounts[src] = (utmCounts[src] ?? 0) + 1;
    }
  }
  const byUtmSource = Object.entries(utmCounts)
    .sort(([, a], [, b]) => b - a)
    .slice(0, 10)
    .map(([source, conversions]) => ({ source, conversions }));

  return {
    totalConversions,
    totalInteractions,
    byType,
    byTypeInteractions,
    conversionRate: sessionCount > 0 ? Math.round((totalConversions / sessionCount) * 1000) / 10 : 0,
    topConvertingPages,
    byUtmSource,
  };
}

export async function getConversionSummary(
  brandId: string,
  range: "1d" | "7d" | "28d" | "30d" | "90d" = "30d",
): Promise<ConversionSummary | null> {
  const key = `conversion-summary:${brandId}:${range}`;
  const cached = await getCached<ConversionSummary>(key);
  if (cached) return cached;
  const fresh = await _getConversionSummaryUncached(brandId, range);
  if (fresh) await setCached(key, fresh, 300);
  return fresh;
}

// ─── Deduplicated conversion rollup helper ──────────────────────────
//
// Used by the admin dashboard + reconciliation endpoint. Same
// semantics as getConversionSummary().byType but runs a single
// Postgres aggregation instead of pulling rows + bucketing in JS.
// Returns per canonical type:
//   unique        — distinct (sessionId, pageUrl) tuples
//   interactions  — raw row count (pre-dedup)
//   sessions      — distinct sessionId count (users, not
//                   user-actions — a single user who triggers both a
//                   phone_call AND a form_submitted counts once here)
//
// Cheap — the query is O(rows-in-window) with the existing
// (brandId, timestamp) index on SiteConversion.

export interface DeduplicatedConversionRow {
  conversionType: string;
  canonicalType: string;
  unique: number;
  interactions: number;
  sessions: number;
}

export async function getDeduplicatedConversions(
  brandId: string,
  from: Date,
  to: Date = new Date(),
): Promise<DeduplicatedConversionRow[]> {
  type Row = {
    conversionType: string;
    unique_conversions: bigint;
    total_interactions: bigint;
    unique_sessions: bigint;
  };
  const rows = await prisma.$queryRaw<Row[]>`
    SELECT
      "conversionType",
      COUNT(DISTINCT "sessionId" || '|' || "pageUrl")::bigint AS unique_conversions,
      COUNT(*)::bigint                                          AS total_interactions,
      COUNT(DISTINCT "sessionId")::bigint                       AS unique_sessions
    FROM "SiteConversion"
    WHERE "brandId" = ${brandId}
      AND "timestamp" >= ${from}
      AND "timestamp" <= ${to}
    GROUP BY "conversionType"
    ORDER BY unique_conversions DESC
  `.catch(() => [] as Row[]);

  return rows.map((r) => ({
    conversionType: r.conversionType,
    canonicalType: canonicalType(r.conversionType),
    unique: Number(r.unique_conversions),
    interactions: Number(r.total_interactions),
    sessions: Number(r.unique_sessions),
  }));
}

// ─── Page Performance (for launch tracker) ───────────────────────────────────

export async function getPagePerformance(
  brandId: string,
  pageUrls: string[],
  range: string = "30d"
): Promise<PagePerformance[]> {
  const siteId = await getSiteId(brandId);
  if (!siteId || pageUrls.length === 0) return [];

  const from = rangeStart(range);

  const results: PagePerformance[] = [];

  for (const url of pageUrls) {
    // Match page views where the URL contains this path
    const views = await prisma.trackedEvent.findMany({
      where: {
        trackedSiteId: siteId,
        eventType: "page_view",
        pageUrl: { contains: url },
        timestamp: { gte: from },
      },
      select: { sessionId: true, referrer: true },
    });

    const conversions = await prisma.trackedEvent.count({
      where: {
        trackedSiteId: siteId,
        eventType: { in: CONVERSION_EVENTS },
        pageUrl: { contains: url },
        timestamp: { gte: from },
      },
    });

    const sessionIds = new Set(views.map(v => v.sessionId).filter(Boolean));
    const sessionCount = sessionIds.size || 1;

    // Top referrer for this page
    const refCounts: Record<string, number> = {};
    for (const v of views) {
      const ref = v.referrer ? extractDomain(v.referrer) : "(direct)";
      refCounts[ref] = (refCounts[ref] ?? 0) + 1;
    }
    const topRef = Object.entries(refCounts).sort(([, a], [, b]) => b - a)[0];

    results.push({
      url,
      pageViews: views.length,
      sessions: sessionCount,
      conversions,
      conversionRate: Math.round((conversions / sessionCount) * 1000) / 10,
      topReferrer: topRef ? topRef[0] : null,
    });
  }

  return results;
}

// ─── Outbound Click Summary ──────────────────────────────────────────────────

/**
 * Aggregate outbound_click events from the SiteEvent table (where the
 * Site Tag writes them via /api/site-tag/event) into a total count and a
 * ranked list of destinations. Outbound clicks are partitioned by full
 * URL so the same external link is grouped across pageviews, while the
 * target domain column lets the UI group by host when rendered.
 *
 * The top-destinations list is capped at 100 rows so the Overview tab can
 * paginate at 25 per page up to four pages deep — enough to see the long
 * tail without ballooning the response payload.
 */
export async function getOutboundClickSummary(
  brandId: string,
  range: "1d" | "7d" | "28d" | "30d" | "90d" = "30d",
): Promise<OutboundClickSummary> {
  const from = rangeStart(range);
  // Resolve the brand's own hostname so we can filter historical
  // rows that were written before the t.js www/apex fix. String
  // normalisation here mirrors the client-side normalisation in
  // public/t.js — strip leading "www." so www.acme.com and acme.com
  // compare equal.
  const brand = await prisma.brand.findUnique({
    where: { id: brandId },
    select: { domain: true },
  }).catch(() => null);
  const brandHostNorm = brand?.domain
    ? brand.domain.replace(/^https?:\/\//, "").replace(/\/.*$/, "").replace(/^www\./i, "").toLowerCase()
    : "";

  // Fast path: for 30d, 28d, and 7d sum the pre-aggregated outboundClickCount
  // from the rollup for the totalClicks headline. The topDestinations list still
  // needs live rows (rollup doesn't store per-URL breakdowns), but we cap the
  // live query at 500 most-recent events so large brands don't timeout.
  let rollupTotalClicks: number | null = null;
  if (range === "30d" || range === "28d" || range === "7d") {
    try {
      const expectedDays = range === "7d" ? 7 : range === "28d" ? 28 : 30;
      const rollupFrom = new Date(Date.now() - expectedDays * 86_400_000);
      const rollupRows = await prisma.siteTagDailyRollup.findMany({
        where: { brandId, date: { gte: rollupFrom }, conversionsByType: { not: { equals: null } } },
        select: { outboundClickCount: true },
      });
      if (rollupRows.length >= expectedDays - 1) {
        rollupTotalClicks = rollupRows.reduce((s, r) => s + r.outboundClickCount, 0);
      }
    } catch { /* fall through to live count */ }
  }

  let events: Array<{ eventData: unknown; pageUrl: string | null }> = [];
  try {
    events = await prisma.siteEvent.findMany({
      where: { brandId, eventType: "outbound_click", timestamp: { gte: from } },
      select: { eventData: true, pageUrl: true },
      orderBy: { timestamp: "desc" },
      take: 500,
    });
  } catch {
    return { totalClicks: 0, topDestinations: [] };
  }

  /** Aggregator slot — carries a source-page frequency map so at
   *  the end we can pick the single most-common origin page per
   *  destination. Keeps the per-row payload tiny while still
   *  producing a useful "Source Page" column. */
  type Slot = OutboundClickDestination & { sourcePages: Map<string, number> };
  const byUrl = new Map<string, Slot>();
  let filtered = 0;
  for (const e of events) {
    const d = (e.eventData ?? {}) as { url?: string; text?: string; targetDomain?: string; host?: string; source_page_url?: string };
    const url = typeof d.url === "string" ? d.url : "";
    if (!url) continue;
    // `host` was the legacy field name before targetDomain; accept both so
    // historical rows aggregate correctly after the tag upgrade.
    const domain = (typeof d.targetDomain === "string" && d.targetDomain) ||
      (typeof d.host === "string" && d.host) ||
      safeHostname(url);
    // Server-side internal-host filter. Catches rows written
    // before commit a9b42d8 (the t.js fix) that were tagged
    // outbound_click but actually point at the brand's own
    // domain — www-vs-apex, apex-vs-www, or any path on the
    // brand's host. Normalisation matches the client: strip
    // leading www. and lowercase. If the brand domain isn't
    // known (no Brand.domain stored) the filter is skipped
    // rather than blocking aggregation — better to show slightly
    // noisy output than empty output.
    if (brandHostNorm) {
      const destHostNorm = (domain || safeHostname(url)).replace(/^www\./i, "").toLowerCase();
      if (destHostNorm === brandHostNorm) {
        filtered++;
        continue;
      }
    }
    const text = typeof d.text === "string" ? d.text : "";
    // Source page — prefer the source_page_url stashed by t.js
    // (commit a9b42d8+) and fall back to the top-level
    // SiteEvent.pageUrl column for historical rows that predate
    // that change. Stored as a relative PATH only so the column
    // renders cleanly even for long URLs.
    const rawSource = (typeof d.source_page_url === "string" && d.source_page_url) || e.pageUrl || "";
    const sourcePath = sourcePathOnly(rawSource);

    const existing = byUrl.get(url);
    if (existing) {
      existing.count++;
      if (!existing.text && text) existing.text = text;
      if (sourcePath) {
        existing.sourcePages.set(sourcePath, (existing.sourcePages.get(sourcePath) ?? 0) + 1);
      }
    } else {
      const slot: Slot = { domain, url, text, count: 1, sourcePages: new Map() };
      if (sourcePath) slot.sourcePages.set(sourcePath, 1);
      byUrl.set(url, slot);
    }
  }

  if (filtered > 0) {
    console.info(`[site-tag-analytics:outbound] filtered ${filtered} internal-host rows (brand=${brandHostNorm})`);
  }

  // Collapse Slot → OutboundClickDestination by picking the most-
  // common source page per destination. Tied source pages resolve
  // by first-seen (Map preserves insertion order).
  const topDestinations: OutboundClickDestination[] = Array.from(byUrl.values())
    .map((slot) => {
      let best: string | null = null;
      let bestCount = 0;
      for (const [path, count] of slot.sourcePages) {
        if (count > bestCount) { best = path; bestCount = count; }
      }
      return {
        domain: slot.domain,
        url: slot.url,
        text: slot.text,
        count: slot.count,
        sourcePage: best,
      };
    })
    .sort((a, b) => b.count - a.count)
    .slice(0, 100);

  const liveTotalClicks = topDestinations.reduce((s, d) => s + d.count, 0);
  return { totalClicks: rollupTotalClicks ?? liveTotalClicks, topDestinations };
}

/** Convert a full URL into a relative path for display. Drops the
 *  scheme + host so the "Source Page" column stays narrow even
 *  when the underlying event URL is 200 characters long. Returns
 *  "/" for the homepage. Returns empty string for malformed
 *  input so the caller skips the source-pages map write. */
function sourcePathOnly(raw: string): string {
  if (!raw) return "";
  try {
    const u = new URL(raw);
    return u.pathname || "/";
  } catch {
    // Relative path stored on older SiteEvent.pageUrl rows — use
    // as-is, trimmed.
    return raw.trim();
  }
}

function safeHostname(url: string): string {
  try { return new URL(url).hostname; } catch { return ""; }
}

// ─── Event Trends (hourly for today) ─────────────────────────────────────────

export async function getEventTrends(brandId: string): Promise<EventTrend[]> {
  const siteId = await getSiteId(brandId);
  if (!siteId) return [];

  const todayStart = new Date();
  todayStart.setUTCHours(0, 0, 0, 0);

  // Always run the live query. The rollup stores per-day totals by event type,
  // not per-hour totals, so it cannot serve this chart. A rollup-based early
  // return would show a false-empty chart for up to one hour after the cron
  // writes a zero row: events that arrive after the last cron run would be
  // invisible until the next hourly write. The live query is bounded to
  // today's rows and the (trackedSiteId, timestamp) index makes it a
  // range-scan aggregate, not a full table scan.
  type HourRow = { hour: bigint; count: bigint };
  const rows = await prisma.$queryRaw<HourRow[]>`
    SELECT EXTRACT(HOUR FROM "timestamp")::bigint AS hour,
           COUNT(*)::bigint                       AS count
    FROM   "TrackedEvent"
    WHERE  "trackedSiteId" = ${siteId}
      AND  "timestamp" >= ${todayStart}
    GROUP BY EXTRACT(HOUR FROM "timestamp")
    ORDER BY hour
  `.catch(() => [] as HourRow[]);

  const currentHour = new Date().getUTCHours();
  const hourMap = new Map(rows.map((r) => [Number(r.hour), Number(r.count)]));
  return Array.from({ length: currentHour + 1 }, (_, hour) => ({
    hour,
    count: hourMap.get(hour) ?? 0,
  }));
}

// ─── Full analytics bundle (combines all summaries) ──────────────────────────

const FRESH_TTL = 60_000;
const STALE_TTL = 5 * 60_000;
type CacheEntry = { data: SiteTagAnalytics; ts: number };
const analyticsCache = new Map<string, CacheEntry>();

export async function getSiteTagAnalytics(
  brandId: string,
  range: "1d" | "7d" | "28d" | "30d" | "90d" = "30d"
): Promise<SiteTagAnalytics | null> {
  const cacheKey = `${brandId}:${range}`;
  const cached = analyticsCache.get(cacheKey);
  const now = Date.now();
  if (cached) {
    const age = now - cached.ts;
    if (age < FRESH_TTL) return cached.data;
    if (age < STALE_TTL) {
      void computeSiteTagAnalytics(brandId, range).then((fresh) => {
        if (fresh) analyticsCache.set(cacheKey, { data: fresh, ts: Date.now() });
      }).catch(() => {});
      return cached.data;
    }
  }
  const result = await computeSiteTagAnalytics(brandId, range);
  if (result) analyticsCache.set(cacheKey, { data: result, ts: Date.now() });
  return result;
}

async function computeSiteTagAnalytics(
  brandId: string,
  range: "1d" | "7d" | "28d" | "30d" | "90d" = "30d"
): Promise<SiteTagAnalytics | null> {
  const t0 = Date.now();

  // For ranges where the rollup is the fast path (7d, 28d, 30d) we also fetch
  // the rollup rows here so dailyPageViews, todayByType, eventsToday,
  // eventsThisWeek, and formFunnel engagement counts can be derived in memory
  // without additional raw TrackedEvent scans.
  const isRollupRange = range === "30d" || range === "28d" || range === "7d";
  const rollupFetchDays = range === "7d" ? 7 : range === "28d" ? 28 : 30;
  type FetchedRollupRow = { date: Date; pageViews: number; sessions: number; eventCountsByType: unknown };
  const rollupFetch: Promise<FetchedRollupRow[]> = isRollupRange
    ? prisma.siteTagDailyRollup.findMany({
        where: { brandId, date: { gte: new Date(Date.now() - rollupFetchDays * 86_400_000) } },
        select: { date: true, pageViews: true, sessions: true, eventCountsByType: true },
        orderBy: { date: "asc" },
      }).catch((): FetchedRollupRow[] => [])
    : Promise.resolve([]);

  // Run the four sub-summaries in parallel. They're independent
  // and each was awaited serially before, costing the dashboard the
  // sum of their durations instead of the max. getTrafficSummary
  // also looks up siteId internally, so we'd previously do that
  // lookup 5 times per request -- Promise.all collapses to one round
  // trip per query.
  const [traffic, conversions, trends, outboundClicks, siteId, rollupRows] = await Promise.all([
    getTrafficSummary(brandId, range),
    getConversionSummary(brandId, range),
    getEventTrends(brandId),
    getOutboundClickSummary(brandId, range),
    getSiteId(brandId),
    rollupFetch,
  ]);
  console.info(`[site-tag-analytics:perf] sub-summaries ${Date.now() - t0}ms`, { brandId, range });

  // ── Auto-backfill gap detection ──────────────────────────────
  // When TrackedEvent has conversion-relevant rows but SiteConversion
  // has fewer, the side-effect writer likely failed (missing brandId,
  // session ID mismatch, or pre-pipeline events). Fire a lightweight
  // backfill in the background so the numbers converge on the next
  // page load. Fire-and-forget — never blocks the response.
  if (siteId && conversions) {
    const CONV_EVENT_TYPES = ["form_submit", "click_to_call", "booking_confirmed", "booking_submit"];
    void (async () => {
      try {
        const teConvCount = await prisma.trackedEvent.count({
          where: { trackedSiteId: siteId, eventType: { in: CONV_EVENT_TYPES }, timestamp: { gte: rangeStart(range) } },
        });
        // Compare against deduped conversion count (totalConversions), not raw
        // interaction count (totalInteractions). Previously the expression
        // `totalConversions + (totalInteractions - totalConversions)` simplified
        // to totalInteractions, so the threshold fired even when dedup was working
        // correctly (e.g. 10 widget-opens → 1 deduped conversion triggered backfill
        // because 10 > 1*2). Now we only backfill when TrackedEvent has 2× or more
        // events than the actual deduped SiteConversion count.
        const scCount = conversions.totalConversions;
        if (teConvCount > 0 && scCount > 0 && teConvCount > scCount * 2) {
          console.info("[site-tag-analytics:auto-backfill] gap detected", {
            brandId, trackedEventConversions: teConvCount, siteConversionRows: scCount,
          });
          // Dynamic import so the backfill module doesn't add to the
          // critical path's cold-start parse time.
          const { backfillConversionsForBrand } = await import("./auto-backfill");
          void backfillConversionsForBrand(brandId, siteId).catch(() => {});
        }
      } catch { /* best effort — never blocks analytics */ }
    })();
  }
  if (!traffic) return null;

  // Pre-aggregate rollup rows so derived metrics below need no extra DB round trips.
  const todayUtc = new Date();
  todayUtc.setUTCHours(0, 0, 0, 0);
  const todayDateKey = todayUtc.toISOString().slice(0, 10);

  type RollupEntry = { pageViews: number; sessions: number; ect: Record<string, number> };
  const rollupByDate = new Map<string, RollupEntry>();
  const rollupAllTypes: Record<string, number> = {};

  for (const row of rollupRows) {
    const key = new Date(row.date).toISOString().slice(0, 10);
    const ect = (row.eventCountsByType ?? {}) as Record<string, number>;
    rollupByDate.set(key, { pageViews: row.pageViews, sessions: row.sessions, ect });
    for (const [type, count] of Object.entries(ect)) {
      rollupAllTypes[type] = (rollupAllTypes[type] ?? 0) + count;
    }
  }

  const hasRollupData = rollupRows.length > 0;
  const todayRollupEntry = rollupByDate.get(todayDateKey);

  let dailyPageViews: DailyPoint[] = [];
  let recentEvents: RecentEvent[] = [];
  let eventsToday = 0;
  let eventsThisWeek = 0;
  let todayByType: Array<{ type: string; count: number }> = [];
  let activePagesCount = 0;

  if (siteId) {
    const from = rangeStart(range);
    const todayStart = new Date();
    todayStart.setUTCHours(0, 0, 0, 0);
    const weekStart = new Date(Date.now() - 7 * 86400000);
    const isHourly = range === "1d";
    const tailStart = Date.now();

    type DailyRow = { day: Date; page_views: bigint; sessions: bigint };
    type ActivePagesRow = { count: bigint };

    // recentEvents (take:10) and activePagesCount always run.
    // activePagesCount is bounded to today only -- the CASE expression in
    // COUNT(DISTINCT ...) cannot use an index at any window size, so even
    // a 7-day window scans ~800K page_view rows for high-volume brands and
    // takes 5-15s, blowing the withDeadline budget. Today's window keeps
    // the scan under ~229K rows and under 1s. A range-accurate count
    // requires a per-day rollup field (activePageCount) as the long-term fix.
    const [recentRows, activePagesRows] = await Promise.all([
      prisma.trackedEvent.findMany({
        where: { trackedSiteId: siteId },
        select: { id: true, eventType: true, pageUrl: true, timestamp: true },
        orderBy: { timestamp: "desc" },
        take: 10,
      }).catch(() => []),
      prisma.$queryRaw<ActivePagesRow[]>`
        SELECT COUNT(DISTINCT
          CASE
            WHEN position('?' in "pageUrl") > 0 THEN left("pageUrl", position('?' in "pageUrl") - 1)
            WHEN position('#' in "pageUrl") > 0 THEN left("pageUrl", position('#' in "pageUrl") - 1)
            ELSE "pageUrl"
          END
        )::bigint AS count
        FROM "TrackedEvent"
        WHERE "trackedSiteId" = ${siteId}
          AND "eventType" = 'page_view'
          AND "timestamp" >= ${todayStart}
      `.catch(() => [] as ActivePagesRow[]),
    ]);
    console.info(`[site-tag-analytics:perf] trailing aggregations ${Date.now() - tailStart}ms`, { siteId, range });

    activePagesCount = activePagesRows.length > 0 ? Number(activePagesRows[0].count) : 0;

    recentEvents = recentRows.map((r) => ({
      id: r.id,
      eventType: r.eventType,
      pageUrl: r.pageUrl,
      timestamp: r.timestamp.toISOString(),
    }));

    if (hasRollupData && !isHourly) {
      // Rollup fast path: derive dailyPageViews, eventsToday, eventsThisWeek,
      // and todayByType from the pre-fetched rollup rows. Eliminates the
      // multi-day TrackedEvent scans for these four fields.
      dailyPageViews = rollupRows.map((r) => ({
        date: new Date(r.date).toISOString().slice(0, 10),
        pageViews: r.pageViews,
        sessions: r.sessions,
      }));

      if (todayRollupEntry) {
        eventsToday = Object.values(todayRollupEntry.ect).reduce((s, n) => s + n, 0);
        todayByType = Object.entries(todayRollupEntry.ect).map(([type, count]) => ({ type, count }));
      } else {
        // Today's rollup row hasn't been written yet (cron runs nightly). Fall
        // back to a live TrackedEvent count so EVENTS TODAY isn't 0 while events
        // are actively flowing.
        [eventsToday, todayByType] = await Promise.all([
          prisma.trackedEvent.count({
            where: { trackedSiteId: siteId!, timestamp: { gte: todayStart } },
          }).catch(() => 0),
          prisma.trackedEvent.groupBy({
            by: ["eventType"],
            where: { trackedSiteId: siteId!, timestamp: { gte: todayStart } },
            _count: { eventType: true },
            orderBy: { _count: { eventType: "desc" } },
            take: 30,
          }).catch(() => []).then((rows) =>
            rows.map((g) => ({ type: g.eventType, count: g._count.eventType }))
          ),
        ]);
      }

      eventsThisWeek = rollupRows
        .filter((r) => new Date(r.date) >= weekStart)
        .reduce((s, row) => {
          const ect = (row.eventCountsByType ?? {}) as Record<string, number>;
          return s + Object.values(ect).reduce((a, b) => a + b, 0);
        }, 0);
      // Add today's live count to this-week total (today's rollup row absent).
      if (!todayRollupEntry) eventsThisWeek += eventsToday;
    } else {
      // Raw fallback: no rollup rows available, or hourly (1d) / 90d range.
      // todayGroupedRows is bounded to todayStart; activePagesRows is bounded
      // to weekStart above.
      const [dailyRows, eventsTodayCount, eventsThisWeekCount, todayGroupedRows] = await Promise.all([
        isHourly
          ? prisma.$queryRaw<DailyRow[]>`
              SELECT
                date_trunc('hour', "timestamp") AS day,
                COUNT(*)::bigint AS page_views,
                COUNT(DISTINCT "sessionId")::bigint AS sessions
              FROM "TrackedEvent"
              WHERE "trackedSiteId" = ${siteId}
                AND "eventType" = 'page_view'
                AND "timestamp" >= ${from}
              GROUP BY day
              ORDER BY day ASC
            `.catch(() => [] as DailyRow[])
          : prisma.$queryRaw<DailyRow[]>`
              SELECT
                date_trunc('day', "timestamp") AS day,
                COUNT(*)::bigint AS page_views,
                COUNT(DISTINCT "sessionId")::bigint AS sessions
              FROM "TrackedEvent"
              WHERE "trackedSiteId" = ${siteId}
                AND "eventType" = 'page_view'
                AND "timestamp" >= ${from}
              GROUP BY day
              ORDER BY day ASC
            `.catch(() => [] as DailyRow[]),
        prisma.trackedEvent.count({
          where: { trackedSiteId: siteId, timestamp: { gte: todayStart } },
        }).catch(() => 0),
        prisma.trackedEvent.count({
          where: { trackedSiteId: siteId, timestamp: { gte: weekStart } },
        }).catch(() => 0),
        // todayByType bounded to todayStart only -- the previous version used
        // `from` (= rangeStart(range)) which scanned the full 28d window.
        prisma.trackedEvent.groupBy({
          by: ["eventType"],
          where: { trackedSiteId: siteId, timestamp: { gte: todayStart } },
          _count: { eventType: true },
          orderBy: { _count: { eventType: "desc" } },
          take: 30,
        }).catch(() => [] as Array<{ eventType: string; _count: { eventType: number } }>),
      ]);

      const dayMap = new Map<string, { pageViews: number; sessions: number }>();
      for (const row of dailyRows) {
        const iso = new Date(row.day).toISOString();
        const key = isHourly ? iso.slice(0, 13) : iso.slice(0, 10);
        dayMap.set(key, { pageViews: Number(row.page_views), sessions: Number(row.sessions) });
      }
      if (isHourly) {
        const today = new Date().toISOString().slice(0, 10);
        for (let h = 0; h < 24; h++) {
          const key = `${today}T${String(h).padStart(2, "0")}`;
          if (!dayMap.has(key)) dayMap.set(key, { pageViews: 0, sessions: 0 });
        }
      }
      dailyPageViews = Array.from(dayMap.entries())
        .sort(([a], [b]) => a.localeCompare(b))
        .map(([date, b]) => ({ date, pageViews: b.pageViews, sessions: b.sessions }));

      eventsToday = eventsTodayCount;
      eventsThisWeek = eventsThisWeekCount;

      todayByType = (todayGroupedRows as Array<{ eventType: string; _count: { eventType: number } | number }>)
        .map((g) => ({
          type: g.eventType,
          count: typeof g._count === "number" ? g._count : g._count.eventType,
        }));
    }

    // Consolidate event types that map to the same canonical conversion
    // so the bar matches the Hard/Soft Conversions breakdown below.
    // Without this, the bar shows "Click to Call: 6" while Hard
    // Conversions shows "Phone Call: 7" because SiteConversion merges
    // click_to_call + phone_number_click + phone_call into one type.
    const CONSOLIDATION_MAP: Record<string, string> = {
      phone_number_click: "click_to_call",
      phone_call: "click_to_call",
      purchase_completed: "purchase",
      sms_submit: "sms_click",
    };
    const consolidated = new Map<string, number>();
    for (const row of todayByType) {
      const key = CONSOLIDATION_MAP[row.type] ?? row.type;
      consolidated.set(key, (consolidated.get(key) ?? 0) + row.count);
    }
    todayByType = Array.from(consolidated.entries()).map(([type, count]) => ({ type, count }));

    // Guarantee that the headline funnel event types are ALWAYS
    // visible in the breakdown — even at zero count — so a brand
    // whose phone clicks haven't fired in the active range still
    // sees "Click to Call: 0" in the row, and the absence is a
    // signal rather than a mystery. Without this guarantee the
    // breakdown silently drops any type with zero events, which
    // confuses operators who can see "59 phone calls" on the
    // admin dashboard but no row here.
    const ALWAYS_SHOW_TYPES = [
      "page_view", "session_start",
      "form_start", "form_submit", "click_to_call", "outbound_click",
      "appointment_intent", "booking_confirmed",
    ];
    const seenTypes = new Set(todayByType.map((r) => r.type));
    for (const t of ALWAYS_SHOW_TYPES) {
      if (!seenTypes.has(t)) todayByType.push({ type: t, count: 0 });
    }

    todayByType.sort((a, b) => b.count - a.count);
  }

  // ── Detected plugins + funnel ────────────────────────────────────
  // Both are best-effort — a query failure here returns empty/zeroed
  // shapes rather than failing the whole analytics request.
  let pluginsDetected: DetectedPlugin[] = [];
  let formFunnel: FormBookingFunnel = {
    intent: 0, started: 0, submitted: 0, confirmed: 0,
    dropoffStarted: null, dropoffSubmitted: null, dropoffConfirmed: null,
  };

  if (siteId) {
    const from = rangeStart(range);
    // Conversion-funnel event-type list. One groupBy across all
    // funnel-relevant types replaces the four separate counts the
    // previous implementation ran — same wall-clock cost, plus we
    // get per-channel breakdown for the row under the funnel
    // without any extra round-trips.
    const FUNNEL_EVENT_TYPES = [
      // engagement (top) — intent signals
      "appointment_intent", "form_intent", "booking_widget_opened",
      "booking_opened", "booking_step", "form_start",
      "email_click", "product_view",
      // soft conversions (mid) — meaningful actions, not final conversions
      "add_to_cart", "checkout_started", "file_download",
      "newsletter_signup", "waitlist_signup", "booking_submit",
      // hard conversions (bottom) — definitive conversion events
      "form_submit", "click_to_call", "booking_confirmed",
      "purchase_completed", "purchase",
      "sms_click", "sms_submit",
    ];
    try {
      // pluginRows are always needed for pluginsDetected (take:100 bound).
      // funnelRows are only fetched when rollup is absent; for rollup ranges
      // we derive engagement counts from rollupAllTypes (pre-aggregated above).
      const pluginRows = await prisma.trackedEvent.findMany({
        where: { trackedSiteId: siteId, eventType: "plugin_detected", timestamp: { gte: from } },
        orderBy: { timestamp: "desc" },
        select: { timestamp: true, metadata: true },
        take: 100,
      }).catch(() => []);

      let c: Record<string, number>;
      if (hasRollupData) {
        // Derive from the rollup event-type cumulative -- eliminates the
        // full-range TrackedEvent groupBy(FUNNEL_EVENT_TYPES) scan.
        c = { ...rollupAllTypes };
      } else {
        const funnelRows = await prisma.trackedEvent.groupBy({
          by: ["eventType"],
          where: {
            trackedSiteId: siteId,
            eventType: { in: FUNNEL_EVENT_TYPES },
            timestamp: { gte: from },
          },
          _count: true,
        }).catch(() => []);
        c = {};
        for (const row of funnelRows) {
          if (!row.eventType) continue;
          c[row.eventType] = typeof row._count === "number" ? row._count : 0;
        }
      }
      const intentCount = (c.appointment_intent ?? 0) + (c.form_intent ?? 0);
      const startedCount = c.form_start ?? 0;
      const submittedCount = c.form_submit ?? 0;
      const confirmedCount = c.booking_confirmed ?? 0;

      // Reduce plugin_detected events to one row per plugin key.
      // Handles both the new consolidated payload { plugins: [{plugin_key, plugin, ...}] }
      // and the legacy single-plugin flat shape { plugin_key, plugin, ... } for backward compat.
      const byKey = new Map<string, { firstSeen: Date; lastSeen: Date; name: string }>();
      for (const row of pluginRows) {
        const meta = (row.metadata ?? {}) as Record<string, unknown>;
        // Normalize to a list of { key, name } pairs from either payload shape.
        const pluginEntries: Array<{ key: string; name: string }> = [];
        if (Array.isArray(meta.plugins)) {
          for (const p of meta.plugins as unknown[]) {
            if (typeof p !== "object" || p === null) continue;
            const pm = p as Record<string, unknown>;
            const k = typeof pm.plugin_key === "string" ? pm.plugin_key.toLowerCase() : null;
            if (!k) continue;
            pluginEntries.push({ key: k, name: typeof pm.plugin === "string" ? pm.plugin : k });
          }
        } else {
          const k = (typeof meta.plugin_key === "string" ? meta.plugin_key : null)?.toLowerCase();
          const n = typeof meta.plugin === "string" ? meta.plugin : null;
          if (k) pluginEntries.push({ key: k, name: n ?? k });
        }
        for (const { key, name } of pluginEntries) {
          const existing = byKey.get(key);
          if (existing) {
            // pluginRows is desc-sorted, so first seen here = latest;
            // we keep walking and the OLDEST stays as firstSeen.
            existing.firstSeen = row.timestamp;
          } else {
            byKey.set(key, { firstSeen: row.timestamp, lastSeen: row.timestamp, name });
          }
        }
      }
      pluginsDetected = Array.from(byKey.entries()).map(([key, info]) => {
        const cap = PLUGIN_TRACKING_CAPABILITIES[key];
        return {
          key,
          name: cap?.name ?? info.name,
          firstSeenAt: info.firstSeen.toISOString(),
          lastSeenAt: info.lastSeen.toISOString(),
          trackingLevel: cap?.trackingLevel ?? "limited",
          trackingNote: cap?.trackingNote ?? "Custom plugin — basic detection only.",
        };
      }).sort((a, b) => a.name.localeCompare(b.name));

      const dropoff = (from: number, to: number): number | null => {
        if (from <= 0) return null;
        const lost = Math.max(0, from - to);
        return Math.round((lost / from) * 1000) / 10;
      };
      // 3-stage rollup aligned with the platform conversion taxonomy:
      //   ENGAGEMENT       = intent signals (no conversion action yet)
      //   SOFT CONVERSIONS = meaningful actions (not final)
      //   HARD CONVERSIONS = definitive conversion events
      //
      // IMPORTANT — source of truth:
      //   Engagement is computed from TrackedEvent (intent signals
      //     that never write a SiteConversion row).
      //   Hard + Soft are computed from SiteConversion via the
      //     `conversions.byType` map built above. This is the SAME
      //     table that drives the Conversions KPI card, so the
      //     funnel's Hard + Soft totals now reconcile with the KPI
      //     card's deduped count (previously the funnel used raw
      //     TrackedEvent counts which diverged wildly from
      //     SiteConversion — form-phone.js text-pattern detections,
      //     plugin postMessage rows, and gtag-intercepted conversions
      //     all write SiteConversion rows without a matching
      //     TrackedEvent eventType).
      const bookingOpensRaw = (c.booking_widget_opened ?? 0) + (c.booking_opened ?? 0);

      const engagement =
        (c.appointment_intent ?? 0) + (c.form_intent ?? 0) +
        bookingOpensRaw + (c.booking_step ?? 0) + (c.form_start ?? 0) +
        (c.product_view ?? 0);

      // Tier split from SiteConversion byType (deduped). byType is
      // keyed by UI-canonical names (click_to_call, form_submit,
      // booking_confirmed, email_click, sms_click, booking_submit,
      // purchase, add_to_cart, ...). canonicalConversionType resolves
      // each key back to the taxonomy canonical, and CONVERSION_TYPES
      // carries the tier so the rollup is pure table-driven.
      const byType = conversions?.byType ?? {};
      let hardConversions = 0;
      let softConversions = 0;
      const hardByKey: Record<string, number> = {};
      const softByKey: Record<string, number> = {};
      for (const [key, count] of Object.entries(byType)) {
        const canon = canonicalConversionType(key);
        if (!canon) continue;
        const tier = CONVERSION_TYPES[canon].tier;
        if (tier === 1) {
          hardConversions += count;
          hardByKey[key] = (hardByKey[key] ?? 0) + count;
        } else if (tier === 2) {
          softConversions += count;
          softByKey[key] = (softByKey[key] ?? 0) + count;
        }
      }
      formFunnel = {
        // Legacy 4-stage fields — kept populated so any non-overview
        // consumers keep working unchanged.
        intent: intentCount,
        started: startedCount,
        submitted: submittedCount,
        confirmed: confirmedCount,
        dropoffStarted: dropoff(intentCount, startedCount),
        dropoffSubmitted: dropoff(startedCount, submittedCount),
        dropoffConfirmed: dropoff(submittedCount, confirmedCount),
        // New 3-stage rollup + per-channel breakdown. Every cell in
        // channels maps to exactly one stage so the stage totals sum
        // to the channel totals exactly. Hard + Soft totals reconcile
        // with the SiteConversion KPI because they're derived from
        // the same byType map.
        engagement,
        actions: softConversions,
        conversions: hardConversions,
        dropoffToActions: dropoff(engagement, softConversions),
        dropoffToConversions: dropoff(softConversions, hardConversions),
        channels: {
          // Engagement tier — TrackedEvent raw counts. Every event
          // type here is one that does NOT write a SiteConversion
          // side-effect (see CONVERSION_EVENT_MAP in site-tag.ts), so
          // there's no overlap with the Hard/Soft SiteConversion
          // counts below.
          appointmentIntents: (c.appointment_intent ?? 0) + (c.form_intent ?? 0),
          bookingOpens: bookingOpensRaw,
          bookingSteps: c.booking_step ?? 0,
          formStarts: c.form_start ?? 0,
          productViews: c.product_view ?? 0,
          // Soft conversions tier — SiteConversion deduped counts.
          addToCarts: softByKey.add_to_cart ?? 0,
          checkoutStarteds: softByKey.checkout_started ?? 0,
          fileDownloads: softByKey.file_download ?? 0,
          newsletterSignups: softByKey.newsletter_signup ?? 0,
          waitlistSignups: softByKey.waitlist_signup ?? 0,
          bookingSubmits: softByKey.booking_submit ?? 0,
          // Hard conversions tier — SiteConversion deduped counts.
          // phone_number_click collapses into click_to_call via
          // canonicalType() above; email_click holds email_contact
          // rows; sms_click holds sms_contact rows.
          formSubmits: hardByKey.form_submit ?? 0,
          phoneCalls: hardByKey.click_to_call ?? 0,
          bookingConfirmed: hardByKey.booking_confirmed ?? 0,
          purchases: hardByKey.purchase ?? 0,
          smsClicks: hardByKey.sms_click ?? 0,
          emailClicks: hardByKey.email_click ?? 0,
        },
      };

      // AI-detected per-type counts — conversions from form-phone.js
      // intelligence (tel: link clicks, phone number text patterns)
      // that never created a TrackedEvent. The UI needs this to
      // compute accurate per-type duplicate counts.
      try {
        const aiRows = await prisma.siteConversion.groupBy({
          by: ["conversionType"],
          where: {
            brandId,
            timestamp: { gte: from },
            detectionMethod: { in: ["tel_link", "phone_pattern"] },
          },
          _count: true,
        });
        const ai: Record<string, number> = {};
        for (const r of aiRows) {
          const canon = canonicalConversionType(r.conversionType);
          if (canon) ai[canon] = (ai[canon] ?? 0) + (typeof r._count === "number" ? r._count : 0);
        }
        if (formFunnel.channels) {
          formFunnel.channels.aiDetected = {
            formSubmits: ai.form_submit ?? 0,
            phoneCalls: ai.click_to_call ?? 0,
            bookingConfirmed: ai.booking_confirmed ?? 0,
            purchases: ai.purchase ?? 0,
            smsClicks: ai.sms_click ?? 0,
            emailClicks: ai.email_click ?? 0,
            bookingSubmits: ai.booking_submit ?? 0,
          };
        }
      } catch { /* non-critical — duplicates just show as "Other" */ }

    } catch (err) {
      console.warn("[site-tag-analytics] plugin/funnel aggregation failed", err);
    }
  }

  // ── Conversions by source (native / gtag / plugin) ──
  // Best-effort. Pulls the metadata.source field from form_submit +
  // booking_submit + booking_confirmed events in the range and
  // bucketises so the dashboard can caption "74 via gtag, 11 via
  // native" without fetching the rows again.
  let conversionsBySource: ConversionsBySource = { formSubmits: {}, bookings: {}, byProvider: {} };
  if (siteId) {
    try {
      // Bound to 14 days to prevent a full-range TrackedEvent scan.
      // conversionsBySource drives the "74 via gtag / 11 via native" caption --
      // a 14-day window is representative without hitting the full range.
      const convSourceFrom = new Date(Date.now() - 14 * 86_400_000);
      type SourceRow = { event_type: string; source: string; cnt: bigint };
      const [sourceRows, providerRows] = await Promise.allSettled([
        prisma.$queryRaw<SourceRow[]>`
          SELECT
            "eventType"                                AS event_type,
            COALESCE("metadata"->>'source', 'unknown') AS source,
            COUNT(*)::bigint                           AS cnt
          FROM "TrackedEvent"
          WHERE "trackedSiteId" = ${siteId}
            AND "eventType" IN ('form_submit', 'booking_submit', 'booking_confirmed')
            AND "timestamp" >= ${convSourceFrom}
          GROUP BY "eventType", COALESCE("metadata"->>'source', 'unknown')
        `,
        prisma.siteConversion.groupBy({
          by: ["formProvider"],
          where: { brandId, timestamp: { gte: convSourceFrom }, formProvider: { not: null } },
          _count: { _all: true },
        }),
      ]);

      const formSubmits: Record<string, number> = {};
      const bookings: Record<string, number> = {};
      if (sourceRows.status === "fulfilled") {
        for (const row of sourceRows.value) {
          const count = Number(row.cnt);
          if (row.event_type === "form_submit") {
            formSubmits[row.source] = (formSubmits[row.source] ?? 0) + count;
          } else {
            bookings[row.source] = (bookings[row.source] ?? 0) + count;
          }
        }
      }
      const byProvider: Record<string, number> = {};
      if (providerRows.status === "fulfilled") {
        for (const row of providerRows.value) {
          if (row.formProvider) byProvider[row.formProvider] = row._count._all;
        }
      }
      conversionsBySource = { formSubmits, bookings, byProvider };
    } catch (err) {
      console.warn("[site-tag-analytics] conversionsBySource aggregation failed", err);
    }
  }

  console.info(`[site-tag-analytics:perf] getSiteTagAnalytics total ${Date.now() - t0}ms`, { brandId, range });
  return {
    traffic,
    conversions: conversions ?? {
      totalConversions: 0, totalInteractions: 0,
      byType: {}, byTypeInteractions: {},
      conversionRate: 0,
      topConvertingPages: [], byUtmSource: [],
    },
    trends,
    dailyPageViews,
    recentEvents,
    eventsToday,
    eventsThisWeek,
    activePagesCount,
    outboundClicks,
    todayByType,
    pluginsDetected,
    formFunnel,
    conversionsBySource,
    period: { from: rangeStart(range), to: new Date() },
  };
}

// ─── Signal detection from tracked data ──────────────────────────────────────

export interface TrackedSignal {
  title: string;
  description: string;
  priority: "high" | "medium" | "low";
  category: string;
  source: "site-tag";
}

/**
 * Scans tracked event data for patterns worth alerting the user about.
 * Returns signals that can be merged into the Signals Center.
 */
export async function detectTrackedSignals(brandId: string): Promise<TrackedSignal[]> {
  const site = await getVerifiedSite(brandId);
  if (!site) return [];
  const siteId = site.id;

  const signals: TrackedSignal[] = [];
  const todayStart = new Date();
  todayStart.setUTCHours(0, 0, 0, 0);
  const yesterdayStart = new Date(todayStart);
  yesterdayStart.setUTCDate(yesterdayStart.getUTCDate() - 1);

  // Count events today vs yesterday
  const todayCount = await prisma.trackedEvent.count({
    where: { trackedSiteId: siteId, timestamp: { gte: todayStart } },
  });
  const yesterdayCount = await prisma.trackedEvent.count({
    where: { trackedSiteId: siteId, timestamp: { gte: yesterdayStart, lt: todayStart } },
  });

  // Signal: traffic drop >30%
  if (yesterdayCount > 10 && todayCount < yesterdayCount * 0.7) {
    const dropPct = Math.round((1 - todayCount / yesterdayCount) * 100);
    signals.push({
      title: `Traffic dropped ${dropPct}% today`,
      description: `${todayCount} events today vs ${yesterdayCount} yesterday. Check if your site is down or if the tracking tag was removed.`,
      priority: "high",
      category: "traffic",
      source: "site-tag",
    });
  }

  // Signal: traffic spike >50%
  if (yesterdayCount > 10 && todayCount > yesterdayCount * 1.5) {
    const spikePct = Math.round((todayCount / yesterdayCount - 1) * 100);
    signals.push({
      title: `Traffic spiked +${spikePct}% today`,
      description: `${todayCount} events today vs ${yesterdayCount} yesterday. Investigate the source — could be a viral post, ad campaign, or bot traffic.`,
      priority: "medium",
      category: "traffic",
      source: "site-tag",
    });
  }

  // Signal: high call volume. Counts SiteConversion rows that
  // resolve to the phone_call canonical — form-phone.js writes
  // "phone_call" / "phone_number_click" plus older posts wrote
  // "click_to_call". Previously only counted TrackedEvent
  // click_to_call rows (~3–4× under the real volume for brands
  // using tel-link + text-pattern detection).
  // Phone-call signal via shared helper — same 3-type phone bucket
  // the admin dashboard + overview Conversion Breakdown use.
  const brandIdForSignal = await prisma.trackedSite
    .findUnique({ where: { id: siteId }, select: { brandId: true } })
    .catch(() => null);
  const callsToday = brandIdForSignal
    ? (await getPhoneMetrics(brandIdForSignal.brandId, todayStart)).total
    : 0;
  if (callsToday >= 5) {
    signals.push({
      title: `${callsToday} click-to-call events today`,
      description: `Phone call clicks are above average. Make sure your team is ready to handle the volume.`,
      priority: "low",
      category: "conversions",
      source: "site-tag",
    });
  }

  // Signal: form submissions without page views on key pages
  const formSubmits = await prisma.trackedEvent.count({
    where: { trackedSiteId: siteId, eventType: "form_submit", timestamp: { gte: todayStart } },
  });
  const formStarts = await prisma.trackedEvent.count({
    where: { trackedSiteId: siteId, eventType: "form_start", timestamp: { gte: todayStart } },
  });
  if (formStarts > 5 && formSubmits === 0) {
    signals.push({
      title: "Form abandonment detected",
      description: `${formStarts} users started filling out forms today but none submitted. Check for form errors or confusing UX.`,
      priority: "high",
      category: "conversions",
      source: "site-tag",
    });
  }

  // Signal: HTTP errors detected by real users (from SiteEvent, not TrackedEvent)
  try {
    const weekAgo = new Date(Date.now() - 7 * 86400000);
    const errorEvents = await prisma.siteEvent.findMany({
      where: { brandId, eventType: { in: ["http_error", "broken_link"] }, timestamp: { gte: weekAgo } },
      select: { eventType: true, eventData: true },
      take: 500,
    });
    const errorCount = errorEvents.filter((e) => e.eventType === "http_error").length;
    const brokenLinkCount = errorEvents.filter((e) => e.eventType === "broken_link").length;
    const serverErrors = errorEvents.filter((e) => {
      const d = (e.eventData ?? {}) as Record<string, unknown>;
      return typeof d.statusCode === "number" && d.statusCode >= 500;
    }).length;

    if (serverErrors > 0) {
      signals.push({
        title: `${serverErrors} server error${serverErrors > 1 ? "s" : ""} detected this week`,
        description: "Real users hit 5xx server errors. Check server logs — this indicates backend issues that need immediate attention.",
        priority: "high",
        category: "traffic",
        source: "site-tag",
      });
    }
    if (errorCount >= 5) {
      signals.push({
        title: `${errorCount} 404 errors detected by real users`,
        description: `Users hit ${errorCount} page-not-found errors in the last 7 days. Review the Errors & Redirects tab to find and fix broken pages.`,
        priority: errorCount >= 20 ? "high" : "medium",
        category: "traffic",
        source: "site-tag",
      });
    }
    if (brokenLinkCount >= 3) {
      signals.push({
        title: `${brokenLinkCount} broken internal link${brokenLinkCount > 1 ? "s" : ""} found`,
        description: `Users clicked internal links that led to error pages ${brokenLinkCount} times. The Errors & Redirects tab shows the exact source pages and broken URLs.`,
        priority: brokenLinkCount >= 10 ? "high" : "medium",
        category: "traffic",
        source: "site-tag",
      });
    }
  } catch { /* error signal generation failed — skip */ }

  return signals;
}

// ─── Error Pattern Aggregation ───────────────────────────────────────────────

export interface ErrorPattern {
  tupleKey: string;
  message: string;
  filename: string | null;
  line: number | null;
  occurrenceCount: number;
  affectedPageCount: number;
  affectedSessionCount: number;
  firstSeenAt: Date;
  lastSeenAt: Date;
  topPages: { pageUrl: string; count: number }[];
}

/**
 * Aggregate js_error events from SiteEvent into distinct error patterns.
 *
 * Uses ORDER BY timestamp DESC LIMIT to bound the scan at a recent window
 * rather than aggregating all-time history. On a high-volume brand with
 * 500K+ js_error rows this keeps the query under 2 seconds via the
 * @@index([brandId, eventType, timestamp]) composite index.
 *
 * The most frequent patterns dominate any reasonable sample, so the
 * actionable findings are preserved even on a bounded scan.
 */
export async function getErrorPatterns(
  brandId: string,
  options?: { limit?: number; timeRangeMs?: number }
): Promise<ErrorPattern[]> {
  const limit = options?.limit ?? 5000;
  const since = options?.timeRangeMs != null
    ? new Date(Date.now() - options.timeRangeMs)
    : new Date(Date.now() - 30 * 86_400_000);

  let rows: Array<{ eventData: unknown; pageUrl: string; sessionId: string | null; timestamp: Date }>;
  try {
    rows = await prisma.siteEvent.findMany({
      where: { brandId, eventType: "js_error", timestamp: { gte: since } },
      select: { eventData: true, pageUrl: true, sessionId: true, timestamp: true },
      orderBy: { timestamp: "desc" },
      take: limit,
    });
  } catch {
    return [];
  }

  if (rows.length === 0) return [];

  // In-memory aggregation by (message, filename, line) tuple
  const map = new Map<string, {
    message: string; filename: string | null; line: number | null;
    count: number; pages: Map<string, number>; sessions: Set<string>;
    firstSeen: Date; lastSeen: Date;
  }>();

  for (const row of rows) {
    const d = (row.eventData ?? {}) as Record<string, unknown>;
    const msg = typeof d.message === "string" ? d.message.slice(0, 100) : "";
    const file = typeof d.filename === "string" ? d.filename : null;
    const ln = typeof d.line === "number" ? d.line : null;
    const key = `${msg}::${file ?? ""}::${ln ?? ""}`;

    let entry = map.get(key);
    if (!entry) {
      entry = { message: msg, filename: file, line: ln, count: 0, pages: new Map(), sessions: new Set(), firstSeen: row.timestamp, lastSeen: row.timestamp };
      map.set(key, entry);
    }
    entry.count++;
    const path = extractPath(row.pageUrl);
    entry.pages.set(path, (entry.pages.get(path) ?? 0) + 1);
    if (row.sessionId) entry.sessions.add(row.sessionId);
    if (row.timestamp < entry.firstSeen) entry.firstSeen = row.timestamp;
    if (row.timestamp > entry.lastSeen) entry.lastSeen = row.timestamp;
  }

  const patterns: ErrorPattern[] = Array.from(map.entries()).map(([key, e]) => {
    const topPages = Array.from(e.pages.entries())
      .sort(([, a], [, b]) => b - a)
      .slice(0, 5)
      .map(([pageUrl, count]) => ({ pageUrl, count }));
    return {
      tupleKey: key,
      message: e.message,
      filename: e.filename,
      line: e.line,
      occurrenceCount: e.count,
      affectedPageCount: e.pages.size,
      affectedSessionCount: e.sessions.size,
      firstSeenAt: e.firstSeen,
      lastSeenAt: e.lastSeen,
      topPages,
    };
  });

  return patterns.sort((a, b) => b.occurrenceCount - a.occurrenceCount);
}

// ─── URL helpers ─────────────────────────────────────────────────────────────

export function extractPath(fullUrl: string): string {
  try { return new URL(fullUrl).pathname; } catch { return fullUrl; }
}

function extractDomain(fullUrl: string): string {
  try { return new URL(fullUrl).hostname; } catch { return fullUrl; }
}

// ─── Error & Redirect Analytics ─────────────────────────────────────────────

export interface HttpErrorRow { pageUrl: string; statusCode: number; occurrences: number; firstSeen: string; lastSeen: string; referrer: string | null }
export interface BrokenLinkRow { sourceUrl: string; targetUrl: string; statusCode: number; linkText: string | null; occurrences: number }
export interface RedirectRow { originalUrl: string; finalUrl: string; redirectCount: number; avgRedirectTimeMs: number; occurrences: number; isChain: boolean }
export interface ResourceErrorRow { pageUrl: string; resourceUrl: string; resourceType: string; occurrences: number }

export interface ErrorAnalytics {
  httpErrors: HttpErrorRow[];
  brokenLinks: BrokenLinkRow[];
  redirects: RedirectRow[];
  resourceErrors: ResourceErrorRow[];
}

/**
 * Aggregate HTTP errors, broken links, redirects, and resource errors
 * from the SiteEvent table for a brand within a date range. Grouped by
 * unique key and sorted by occurrence count.
 */
export async function getErrorAnalytics(brandId: string, range: "1d" | "7d" | "28d" | "30d" | "90d" = "30d"): Promise<ErrorAnalytics> {
  const from = rangeStart(range);
  // Take cap tightened from 5000 → 2000 after observing that all
  // downstream aggregations (httpMap / blMap / rdMap / reMap) cap
  // their output lists at ≤ 200 rows each, and the occurrence
  // counts for the top rows saturate long before the 5000th input
  // event. 2000 covers the 90d window for a busy site while keeping
  // the Prisma round-trip small — memory allocation for eventData
  // JSON was the dominant cost above ~2k rows.
  const events = await prisma.siteEvent.findMany({
    where: {
      brandId,
      eventType: { in: ["http_error", "soft_404", "broken_link", "redirect_detected", "resource_error", "resource_errors"] },
      timestamp: { gte: from },
    },
    select: { eventType: true, pageUrl: true, eventData: true, timestamp: true },
    orderBy: { timestamp: "desc" },
    take: 2000,
  });

  // HTTP errors + soft 404s
  const httpMap = new Map<string, HttpErrorRow>();
  for (const e of events) {
    if (e.eventType !== "http_error" && e.eventType !== "soft_404") continue;
    const d = (e.eventData ?? {}) as Record<string, unknown>;
    const code = e.eventType === "soft_404" ? 200 : (typeof d.statusCode === "number" ? d.statusCode : 404);
    const key = `${code}::${e.pageUrl}`;
    const ts = e.timestamp.toISOString();
    const existing = httpMap.get(key);
    if (existing) {
      existing.occurrences++;
      if (ts < existing.firstSeen) existing.firstSeen = ts;
      if (ts > existing.lastSeen) existing.lastSeen = ts;
    } else {
      httpMap.set(key, { pageUrl: e.pageUrl, statusCode: code, occurrences: 1, firstSeen: ts, lastSeen: ts, referrer: typeof d.referrer === "string" ? d.referrer : null });
    }
  }

  // Broken links
  const blMap = new Map<string, BrokenLinkRow>();
  for (const e of events) {
    if (e.eventType !== "broken_link") continue;
    const d = (e.eventData ?? {}) as Record<string, unknown>;
    const src = typeof d.sourceUrl === "string" ? d.sourceUrl : "";
    const tgt = typeof d.targetUrl === "string" ? d.targetUrl : e.pageUrl;
    const key = `${src}::${tgt}`;
    const existing = blMap.get(key);
    if (existing) existing.occurrences++;
    else blMap.set(key, { sourceUrl: src, targetUrl: tgt, statusCode: typeof d.statusCode === "number" ? d.statusCode : 404, linkText: typeof d.linkText === "string" ? d.linkText : null, occurrences: 1 });
  }

  // Redirects
  const rdMap = new Map<string, { row: RedirectRow; totalTime: number }>();
  for (const e of events) {
    if (e.eventType !== "redirect_detected") continue;
    const d = (e.eventData ?? {}) as Record<string, unknown>;
    const orig = typeof d.originalUrl === "string" ? d.originalUrl : "unknown";
    const fin = typeof d.finalUrl === "string" ? d.finalUrl : e.pageUrl;
    const key = `${orig}::${fin}`;
    const timeMs = typeof d.redirectTimeMs === "number" ? d.redirectTimeMs : 0;
    const count = typeof d.redirectCount === "number" ? d.redirectCount : 1;
    const chain = d.isChain === true;
    const existing = rdMap.get(key);
    if (existing) { existing.row.occurrences++; existing.totalTime += timeMs; existing.row.avgRedirectTimeMs = Math.round(existing.totalTime / existing.row.occurrences); if (chain) existing.row.isChain = true; }
    else rdMap.set(key, { row: { originalUrl: orig, finalUrl: fin, redirectCount: count, avgRedirectTimeMs: timeMs, occurrences: 1, isChain: chain }, totalTime: timeMs });
  }

  // Resource errors — handle both singular "resource_error" and batched
  // "resource_errors" (array of {type, url} in eventData.resources)
  const reMap = new Map<string, ResourceErrorRow>();
  function addResourceError(pageUrl: string, resUrl: string, resType: string) {
    if (!resUrl) return;
    const key = `${pageUrl}::${resUrl}`;
    const existing = reMap.get(key);
    if (existing) existing.occurrences++;
    else reMap.set(key, { pageUrl, resourceUrl: resUrl, resourceType: resType, occurrences: 1 });
  }
  for (const e of events) {
    if (e.eventType === "resource_error") {
      const d = (e.eventData ?? {}) as Record<string, unknown>;
      addResourceError(e.pageUrl, typeof d.resourceUrl === "string" ? d.resourceUrl : "", typeof d.resourceType === "string" ? d.resourceType : "unknown");
    } else if (e.eventType === "resource_errors") {
      const d = (e.eventData ?? {}) as Record<string, unknown>;
      const resources = Array.isArray(d.resources) ? d.resources : [];
      for (const r of resources) {
        const res = r as Record<string, unknown>;
        addResourceError(e.pageUrl, typeof res.url === "string" ? res.url : "", typeof res.type === "string" ? res.type : "unknown");
      }
    }
  }

  return {
    httpErrors: [...httpMap.values()].sort((a, b) => b.occurrences - a.occurrences).slice(0, 50),
    brokenLinks: [...blMap.values()].sort((a, b) => b.occurrences - a.occurrences).slice(0, 50),
    redirects: [...rdMap.values()].map((r) => r.row).sort((a, b) => b.occurrences - a.occurrences).slice(0, 50),
    resourceErrors: [...reMap.values()].sort((a, b) => b.occurrences - a.occurrences).slice(0, 50),
  };
}
