/**
 * meSEO Site Tag — Service Layer
 * --------------------------------
 * Handles creating tracked sites, generating snippets, and validating
 * incoming events from the tracker script.
 *
 * How IDs work:
 *   siteId      = "ms_" + cuid()   — public, goes in the HTML snippet
 *   trackingKey = 32 random hex chars — secret, stored in DB for validation
 */

import { prisma } from "@/lib/prisma";
import { randomBytes } from "crypto";
import { getCachedSite, setCachedSite, invalidateSiteCache } from "./site-tag-cache";
import { resolveConversion, findPhoneDuplicateId } from "./conversion-resolver";
import { extractBrandServices } from "./brand-extract";
import { scoreSession } from "@/lib/predictive-scoring/score-session";
import { boundedFireAndForget } from "@/lib/utils/bounded-async";
import { setCached } from "@/lib/cache/redis-cache";
import { pulseLastEventKey } from "@/lib/cache/pulse-keys";

// Gate for all fire-and-forget side effects on the event-ingest path.
// Anything other than "true" disables them so the route keeps recording
// events with zero bounded-async DB work (frees the connection pool).
// Re-enable by setting SITE_TAG_SIDE_EFFECTS_ENABLED=true in Vercel and redeploying.
const SIDE_EFFECTS_ENABLED = process.env.SITE_TAG_SIDE_EFFECTS_ENABLED === "true";

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

export interface TrackedSiteInfo {
  id: string;
  siteId: string;
  status: string;
  allowedDomains: string[];
  lastEventAt: Date | null;
  createdAt: Date;
  snippet: string;
}

// ─── Create a tracked site for a brand ───────────────────────────────────────

export async function createTrackedSite(
  orgId: string,
  brandId: string,
  domain: string
): Promise<TrackedSiteInfo> {
  // Safety: if TrackedSite model doesn't exist yet, throw a clear error
  if (!prisma.trackedSite) {
    throw new Error("TrackedSite table not available — run: npx prisma db push && npx prisma generate");
  }

  // Check if this brand already has a tracked site
  const existing = await prisma.trackedSite.findUnique({
    where: { brandId },
  });
  if (existing) {
    return formatSiteInfo(existing);
  }

  // Generate unique IDs
  //   siteId: "ms_" prefix + Prisma's cuid (set via @default, but we need it before insert)
  //   trackingKey: 32 random hex characters (secret)
  const siteId = "ms_" + generateCuid();
  const trackingKey = randomBytes(16).toString("hex"); // 32 hex chars

  // Allowed domains: the brand's main domain + www variant, cleaned of
  // scheme/path/trailing slashes so the domain matcher can work correctly.
  const bareDomain = cleanDomain(domain);
  const allowedDomains = [
    bareDomain,
    bareDomain.startsWith("www.") ? bareDomain.slice(4) : `www.${bareDomain}`,
  ];

  const site = await prisma.trackedSite.create({
    data: {
      siteId,
      trackingKey,
      status: "active",
      allowedDomains,
      orgId,
      brandId,
    },
  });

  return formatSiteInfo(site);
}

// ─── Get a tracked site by brand ─────────────────────────────────────────────

export async function getTrackedSite(brandId: string): Promise<TrackedSiteInfo | null> {
  // Safety: if TrackedSite model doesn't exist yet (schema not migrated), return null
  if (!prisma.trackedSite) return null;
  try {
    const site = await prisma.trackedSite.findUnique({
      where: { brandId },
    });
    if (!site) return null;
    return formatSiteInfo(site);
  } catch {
    // Table might not exist yet — return null instead of crashing
    return null;
  }
}

// ─── Toggle site status ──────────────────────────────────────────────────────

export async function toggleSiteStatus(brandId: string): Promise<TrackedSiteInfo | null> {
  const site = await prisma.trackedSite.findUnique({ where: { brandId } });
  if (!site) return null;

  const newStatus = site.status === "active" ? "paused" : "active";
  const updated = await prisma.trackedSite.update({
    where: { id: site.id },
    data: { status: newStatus },
  });
  invalidateSiteCache(site.siteId); // Clear cache so new status takes effect immediately
  return formatSiteInfo(updated);
}

// ─── Regenerate tracking key ─────────────────────────────────────────────────

export async function regenerateTrackingKey(brandId: string): Promise<{ trackingKey: string } | null> {
  const site = await prisma.trackedSite.findUnique({ where: { brandId } });
  if (!site) return null;

  const newKey = randomBytes(16).toString("hex");
  await prisma.trackedSite.update({
    where: { id: site.id },
    data: { trackingKey: newKey },
  });
  return { trackingKey: newKey };
}

// ─── Add an allowed domain ───────────────────────────────────────────────────

export async function addAllowedDomain(brandId: string, domain: string): Promise<TrackedSiteInfo | null> {
  const site = await prisma.trackedSite.findUnique({ where: { brandId } });
  if (!site) return null;

  // Normalize: lowercase, trim whitespace, strip trailing slashes, drop scheme/path if present
  const normalized = cleanDomain(domain);
  if (!normalized || normalized.length < 3) return formatSiteInfo(site);

  // Don't add duplicates
  if (site.allowedDomains.includes(normalized)) return formatSiteInfo(site);

  const updated = await prisma.trackedSite.update({
    where: { id: site.id },
    data: { allowedDomains: { push: normalized } },
  });
  invalidateSiteCache(site.siteId);
  return formatSiteInfo(updated);
}

// ─── Remove an allowed domain ────────────────────────────────────────────────

export async function removeAllowedDomain(brandId: string, domain: string): Promise<TrackedSiteInfo | null> {
  const site = await prisma.trackedSite.findUnique({ where: { brandId } });
  if (!site) return null;

  // Must keep at least 1 domain
  if (site.allowedDomains.length <= 1) return formatSiteInfo(site);

  // Normalize the target so lookup matches regardless of how it was passed in
  const target = cleanDomain(domain);
  const filtered = site.allowedDomains.filter((d) => cleanDomain(d) !== target);
  const updated = await prisma.trackedSite.update({
    where: { id: site.id },
    data: { allowedDomains: { set: filtered } },
  });
  invalidateSiteCache(site.siteId);
  return formatSiteInfo(updated);
}

/** Normalize a domain string for storage/comparison: lowercase, trim, strip
 * scheme/path/trailing slashes. Leaves the www prefix intact — the domain
 * matcher handles www-vs-bare equivalence at read time. */
export function cleanDomain(input: string): string {
  let d = String(input || "").trim().toLowerCase();
  // Drop scheme and path if a full URL was pasted
  try {
    if (d.includes("://")) {
      const u = new URL(d);
      d = u.hostname;
    }
  } catch { /* fall through with raw string */ }
  // Strip trailing slashes and any path residue
  d = d.replace(/\/+.*$/, "").replace(/\/+$/, "");
  return d;
}

// ─── Validate an incoming event ──────────────────────────────────────────────

export interface ValidationResult {
  valid: boolean;
  error?: string;
  site?: { id: string; siteId: string; brandId: string; allowedDomains: string[] };
}

/**
 * Validates an incoming tracking event:
 *   1. Site ID format is correct
 *   2. Site exists (checked via in-memory cache first, then DB)
 *   3. Site is active
 *   4. Page URL's domain is in the allowed domains list
 *
 * The cache avoids hitting the DB on every request. Cache TTL = 60s.
 */
export async function validateTrackingEvent(
  siteId: string,
  pageUrl: string
): Promise<ValidationResult> {
  // Format check (no DB call needed)
  if (!siteId || !siteId.startsWith("ms_") || siteId.length < 10 || siteId.length > 60) {
    return { valid: false, error: "Invalid site ID format" };
  }

  // URL length check (prevent abuse)
  if (!pageUrl || pageUrl.length > 2048) {
    return { valid: false, error: "Invalid or oversized page URL" };
  }

  // Look up site — cache first, then DB
  let cached = getCachedSite(siteId);
  if (!cached) {
    const site = await prisma.trackedSite.findUnique({
      where: { siteId },
      select: { id: true, siteId: true, brandId: true, status: true, allowedDomains: true },
    });

    if (!site) {
      return { valid: false, error: "Site not found" };
    }

    // Store in cache for subsequent requests
    setCachedSite(site);
    cached = { ...site, fetchedAt: Date.now() };
  }

  if (cached.status !== "active") {
    console.info("[site-tag] validateTrackingEvent rejected: site inactive", { siteId, status: cached.status });
    return { valid: false, error: `Site is ${cached.status}` };
  }

  // Domain validation — stored entries may include a path (e.g. "quiz.pbdquiz.com/sf/2fc04c39").
  // Extract the hostname from both the event URL and each stored entry for comparison.
  try {
    const url = new URL(pageUrl);
    const rawHost = url.hostname.toLowerCase();
    const hostname = rawHost.replace(/^www\./, "");
    // extractHostname: stored value may be "host/path" or "host:port/path" — take the first segment.
    const allowed = cached.allowedDomains.map((d) =>
      d.toLowerCase().split("/")[0].split(":")[0].replace(/^www\./, "")
    );

    if (!allowed.some((d) => hostname === d || hostname.endsWith(`.${d}`))) {
      console.info("[site-tag] validateTrackingEvent rejected: domain not allowed", {
        siteId, host: rawHost, normalizedHost: hostname, allowedDomains: cached.allowedDomains,
      });
      return { valid: false, error: `Domain ${rawHost} is not allowed for this site` };
    }
  } catch {
    return { valid: false, error: "Invalid page URL" };
  }

  return {
    valid: true,
    site: { id: cached.id, siteId: cached.siteId, brandId: cached.brandId, allowedDomains: cached.allowedDomains },
  };
}

// ─── Record an event ─────────────────────────────────────────────────────────

export interface EventPayload {
  siteId: string;
  eventType: string;
  pageUrl: string;
  referrer?: string;
  timestamp?: string;
  sessionId?: string;
  visitorId?: string;
  metadata?: Record<string, unknown>;
  /** Pre-resolved site ID from validation (avoids a second DB lookup) */
  resolvedSiteDbId?: string;
  /** Pre-resolved brand ID from validation (used for SiteConversion writes
   *  on conversion-relevant event types — form_submit, booking_submit,
   *  booking_confirmed). Optional for backwards compat: when missing,
   *  recordEvent looks it up from siteDbId before writing a conversion. */
  resolvedBrandId?: string;
  /** True for events emitted by the installation-verification harness.
   *  Excluded from analytics counts so test traffic doesn't inflate metrics. */
  synthetic?: boolean;
  /** Server-derived client context from the inbound request — UA
   *  parse + forwarded-for IP + Vercel geo headers. Only written to
   *  TrackedSession on session-starter events; subsequent events for
   *  the same session are unaffected (no column overwrite). */
  clientContext?: {
    userAgent?: string | null;
    device?: string | null;
    browser?: string | null;
    os?: string | null;
    ipAddress?: string | null;
    country?: string | null;
    city?: string | null;
    region?: string | null;
  };
}

// ─── Conversion event mapping ────────────────────────────────────────
//
// /api/collect ingests events from t.js. A subset of those events
// represent business-meaningful conversions and should also write to
// SiteConversion (the table dashboards + attribution + revenue use as
// the source of truth).
//
// click_to_call IS mapped here (as of the Life Imaging audit) — the
// resolver's 30-second (brand, session, pageUrl) dedup window means
// the parallel form-phone.js path (tel-link + phone-text-pattern
// detectors that POST to /api/site-tag/conversion) collapses against
// the t.js-emitted row automatically. Previously click_to_call was
// excluded on the theory that form-phone.js handled it, but brands
// without form-phone.js loaded (legacy installs, customer-bundle
// opt-outs) ended up with TrackedEvent click_to_call rows and zero
// SiteConversion rows — a 100% gap on the phone-conversion KPI.
//
// Mapping is intentionally minimal:
//   form_submit       → "form_submission"   — native + plugin AJAX
//                                              submits both land here
//                                              with metadata.source
//                                              tagging the provider
//   click_to_call     → "phone_call"        — resolver collapses
//                                              against form-phone.js
//                                              writes via 30s dedup
//   booking_submit    → "booking_attempt"   — booking POSTed but not
//                                              confirmed (Amelia AJAX
//                                              fired before server
//                                              echoed back)
//   booking_confirmed → "booking_confirmed" — hard conversion; the
//                                              dashboard counts these
//                                              as the bookings KPI.

// Visibility events fire once per element per page session on the client,
// but DOM recreation (React/Next.js hydration, CMS re-render) can cause the
// client-side guard to miss. This set drives a 60-second server-side dedup
// using (siteDbId, sessionId, eventType) — element precision isn't possible
// without a payload element target, so the window errs on the tight side.
const VISIBILITY_EVENT_TYPES = new Set([
  "phone_number_visible",
  "social_proof_visible",
  "review_widget_visible",
]);

const CONVERSION_EVENT_MAP: Record<string, string> = {
  form_submit: "form_submission",
  click_to_call: "phone_call",
  booking_submit: "booking_attempt",
  booking_confirmed: "booking_confirmed",
  // purchase — gtag-intercepted e-commerce conversion. metadata.value
  // and metadata.currency carry the transaction amount when present.
  // The dashboard's revenue rollup reads SiteConversion rows of this
  // type when computing GMV.
  purchase: "purchase",
  // URL + JSON-LD based confirmation-page detection. Routes to the
  // same "purchase" canonical so both paths share a single row in
  // SUM(conversionValue) rollups — the resolver's 30-second dedup
  // collapses a gtag purchase + purchase_completed pair on platforms
  // that emit both.
  purchase_completed: "purchase",
  // Email interactions. email_click — mailto: click, carries only
  // metadata.email_domain (never the local-part). newsletter_signup —
  // native or ESP-embed (Mailchimp / ConvertKit / Klaviyo / CTCT /
  // ActiveCampaign) submit. Both canonical names match the entries
  // in conversion-taxonomy.ts so the resolver's tier + label lookups
  // resolve correctly.
  email_click: "email_contact",
  newsletter_signup: "newsletter_signup",
  // SMS: clicks. sms_click is the privacy-preserving variant carrying
  // only phone_last4; sms_submit is the pre-existing tracking event
  // carrying the full phone for session timelines. Both route to the
  // same sms_contact canonical so they collapse into one row via the
  // resolver's 30s dedup window.
  sms_click: "sms_contact",
  // E-commerce Tier-2 signals. add_to_cart — click on an add-to-cart
  // button OR gtag add_to_cart. checkout_started — URL-based
  // checkout-page detection OR gtag begin_checkout. Both carry the
  // cart value + currency when available; SiteConversion rows get
  // those via the new conversionValue + currency columns below.
  add_to_cart: "add_to_cart",
  checkout_started: "checkout_started",
  // file_download — click on a link whose href points at a
  // downloadable file extension (.pdf, .docx, .xlsx, etc.). The
  // category enum (brochure / whitepaper / pricing / form / menu /
  // manual / case_study / other) comes across in metadata.category
  // and lands on formFields._category for the "pricing-download →
  // conversion-rate" reports.
  file_download: "file_download",
  // waitlist_signup — submit on a form that looks like a waitlist
  // / notify-me form (text or URL match). Tier-2 canonical so the
  // "deferred demand" KPI stays separate from booked appointments.
  waitlist_signup: "waitlist_signup",
};

// Track which sites had lastEventAt updated this invocation. In
// serverless (Vercel), each request is a separate function instance
// — the old 10-second setTimeout debounce never fired because the
// function froze after returning the response. This Set collapses
// writes within a single warm-instance lifetime (cheap: one DB
// write per site per warm period instead of per event).
const lastEventUpdatedThisInvocation = new Set<string>();
const LAST_EVENT_REDIS_TTL_S = 86_400; // 24h -- written on every event, outlives the cache
// Track sites where we have already attempted a firstEventAt backfill this
// invocation. Prevents a redundant findUnique + findFirst on every event once
// the cache is known to be populated.
const firstEventAtCheckedThisInvocation = new Set<string>();

export async function recordEvent(payload: EventPayload): Promise<{ eventId: string; sessionId: string | null }> {
  // Use pre-resolved site ID if available (from validation), otherwise look up
  let siteDbId = payload.resolvedSiteDbId;
  if (!siteDbId) {
    const site = await prisma.trackedSite.findUnique({
      where: { siteId: payload.siteId },
      select: { id: true },
    });
    if (!site) throw new Error("Site not found");
    siteDbId = site.id;
  }

  // ── Input sanitization ──
  const sanitizedUrl = payload.pageUrl.slice(0, 2048);
  const sanitizedReferrer = payload.referrer?.slice(0, 2048) ?? null;
  const sanitizedSessionId = payload.sessionId?.slice(0, 100);

  // Sanitize metadata: limit keys, values, and total size
  const rawMeta = payload.metadata ?? {};
  const sanitizedMeta: Record<string, unknown> = {};
  let metaKeys = 0;
  for (const [k, v] of Object.entries(rawMeta)) {
    if (metaKeys >= 30) break; // max 30 keys
    const key = String(k).slice(0, 50);
    const val = typeof v === "string" ? v.slice(0, 500) : v;
    sanitizedMeta[key] = val;
    metaKeys++;
  }

  // Validate timestamp (reject future timestamps > 5 min or too-old > 24h)
  let eventTimestamp = new Date();
  if (payload.timestamp) {
    const parsed = new Date(payload.timestamp);
    if (!isNaN(parsed.getTime())) {
      const diff = parsed.getTime() - Date.now();
      if (diff < 5 * 60_000 && diff > -24 * 3600_000) {
        eventTimestamp = parsed;
      }
    }
  }

  // ── Session handling ──
  // Previous find-then-create pattern had a TOCTOU race: two events
  // for the same sessionId arriving in the same millisecond (common
  // for the synthetic session_start + page_view pair t.js emits on
  // the first hit, or for a fast page navigation that fires a
  // click_to_call + page_view back-to-back) both saw findUnique=null
  // and both tried .create(). One wins, the other threw
  // "Unique constraint failed on the fields: (sessionId)" up to the
  // collect endpoint, which then returned 500 to the tracker and lost
  // the event entirely. upsert resolves the race atomically in
  // Postgres via INSERT … ON CONFLICT DO UPDATE, so concurrent arrivals
  // serialise on the unique index instead of racing in application code.
  //
  // Conditional-create preserved: we still only CREATE a session row
  // from session_start / page_view events. Stray non-starter events
  // (click, form_submit, etc.) that arrive with a sessionId we've
  // never seen link to no session rather than manufacturing a ghost
  // row with no entry page or referrer.
  let dbSessionId: string | null = null;

  if (sanitizedSessionId) {
    const isSessionStarter = payload.eventType === "session_start" || payload.eventType === "page_view";

    if (isSessionStarter) {
      const meta = sanitizedMeta as Record<string, string>;
      // Server-derived context wins over anything the client sent.
      // Falls back to the client-provided user_agent in metadata so
      // callers that don't supply clientContext (tests, legacy
      // codepaths) still get the legacy behaviour.
      const ctx = payload.clientContext ?? {};
      const resolvedUserAgent =
        (ctx.userAgent && ctx.userAgent.slice(0, 500)) ||
        (meta.user_agent?.slice(0, 500)) ||
        null;
      try {
        const session = await prisma.trackedSession.upsert({
          where: { sessionId: sanitizedSessionId },
          create: {
            sessionId: sanitizedSessionId,
            trackedSiteId: siteDbId,
            entryPage: sanitizedUrl,
            referrer: sanitizedReferrer,
            utmSource: meta.utm_source ?? null,
            utmMedium: meta.utm_medium ?? null,
            utmCampaign: meta.utm_campaign ?? null,
            utmTerm: meta.utm_term ?? null,
            utmContent: meta.utm_content ?? null,
            // Ad-platform click IDs — present when the visitor landed
            // from a paid ad. Stored so the attribution service can
            // match sessions to campaign data. Only the opaque token
            // is stored (no PII).
            gclid: meta.gclid ?? null,
            fbclid: meta.fbclid ?? null,
            msclkid: meta.msclkid ?? null,
            ttclid: meta.ttclid ?? null,
            li_fat_id: meta.li_fat_id ?? null,
            dclid: meta.dclid ?? null,
            wbraid: meta.wbraid ?? null,
            gbraid: meta.gbraid ?? null,
            userAgent: resolvedUserAgent,
            device: ctx.device ?? null,
            browser: ctx.browser ?? null,
            os: ctx.os ?? null,
            ipAddress: ctx.ipAddress ?? null,
            country: ctx.country ?? null,
            city: ctx.city ?? null,
            region: ctx.region ?? null,
          },
          update: {
            lastSeenAt: new Date(),
            // Only page_view counts as a page visit. session_start
            // events land here too (it's a session-starter eventType)
            // but shouldn't inflate pageCount. Device / geo columns
            // are intentionally NOT touched on update — the first
            // observation wins, so a session that starts on mobile
            // and ends with a desktop re-poll doesn't flip device.
            pageCount: { increment: payload.eventType === "page_view" ? 1 : 0 },
          },
          select: { id: true },
        });
        dbSessionId = session.id;
        // Score recompute removed from ingest hot path -- runs in batch cron instead.
      } catch (err) {
        // Defensive only — upsert itself can't race on the unique
        // index, but a schema drift or constraint change would
        // surface here. Log and continue with no session link so
        // the event itself still gets recorded.
        console.warn("[site-tag] session upsert failed, event will be recorded without session link", {
          sessionId: sanitizedSessionId,
          err: err instanceof Error ? err.message : "unknown",
        });
      }
    } else {
      // Non-starter event — link to an existing session if one exists,
      // but don't create. Fire-and-forget the lastSeenAt update so
      // long DB latency doesn't stall the event-ingest hot path.
      const existingSession = await prisma.trackedSession.findUnique({
        where: { sessionId: sanitizedSessionId },
        select: { id: true },
      });
      if (existingSession) {
        dbSessionId = existingSession.id;
        if (SIDE_EFFECTS_ENABLED) {
          void boundedFireAndForget(
            () => prisma.$transaction(
              (tx) => tx.trackedSession.update({ where: { id: existingSession.id }, data: { lastSeenAt: new Date() } }),
              { timeout: 3000 },
            ),
            "trackedSession.lastSeenAt",
            3000,
          );
          // Score recompute removed from ingest hot path -- runs in batch cron instead.
        }
      }
    }
  }

  // ── Visibility event server-side dedup ──
  // Defense-in-depth against client-side guard misses (DOM recreation,
  // hydration, observer re-attach). Uses a tight 60-second window because
  // the dedup key is coarse: (site, session, eventType) — no element target
  // in the payload. A legitimate second scroll-into-view for a different
  // element on the same page is suppressed, but that's acceptable given
  // how rarely a visitor scrolls past two distinct phone/review widgets
  // within a minute.
  if (VISIBILITY_EVENT_TYPES.has(payload.eventType) && dbSessionId) {
    // Fail open: if the dedup query itself fails (DB timeout under load),
    // allow the insert to proceed rather than dropping the event entirely.
    // A duplicate visibility row is far less harmful than a missing event.
    try {
      const windowStart = new Date(Date.now() - 60_000);
      const existing = await prisma.trackedEvent.findFirst({
        where: {
          trackedSiteId: siteDbId,
          sessionId: dbSessionId,
          eventType: payload.eventType,
          timestamp: { gte: windowStart },
        },
        select: { id: true },
      });
      if (existing) {
        return { eventId: existing.id, sessionId: dbSessionId };
      }
    } catch (err) {
      console.warn("[site-tag] visibility dedup check failed, allowing insert", {
        eventType: payload.eventType,
        siteDbId,
        err: err instanceof Error ? err.message : "unknown",
      });
    }
  }

  // ── Create the event ──
  const enrichedMeta = enrichEventMetadata(payload.eventType, sanitizedMeta);

  const event = await prisma.trackedEvent.create({
    data: {
      trackedSiteId: siteDbId,
      eventType: payload.eventType,
      pageUrl: sanitizedUrl,
      referrer: sanitizedReferrer,
      timestamp: eventTimestamp,
      sessionId: dbSessionId,
      metadata: JSON.parse(JSON.stringify(enrichedMeta)),
      synthetic: payload.synthetic ?? false,
    },
  });

  // ── firstEventAt cache population ──
  // Fire-and-forget, once per siteDbId per warm-instance lifetime.
  // Uses a real findFirst(orderBy: asc) MIN so it always writes the
  // genuinely earliest event timestamp -- not eventTimestamp, which
  // could be wrong for sites that had events before this column
  // existed. The audit runner gates shouldFetchFirstParty on a direct
  // DB read of firstEventAt, so this must be reliably written;
  // getSiteTagInstalledAt() only backfills as a side effect of
  // analytics page loads, which may never happen before an audit.
  if (!firstEventAtCheckedThisInvocation.has(siteDbId)) {
    firstEventAtCheckedThisInvocation.add(siteDbId);
    if (SIDE_EFFECTS_ENABLED) {
      void boundedFireAndForget(
        async () => {
          const site = await prisma.trackedSite.findUnique({
            where: { id: siteDbId },
            select: { firstEventAt: true },
          });
          if (site?.firstEventAt) return;
          const first = await prisma.trackedEvent.findFirst({
            where: { trackedSiteId: siteDbId },
            orderBy: { timestamp: "asc" },
            select: { timestamp: true },
          });
          if (!first) return;
          await prisma.$transaction(
            (tx) => tx.trackedSite.updateMany({
              where: { id: siteDbId, firstEventAt: null },
              data: { firstEventAt: first.timestamp },
            }),
            { timeout: 3000 },
          );
          console.info(`[site-tag] firstEventAt backfilled for site ${siteDbId}: ${first.timestamp.toISOString()}`);
        },
        "trackedSite.firstEventAt.backfill",
        3000,
      );
    }
  }

  // ── lastEventAt update ──────────────────────────────────────────
  // Fire-and-forget, deduplicated per site per warm-instance
  // lifetime. Previously used setTimeout(fn, 10_000) as a debounce
  // but in Vercel serverless the function freezes after the
  // response and the timer never fires — so lastEventAt stayed
  // stale for minutes/hours, and the LIVE badge showed "17m ago"
  // even with fresh events. Now the update runs immediately (within
  // the same event loop tick as the event write) and the Set
  // prevents hammering the DB if multiple events arrive in the
  // same warm invocation.
  if (!lastEventUpdatedThisInvocation.has(siteDbId)) {
    lastEventUpdatedThisInvocation.add(siteDbId);
    if (SIDE_EFFECTS_ENABLED) {
      void boundedFireAndForget(
        () => prisma.$transaction(
          (tx) => tx.trackedSite.update({ where: { id: siteDbId }, data: { lastEventAt: new Date() } }),
          { timeout: 3000 },
        ),
        "trackedSite.lastEventAt",
        3000,
      );
    }
    // Always update the pulse Redis key (cheap, no Postgres connection).
    if (payload.resolvedBrandId) {
      void setCached(pulseLastEventKey(payload.resolvedBrandId), new Date().toISOString(), LAST_EVENT_REDIS_TTL_S);
    }
  }

  // ── SiteConversion side-effect ──────────────────────────────────────
  // Conversion-relevant events (form_submit, booking_submit,
  // booking_confirmed) ALSO write a SiteConversion row so the
  // dashboards + attribution pipeline pick them up. Fire-and-forget —
  // a conversion-write failure must NEVER break the event-ingest path
  // (the TrackedEvent is already persisted above). Dedup mirrors the
  // /api/site-tag/conversion 60-second window so duplicate plugin
  // detection paths (native submit + plugin AJAX intercept) don't
  // double-count.
  const conversionType = CONVERSION_EVENT_MAP[payload.eventType];
  if (conversionType && SIDE_EFFECTS_ENABLED) {
    const brandId = payload.resolvedBrandId;
    if (!brandId) {
      console.warn("[site-tag] conversion side-effect skipped — no resolvedBrandId", {
        eventType: payload.eventType,
        siteDbId,
      });
    }
    void boundedFireAndForget(
      () => writeConversionSideEffect({
        brandId,
        siteDbId,
        conversionType,
        eventType: payload.eventType,
        pageUrl: sanitizedUrl,
        sessionId: sanitizedSessionId ?? null,
        visitorId: payload.visitorId,
        metadata: enrichedMeta,
        timestamp: eventTimestamp,
        synthetic: payload.synthetic,
      }),
      "writeConversionSideEffect",
      15000,
    );
  }

  // ── AdClickEvent side-effect (paid_click_landing) ──────────────────
  // When t.js detects an ad-platform click ID in the landing URL, it
  // fires paid_click_landing with the click IDs in metadata. We
  // upsert an AdClickEvent row here so the attribution service can
  // match it to the TrackedSession + campaign data. Fire-and-forget.
  if (payload.eventType === "paid_click_landing") {
    const meta = enrichedMeta as Record<string, unknown>;
    const platform = typeof meta._platform === "string" ? meta._platform : null;
    const clickId =
      (typeof meta.gclid === "string" && meta.gclid) ||
      (typeof meta.fbclid === "string" && meta.fbclid) ||
      (typeof meta.msclkid === "string" && meta.msclkid) ||
      (typeof meta.ttclid === "string" && meta.ttclid) ||
      (typeof meta.li_fat_id === "string" && meta.li_fat_id) ||
      (typeof meta.dclid === "string" && meta.dclid) ||
      (typeof meta.wbraid === "string" && meta.wbraid) ||
      (typeof meta.gbraid === "string" && meta.gbraid) ||
      null;
    const brandId = payload.resolvedBrandId;
    if (SIDE_EFFECTS_ENABLED && platform && clickId && brandId) {
      void boundedFireAndForget(
        () => prisma.$transaction(
          (tx) => tx.adClickEvent.upsert({
            where: { brandId_clickId: { brandId, clickId } },
            create: {
              brandId,
              clickId,
              platform,
              landingPage: sanitizedUrl,
              matched: !!sanitizedSessionId,
              matchedSessionId: sanitizedSessionId ?? null,
              matchedVisitorId: null,
            },
            update: {
              matched: true,
              matchedSessionId: sanitizedSessionId ?? undefined,
            },
          }),
          { timeout: 5000 },
        ),
        "adClickEvent.upsert",
        5000,
      );
    }
  }

  // ── AI referral detection → create AiAttribution record ──
  if (SIDE_EFFECTS_ENABLED && payload.eventType === "ai_referral" && payload.resolvedBrandId) {
    const meta = enrichedMeta as Record<string, unknown>;
    void boundedFireAndForget(
      async () => {
        const { createAiAttribution } = await import("@/lib/services/ai-attribution");
        await createAiAttribution({
          brandId: payload.resolvedBrandId!,
          sessionId: sanitizedSessionId ?? "unknown",
          platform: typeof meta.platform === "string" ? meta.platform : "unknown",
          channel: typeof meta.channel === "string" ? meta.channel : "llmo",
          referrerUrl: typeof meta.referrerUrl === "string" ? meta.referrerUrl : undefined,
          queryContext: typeof meta.queryContext === "string" ? meta.queryContext : undefined,
          landingPage: typeof meta.landingPage === "string" ? meta.landingPage : payload.pageUrl,
        });
      },
      "createAiAttribution.aiReferral",
      5000,
    );
  }

  // ── Redundant Google AI Overview detection on session_start ──
  // The client-side ai_referral block above handles the primary path,
  // but if it misfires (JS error, blocked, race with nav) we still get
  // a session_start with the landingTextFragment field. Fragments
  // don't cross HTTP naturally — t.js extracts and forwards them
  // explicitly. A google.com referrer + text fragment is a near-
  // definitive signal of a Google AI Overview click.
  if (
    SIDE_EFFECTS_ENABLED &&
    payload.eventType === "session_start" &&
    payload.resolvedBrandId &&
    sanitizedReferrer &&
    /(^|\.)google\.[a-z.]+/.test(sanitizedReferrer.toLowerCase())
  ) {
    const meta = enrichedMeta as Record<string, unknown>;
    const fragment = typeof meta.landingTextFragment === "string" ? meta.landingTextFragment : null;
    if (fragment) {
      const existing = await prisma.aiAttribution.findFirst({
        where: { brandId: payload.resolvedBrandId, sessionId: sanitizedSessionId ?? "unknown" },
        select: { id: true },
      }).catch(() => null);
      if (!existing) {
        let queryContext: string | null = null;
        try {
          queryContext = decodeURIComponent(fragment.replace(/\+/g, " ")).slice(0, 500);
        } catch {
          queryContext = fragment.slice(0, 500);
        }
        void boundedFireAndForget(
          async () => {
            const { createAiAttribution } = await import("@/lib/services/ai-attribution");
            await createAiAttribution({
              brandId: payload.resolvedBrandId!,
              sessionId: sanitizedSessionId ?? "unknown",
              platform: "google_ai_overview",
              channel: "geo",
              referrerUrl: sanitizedReferrer ?? undefined,
              queryContext: queryContext ?? undefined,
              landingPage: payload.pageUrl,
            });
          },
          "createAiAttribution.googleAiOverview",
          5000,
        );
      }
    }
  }

  // ── Auto-extract services on first real event ──────────────────────
  // Fire-and-forget — must never block event ingestion. Skip synthetic
  // events (test harness) so they don't trigger extraction.
  if (SIDE_EFFECTS_ENABLED && !payload.synthetic && payload.resolvedBrandId) {
    void boundedFireAndForget(
      () => triggerServiceExtractionIfNeeded(payload.resolvedBrandId!),
      "triggerServiceExtraction",
      20000,
    );
  }

  return { eventId: event.id, sessionId: sanitizedSessionId ?? null };
}

// ─── Service extraction trigger ──────────────────────────────────────
//
// Called fire-and-forget from recordEvent. Checks whether the brand
// Recomputes the predictive conversion score for a single session.
// Called fire-and-forget from the event-ingest path — must never throw.
async function recomputeSessionScore(sessionDbId: string): Promise<void> {
  await prisma.$transaction(async (tx) => {
    const session = await tx.trackedSession.findUnique({
      where: { id: sessionDbId },
    });
    if (!session) return;

    const [recentEvents, heatmapEvents] = await Promise.all([
      tx.siteEvent.findMany({
        where: { sessionId: session.sessionId },
        select: { eventType: true, timestamp: true },
        orderBy: { timestamp: "desc" },
        take: 50,
      }),
      tx.heatmapEvent.findMany({
        where: { sessionId: session.sessionId },
        select: { pageUrl: true, timeOnPage: true, maxScrollPercent: true },
        take: 20,
      }),
    ]);

    const pageVisits = heatmapEvents.map((h) => ({
      pageUrl: h.pageUrl,
      timeOnPage: h.timeOnPage ?? undefined,
      maxScrollPercent: h.maxScrollPercent ?? undefined,
    }));

    const { score, contributingFactors } = scoreSession(
      session,
      recentEvents as Parameters<typeof scoreSession>[1],
      pageVisits,
    );

    await tx.trackedSession.update({
      where: { id: sessionDbId },
      data: {
        predictedConversionScore: score,
        scoreLastComputedAt: new Date(),
        scoreContributingFactors: contributingFactors as object[],
      },
    });
  }, { timeout: 5000, maxWait: 1000 });
}

// still needs service extraction, runs it if so, and marks it done.
// Errors bubble up to the .catch() in the caller — never to the
// event-ingest response.

async function triggerServiceExtractionIfNeeded(brandId: string): Promise<void> {
  const brand = await prisma.brand.findUnique({
    where: { id: brandId },
    select: {
      id: true,
      domain: true,
      primaryServices: true,
      secondaryServices: true,
      servicesExtractedAt: true,
    },
  });

  if (!brand) return;

  // Already extracted — never run again
  if (brand.servicesExtractedAt) return;

  // Brand has manually entered services — mark as extracted (don't overwrite) and skip
  if (
    (brand.primaryServices && brand.primaryServices.length > 0) ||
    (brand.secondaryServices && brand.secondaryServices.length > 0)
  ) {
    await prisma.$transaction(
      (tx) => tx.brand.update({ where: { id: brandId }, data: { servicesExtractedAt: new Date() } }),
      { timeout: 3000 },
    );
    return;
  }

  if (!brand.domain) return;

  // External I/O (fetch + Claude API) — cannot be inside a $transaction.
  // Runs outside; only the final write is transaction-bounded.
  const result = await extractBrandServices(brand.domain);

  if (!result) {
    // Leave servicesExtractedAt null — next event will retry
    console.warn("[site-tag] extractBrandServices returned null for brand", brandId);
    return;
  }

  await prisma.$transaction(
    (tx) => tx.brand.update({
      where: { id: brandId },
      data: {
        primaryServices: result.primaryServices,
        secondaryServices: result.secondaryServices,
        servicesExtractedAt: new Date(),
      },
    }),
    { timeout: 3000 },
  );

  console.log("[site-tag] auto-extracted services for brand", brandId, {
    primary: result.primaryServices.length,
    secondary: result.secondaryServices.length,
  });
}

// ─── SiteConversion side-effect writer ───────────────────────────────
//
// Called fire-and-forget from recordEvent for conversion-relevant
// events. Resolves brandId via siteDbId when not provided. Applies
// the same 60-second per-(session, page, conversionType) dedup gate
// the dedicated /api/site-tag/conversion endpoint uses, so a brand
// whose forms fire BOTH a native submit and a plugin AJAX intercept
// (Gravity Forms, WPForms) doesn't get a double-counted conversion.

interface ConversionSideEffectArgs {
  visitorId?: string;
  brandId?: string;
  siteDbId: string;
  conversionType: string;
  eventType: string;
  pageUrl: string;
  sessionId: string | null;
  metadata: Record<string, unknown>;
  timestamp: Date;
  synthetic?: boolean;
}

async function writeConversionSideEffect(args: ConversionSideEffectArgs): Promise<void> {
  // Resolve brandId outside the transaction (read-only, idempotent).
  let brandId = args.brandId;
  if (!brandId) {
    const site = await prisma.trackedSite.findUnique({
      where: { id: args.siteDbId },
      select: { brandId: true },
    });
    if (!site) return;
    brandId = site.brandId;
  }

  const sessionKey = args.sessionId ?? "anon";
  const meta = args.metadata as Record<string, unknown>;
  // Prefer top-level args.visitorId (from collect body.visitor_id, always present after t.js fix),
  // fall back to metadata.visitor_id (intermediate period), then sessionId as last resort.
  const visitorKey = args.visitorId
    ?? (typeof meta.visitor_id === "string" && meta.visitor_id ? meta.visitor_id : null)
    ?? sessionKey;
  const sourceTag = (typeof meta.source === "string" && meta.source ? meta.source : null) ?? "native";
  const originalEvent = (typeof meta.original_event === "string" ? meta.original_event : null) ?? args.eventType;
  const submissionId = (typeof meta.submissionId === "string" && meta.submissionId) ? meta.submissionId : null;
  const successConfirmed = meta.successConfirmed === true;

  // ── Atomic dedup + write under a single server-enforced timeout ──
  // resolveConversion receives tx so its reads/writes share the same
  // connection and transaction boundary. The nested AI attribution
  // calls (#10, #11) run AFTER the transaction commits on their own
  // boundedFireAndForget wrappers.
  const created = await prisma.$transaction(async (tx) => {
    const action = await resolveConversion({
      brandId: brandId!,
      sessionId: sessionKey,
      visitorId: visitorKey,
      pageUrl: args.pageUrl,
      rawType: args.conversionType,
      source: sourceTag,
      originalEvent,
      timestamp: args.timestamp,
      submissionId,
      successConfirmed,
    }, tx);

    if (action.kind !== "create") {
      // skip = duplicate / lower_tier / lower_authority / unknown_type.
      // upgrade = resolver mutated the existing row in place.
      // Either way, no insert needed.
      return null;
    }

    // on SiteConversion — formFields is the free-form Json column we
    // have. Underscore-prefixed keys avoid colliding with real
    // field-name entries written by form-phone.js.
    const formId = (typeof meta.form_id === "string" ? meta.form_id : null);
    const formProvider = sourceTag;
    // Normalize phone to digits-only so findPhoneDuplicateId can match across
    // both write paths (click_to_call sends phone_number; form-phone.js sends
    // phoneNumber through the /conversion route). phone_normalized is kept as
    // a fallback for any caller that already ran normalization client-side.
    const phoneRaw = typeof meta.phone_number === "string" ? meta.phone_number
      : typeof meta.phone_normalized === "string" ? meta.phone_normalized : null;
    const phoneNumber = phoneRaw ? (phoneRaw.replace(/\D/g, "") || null) : null;

    // Phone dedup: for phone_call rows, check whether the same visitor
    // already has a phone_call row for the same number within 60s. Both
    // write paths (t.source.js->event and form-phone.js->conversion) fire
    // for every tel: click, so the second row gets isDuplicateOf set rather
    // than being dropped -- it stays recoverable while the display layer
    // excludes it. Uses visitorId (shared via localStorage) not sessionId
    // (different sessionStorage keys per path).
    let phoneDuplicateOf: string | null = null;
    if (args.conversionType === "phone_call" && phoneNumber) {
      phoneDuplicateOf = await findPhoneDuplicateId(brandId!, visitorKey, phoneNumber, args.timestamp, tx);
    }

    const sourceDetail: Record<string, unknown> = {
      _source: sourceTag,
      _originalEvent: originalEvent,
      _tier: action.tier,
      _eventType: args.eventType,
    };
    if (typeof meta.value === "number" || typeof meta.value === "string") sourceDetail._value = meta.value;
    if (typeof meta.currency === "string") sourceDetail._currency = meta.currency;

    let conversionValue: number | null = null;
    if (typeof meta.value === "number" && Number.isFinite(meta.value) && meta.value >= 0) {
      conversionValue = meta.value;
    } else if (typeof meta.value === "string") {
      const parsed = Number.parseFloat(meta.value);
      if (Number.isFinite(parsed) && parsed >= 0) conversionValue = parsed;
    }
    let currency: string | null = null;
    if (typeof meta.currency === "string" && /^[A-Za-z]{3}$/.test(meta.currency.trim())) {
      currency = meta.currency.trim().toUpperCase();
    }

    const created = await tx.siteConversion.create({
      data: {
        brandId: brandId!,
        visitorId: visitorKey,
        sessionId: sessionKey,
        conversionType: action.conversionType,
        pageUrl: args.pageUrl.slice(0, 2048),
        formId,
        formProvider,
        formFields: sourceDetail as object,
        detectionMethod: `t.js:${args.eventType}`,
        phoneNumber,
        conversionValue,
        currency,
        synthetic: args.synthetic ?? false,
        timestamp: args.timestamp,
        submissionId,
        isDuplicateOf: phoneDuplicateOf ?? action.duplicateOf ?? null,
        invalidatedReason: action.invalidatedReason ?? null,
        successConfirmed: action.successConfirmed ?? false,
      },
    });
    return { id: created.id, conversionType: action.conversionType };
  }, { timeout: 15000, maxWait: 1500 });

  if (!created) return;

  // Post-transaction: AI attribution side-effects run independently
  // so they don't hold the conversion write's connection open.
  void boundedFireAndForget(
    async () => {
      const { checkMultiSessionAiTouch } = await import("@/lib/services/ai-multi-session");
      await checkMultiSessionAiTouch(brandId!, visitorKey, sessionKey);
    },
    "checkMultiSessionAiTouch",
    5000,
  );

  void boundedFireAndForget(
    async () => {
      const { attributeConversion } = await import("@/lib/services/ai-attribution");
      await attributeConversion({
        brandId: brandId!,
        sessionId: sessionKey,
        conversionType: created.conversionType,
        conversionPage: args.pageUrl.slice(0, 2048),
        conversionId: created.id,
      });
    },
    "attributeConversion",
    5000,
  );
}

// ─── Get recent events for a site ───────────────────────────────────────────

export async function getRecentEvents(brandId: string, limit: number = 10) {
  const site = await prisma.trackedSite.findUnique({ where: { brandId } });
  if (!site) return [];

  const events = await prisma.trackedEvent.findMany({
    where: { trackedSiteId: site.id },
    orderBy: { timestamp: "desc" },
    take: limit,
    select: {
      id: true,
      eventType: true,
      pageUrl: true,
      referrer: true,
      timestamp: true,
      metadata: true,
      synthetic: true,
    },
  });

  // Enrich with source + status from SourceEvent/CanonicalEvent if available
  const enriched = await Promise.all(
    events.map(async (e) => {
      // Find matching source event to get source system
      const sourceEvent = await prisma.sourceEvent.findFirst({
        where: {
          trackedSiteId: site.id,
          eventType: e.eventType,
          timestamp: e.timestamp,
        },
        select: { source: true, canonicalEvent: { select: { status: true } } },
      }).catch(() => null);

      return {
        ...e,
        source: sourceEvent?.source ?? "site_tag",
        status: sourceEvent?.canonicalEvent?.status ?? "recorded",
      };
    })
  );

  return enriched;
}

// ─── Event metadata enrichment ───────────────────────────────────────────────

/**
 * Adds normalized fields to the metadata based on event type.
 * This ensures consistent data shape regardless of what the tracker sends.
 *
 * What each event type expects in metadata from t.js:
 *   page_view:      { title }
 *   session_start:  { utm_source, utm_medium, utm_campaign, user_agent }
 *   click_to_call:  { phone_number, link_text, link_url }
 *   outbound_click: { link_url, link_text, link_domain }
 *   form_start:     { form_id, form_action, field_name }
 *   form_submit:    { form_id, form_action, form_method }
 *   sms_submit:     { phone_number, link_text, link_url }
 */
function enrichEventMetadata(
  eventType: string,
  raw: Record<string, unknown>
): Record<string, unknown> {
  const meta = { ...raw };

  switch (eventType) {
    case "click_to_call":
      // Normalize the phone number (strip non-digit chars for consistency)
      if (typeof meta.phone_number === "string") {
        meta.phone_normalized = (meta.phone_number as string).replace(/[^\d+]/g, "");
      }
      meta.conversion_type = "phone_call";
      break;

    case "sms_submit":
      if (typeof meta.phone_number === "string") {
        meta.phone_normalized = (meta.phone_number as string).replace(/[^\d+]/g, "");
      }
      meta.conversion_type = "sms";
      break;

    case "outbound_click":
      // Extract the domain from the link URL
      if (typeof meta.link_url === "string") {
        try {
          meta.link_domain = new URL(meta.link_url as string).hostname;
        } catch { /* keep whatever was sent */ }
      }
      break;

    case "form_submit":
      meta.conversion_type = "form_submission";
      break;

    case "form_start":
      meta.engagement_type = "form_interaction";
      break;

    case "appointment_intent":
      meta.engagement_type = "appointment_intent";
      break;

    case "form_intent":
      meta.engagement_type = "form_intent";
      break;

    case "booking_widget_opened":
      meta.engagement_type = "booking_widget_opened";
      break;

    case "booking_step":
      meta.engagement_type = "booking_step";
      break;

    case "booking_submit":
      meta.conversion_type = "booking_attempt";
      break;

    case "booking_confirmed":
      meta.conversion_type = "booking_confirmed";
      break;

    case "plugin_detected":
      meta.engagement_type = "plugin_detected";
      break;

    case "form_iframe_present":
      meta.engagement_type = "form_iframe_present";
      break;

    case "page_view":
      meta.engagement_type = "pageview";
      break;

    case "session_start":
      meta.engagement_type = "new_session";
      break;

    case "paid_click_landing":
      meta.engagement_type = "paid_click_landing";
      break;
  }

  return meta;
}

// ─── Snippet generator ──────────────────────────────────────────────────────

/**
 * Generates the HTML snippet a user pastes into their website's <head>.
 */
// ─── Data maturity / install-age helpers ────────────────────────────────────

/**
 * Derive when the Site Tag actually started **capturing data**, which is
 * more honest than `TrackedSite.createdAt`. The row can exist for days
 * before the user finishes installing the snippet, so using `createdAt`
 * falsely inflates the "days of data collected" number shown in the UI.
 *
 * Strategy:
 *   1. Earliest TrackedEvent (page_view / session_start / custom) for the
 *      brand's TrackedSite — this is the primary event stream and fires
 *      on every page load, so it's the most accurate signal.
 *   2. Fallback to earliest SiteEvent (behavioral anomalies like rage_click)
 *      — covers the unlikely case where only UX events have fired.
 *   3. Fallback to `TrackedSite.createdAt` when no events exist yet (the
 *      snippet has never successfully phoned home).
 *
 * Returns `null` when no TrackedSite exists for the brand at all.
 */
export async function getSiteTagInstalledAt(brandId: string): Promise<Date | null> {
  if (!prisma.trackedSite) return null;
  const site = await prisma.trackedSite.findUnique({
    where: { brandId },
    select: { id: true, createdAt: true, firstEventAt: true },
  });
  if (!site) return null;

  // Phase 19A.8 backfill corrected all stale firstEventAt values across
  // every brand. The per-call verification findFirst (unbounded sort on
  // 1M+ rows) added 100-500ms to every request on high-volume brands.
  // Trust the cached column directly.
  if (site.firstEventAt) {
    return site.firstEventAt;
  }

  // The "earliest event" lookups are unbounded findFirst sorts on
  // tables that grow into the millions of rows. Even with the
  // (trackedSiteId, timestamp) index, a cache-cold sort can take
  // 10s+ — observed hanging the Site Tag Analytics route past its
  // 12s deadline. Bound each lookup at 1.5s and fall back to
  // TrackedSite.createdAt; for the data-maturity badge ("X days
  // collecting data") an exact first-event timestamp doesn't
  // matter — being off by a day is acceptable.
  const FIRST_EVENT_TIMEOUT_MS = 1_500;
  const t = Date.now();
  const raceFirst = <T>(p: Promise<T>) => Promise.race([
    p,
    new Promise<null>((resolve) => setTimeout(() => resolve(null), FIRST_EVENT_TIMEOUT_MS)),
  ]);

  const [firstTracked, firstSiteEvent] = await Promise.all([
    raceFirst(prisma.trackedEvent.findFirst({
      where: { trackedSiteId: site.id },
      orderBy: { timestamp: "asc" },
      select: { timestamp: true },
    }).catch(() => null)),
    raceFirst(prisma.siteEvent.findFirst({
      where: { brandId },
      orderBy: { timestamp: "asc" },
      select: { timestamp: true },
    }).catch(() => null)),
  ]);
  console.info(`[site-tag:perf] getSiteTagInstalledAt first-event lookups ${Date.now() - t}ms`, { brandId, hasFirstTracked: !!firstTracked, hasFirstSiteEvent: !!firstSiteEvent });

  const candidates: Date[] = [];
  if (firstTracked?.timestamp) candidates.push(firstTracked.timestamp);
  if (firstSiteEvent?.timestamp) candidates.push(firstSiteEvent.timestamp);
  // No events captured yet for this brand. Returning site.createdAt
  // here used to backdate the "Site Tag has been capturing data
  // since X (N days)" banner to the TrackedSite row-creation date —
  // typically when the brand was set up on the platform, days or
  // weeks before the tag was actually deployed. Return null so the
  // caller can render a "Site Tag deployed X ago — waiting for
  // first event" state instead, using site.createdAt as the deploy
  // date via a separate response field. Avoids the misleading
  // back-dated banner observed on Fairway Lawns (showed "since Apr 16
  // (19 days)" when the tag was actually installed today).
  if (candidates.length === 0) return null;
  // Earliest wins — whichever event stream fired first is when data collection
  // actually began, regardless of how the row in TrackedSite got there.
  const resolved = new Date(Math.min(...candidates.map((d) => d.getTime())));
  // Backfill the cache so the next call returns via the fast path.
  // updateMany with firstEventAt:null guard makes this idempotent
  // — concurrent calls can't overwrite each other. Fire-and-forget.
  void boundedFireAndForget(
    () => prisma.$transaction(
      (tx) => tx.trackedSite.updateMany({ where: { id: site.id, firstEventAt: null }, data: { firstEventAt: resolved } }),
      { timeout: 3000 },
    ),
    "trackedSite.firstEventAt.backfill",
    3000,
  );
  return resolved;
}

/**
 * Age in days since the Site Tag started capturing data. Clamps to 0 when
 * the computed date is in the future (clock skew).
 */
export function siteTagAgeDays(installedAt: Date | null | undefined): number {
  if (!installedAt) return 0;
  const diffMs = Date.now() - installedAt.getTime();
  return Math.max(0, Math.floor(diffMs / 86_400_000));
}

// Minimum page-views per day to count as a real capture day.
// Excludes single stray test fires (e.g. GTM preview) that set
// firstEventAt weeks before the tag was genuinely deployed.
const MIN_PAGEVIEWS_PER_DAY = 5;

/**
 * Site Tag maturity based on distinct days of meaningful capture,
 * read from SiteTagDailyRollup. A day qualifies when pageViews >=
 * MIN_PAGEVIEWS_PER_DAY. This excludes stray early test events that
 * would inflate the "capturing since" age shown in the banner.
 *
 * Returns activeDays (count of qualifying days) and captureStart
 * (earliest qualifying day). Falls back to { activeDays: 0,
 * captureStart: null } on any error so callers degrade gracefully.
 */
export async function getSiteTagMaturity(
  brandId: string,
): Promise<{ activeDays: number; captureStart: Date | null }> {
  try {
    const rows = await prisma.siteTagDailyRollup.findMany({
      where: { brandId, pageViews: { gte: MIN_PAGEVIEWS_PER_DAY } },
      select: { date: true },
      orderBy: { date: "asc" },
    });
    if (rows.length === 0) {
      return { activeDays: 0, captureStart: null };
    }
    return { activeDays: rows.length, captureStart: rows[0].date };
  } catch (e) {
    console.warn(
      `[site-tag] getSiteTagMaturity failed for ${brandId}, returning 0`,
      e instanceof Error ? e.message : e,
    );
    return { activeDays: 0, captureStart: null };
  }
}

export function generateSnippet(siteId: string): string {
  const appUrl = process.env.NEXT_PUBLIC_APP_URL
    || (process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : "https://app.meseoapp.com");
  const v = process.env.NEXT_PUBLIC_TAG_VERSION || "2";
  const scriptUrl = `${appUrl}/t.js?v=${v}`;

  return `<!-- meSEO Site Tag -->
<script>
(function(w,d,s,id){
  w.meSEOConfig={siteId:"${siteId}",appUrl:"${appUrl}"};
  var f=d.getElementsByTagName(s)[0],
      j=d.createElement(s);
  j.async=true;
  j.src="${scriptUrl}";
  f.parentNode.insertBefore(j,f);
})(window,document,"script","meseo-tag");
</script>
<!-- End meSEO Site Tag -->`;
}

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

function formatSiteInfo(site: {
  id: string;
  siteId: string;
  status: string;
  allowedDomains: string[];
  lastEventAt: Date | null;
  createdAt: Date;
}): TrackedSiteInfo {
  return {
    id: site.id,
    siteId: site.siteId,
    status: site.status,
    allowedDomains: site.allowedDomains,
    lastEventAt: site.lastEventAt,
    createdAt: site.createdAt,
    snippet: generateSnippet(site.siteId),
  };
}

/** Simple cuid-like generator (timestamp + random) */
function generateCuid(): string {
  const time = Date.now().toString(36);
  const rand = randomBytes(8).toString("hex").slice(0, 8);
  return time + rand;
}
