/**
 * Analysis Engine
 * ─────────────────
 * Orchestrates data from multiple sources, runs analysis logic,
 * and produces a structured Analysis object.
 *
 * Template: Seasonal Performance / YoY Anomaly Analysis
 */

import { prisma } from "@/lib/prisma";
import { getConnectedSources } from "./analytics-adapter";
import { runResearch, buildResearchTopics } from "./research-service";
import { getIndustryBenchmarks } from "./benchmark-engine";
import { getRecommendationSuccessRates } from "./outcome-tracker";
import { getEnrichmentContext, formatEnrichmentForPrompt } from "./enrichment-context";
import { getMemoryContext } from "./client-memory";
import { buildAnalysisDataPackage } from "./analysis-data-package";
import { runAiAnalysis } from "./ai-analysis-runner";
import type {
  Analysis, AnalysisBranding, AnalysisSource, KpiItem, Finding, ChartSpec, TableSpec,
  TimelineEvent, Prediction, Caveat, AnalysisRecommendation,
  ResearchContext, ResearchEvidence, CitationRef,
} from "./analysis-schema";

export interface AnalysisJobInput {
  brandId: string;
  period: { start: string; end: string };
  prompt?: string;
  manualCaveats?: string[];
  uploadedNotes?: string[];
  enableResearch?: boolean;
  industry?: string;
}

// ─── Run Analysis ───────────────────────────────────────────────────────────

export async function runSeasonalAnalysis(input: AnalysisJobInput): Promise<Analysis> {
  const { brandId, period, prompt, manualCaveats, uploadedNotes, enableResearch = true, industry } = input;
  const id = `analysis_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;

  // Fetch brand + profile for branding metadata
  const brand = await prisma.brand.findUnique({
    where: { id: brandId },
    select: { domain: true, name: true, profile: { select: { logoUrl: true, companyName: true } } },
  });
  if (!brand) throw new Error("Brand not found");

  // Build branding from brand profile / AI Website Intake
  const branding: AnalysisBranding = {
    appBrand: "meSEO",
    clientName: brand.profile?.companyName ?? brand.name,
    clientDomain: brand.domain,
    clientLogoUrl: brand.profile?.logoUrl ?? null,
    clientLogoSource: brand.profile?.logoUrl ? "brand_profile" : "none",
    reportDate: new Date().toISOString().split("T")[0],
    artifactTheme: "default",
    artifactHeaderMeta: {
      generatedBy: "meSEO AI Strategist",
      analysisType: "Seasonal Performance",
      periodLabel: `${period.start} to ${period.end}`,
    },
  };

  // Collect data from all available sources
  const sources: AnalysisSource[] = [];
  const connectedSources = await getConnectedSources(brandId);
  const startDate = new Date(period.start);
  const endDate = new Date(period.end);
  const daySpan = Math.round((endDate.getTime() - startDate.getTime()) / 86400000);

  // Over-fetch GSC data by 3 days to compensate for GSC's 2-3 day processing delay
  const gscStartDate = new Date(startDate.getTime() - 3 * 86400000);

  // ── GSC Data (prefer GscPage aggregation over MetricSnapshot for accuracy) ──
  type GscPoint = { date: string; clicks: number; impressions: number; position: number; ctr: number };
  let gscData: GscPoint[] = [];
  if (connectedSources.gsc) {
    // Primary: aggregate from GscPage (same source as top pages, accurate per-page data)
    const gscPageDaily = await prisma.gscPage.groupBy({
      by: ["date"],
      where: { brandId, date: { gte: gscStartDate, lte: endDate } },
      _sum: { clicks: true, impressions: true },
      _avg: { position: true, ctr: true },
      orderBy: { date: "asc" },
    });

    console.log(`[analysis] GscPage daily rows: ${gscPageDaily.length}, range: ${startDate.toISOString().split("T")[0]} to ${endDate.toISOString().split("T")[0]}`);
    if (gscPageDaily.length > 0) {
      console.log(`[analysis] GscPage total clicks: ${gscPageDaily.reduce((s, d) => s + (d._sum.clicks ?? 0), 0)}`);
      console.log(`[analysis] GscPage first row:`, JSON.stringify(gscPageDaily[0]));
    }

    if (gscPageDaily.length > 1) {
      gscData = gscPageDaily.map(d => {
        const dateStr = d.date instanceof Date ? d.date.toISOString().split("T")[0] : String(d.date).split("T")[0];
        return {
          date: dateStr,
          clicks: d._sum.clicks ?? 0,
          impressions: d._sum.impressions ?? 0,
          position: d._avg.position ?? 0,
          ctr: d._avg.ctr ?? 0,
        };
      });
    } else {
      // GscPage has ≤1 date — try MetricSnapshot for daily granularity
      const snapshots = await prisma.metricSnapshot.findMany({
        where: { brandId, source: "gsc", date: { gte: gscStartDate, lte: endDate } },
        orderBy: { date: "asc" },
      });
      console.log(`[analysis] MetricSnapshot GSC fallback: ${snapshots.length} rows`);
      if (snapshots.length > 1) {
        gscData = snapshots.map(s => ({
          date: s.date.toISOString().split("T")[0],
          clicks: s.organicClicks,
          impressions: s.impressions,
          position: s.avgPosition,
          ctr: s.ctr,
        }));
      } else if (gscPageDaily.length === 1) {
        // Truly single point — use it but log for debugging
        const d = gscPageDaily[0];
        console.log(`[analysis] Single GSC date point: ${JSON.stringify(d)}`);
        gscData = [{
          date: d.date instanceof Date ? d.date.toISOString().split("T")[0] : String(d.date).split("T")[0],
          clicks: d._sum.clicks ?? 0,
          impressions: d._sum.impressions ?? 0,
          position: d._avg.position ?? 0,
          ctr: d._avg.ctr ?? 0,
        }];
      }
    }
    sources.push({
      id: "gsc", type: "gsc", label: "Google Search Console",
      status: gscData.length > 0 ? "available" : "partial",
      recordCount: gscData.length,
      dateRange: gscData.length > 0 ? { start: gscData[0].date, end: gscData[gscData.length - 1].date } : undefined,
    });
  } else {
    sources.push({ id: "gsc", type: "gsc", label: "Google Search Console", status: "missing" });
  }

  // ── GA4 Data (with engagement metrics) ──
  type Ga4Point = {
    date: string; sessions: number; users: number; conversions: number; revenue: number;
    pageviews: number; bounceRate: number; avgSessionSec: number;
  };
  let ga4Data: Ga4Point[] = [];
  if (connectedSources.ga4) {
    const snapshots = await prisma.metricSnapshot.findMany({
      where: { brandId, source: "ga4", date: { gte: startDate, lte: endDate } },
      orderBy: { date: "asc" },
    });
    ga4Data = snapshots.map(s => ({
      date: s.date.toISOString().split("T")[0],
      sessions: s.sessions,
      users: s.users,
      conversions: s.conversions,
      revenue: s.revenue,
      pageviews: s.pageviews,
      bounceRate: s.bounceRate,
      avgSessionSec: s.avgSessionSec,
    }));
    sources.push({
      id: "ga4", type: "ga4", label: "Google Analytics 4",
      status: ga4Data.length > 0 ? "available" : "partial",
      recordCount: ga4Data.length,
    });
  } else {
    sources.push({ id: "ga4", type: "ga4", label: "Google Analytics 4", status: "missing" });
  }

  // ── Technical Audit Data ──
  const latestAudit = await prisma.technicalAudit.findFirst({
    where: { brandId, status: "completed" },
    orderBy: { createdAt: "desc" },
    select: { crawlHealth: true, errorCount: true, warningCount: true, pagesScanned: true, completedAt: true },
  });
  sources.push({
    id: "audit", type: "technical_audit", label: "Technical Audit",
    status: latestAudit ? "available" : "missing",
    recordCount: latestAudit ? 1 : 0,
  });

  // ── meSEO Site Tag ──
  const siteTagRecord = await prisma.trackedSite.findUnique({ where: { brandId }, select: { id: true } }).catch(() => null);
  if (siteTagRecord) {
    // intentionally unfiltered: AI analysis context; broader data is intentional
    const stConversions = await prisma.siteConversion.count({ where: { brandId, timestamp: { gte: startDate, lte: endDate } } }).catch(() => 0);
    sources.push({ id: "site_tag", type: "site_tag", label: "meSEO Site Tag", status: "available", recordCount: stConversions });
  } else {
    sources.push({ id: "site_tag", type: "site_tag", label: "meSEO Site Tag", status: "missing", recordCount: 0 });
  }

  // ── AI Attribution (LLMO) ──
  const aiAttrCount = await prisma.aiAttribution.count({ where: { brandId, timestamp: { gte: startDate, lte: endDate } } }).catch(() => 0);
  sources.push({ id: "ai_attribution", type: "ai_attribution", label: "AI Attribution (LLMO)", status: aiAttrCount > 0 ? "available" : "missing", recordCount: aiAttrCount });

  // ── Comparison period (previous equivalent period) ──
  const compStart = new Date(startDate.getTime() - daySpan * 86400000);
  const compEnd = new Date(startDate.getTime() - 86400000);

  let prevGscData: GscPoint[] = [];
  let prevGa4Data: Ga4Point[] = [];
  if (connectedSources.gsc) {
    // Primary: GscPage aggregation for previous period
    const prevPageDaily = await prisma.gscPage.groupBy({
      by: ["date"],
      where: { brandId, date: { gte: compStart, lte: compEnd } },
      _sum: { clicks: true, impressions: true },
      _avg: { position: true, ctr: true },
      orderBy: { date: "asc" },
    });
    if (prevPageDaily.length > 0) {
      prevGscData = prevPageDaily.map(d => ({
        date: d.date.toISOString().split("T")[0], clicks: d._sum.clicks ?? 0,
        impressions: d._sum.impressions ?? 0, position: d._avg.position ?? 0, ctr: d._avg.ctr ?? 0,
      }));
    } else {
      const prev = await prisma.metricSnapshot.findMany({
        where: { brandId, source: "gsc", date: { gte: compStart, lte: compEnd } },
        orderBy: { date: "asc" },
      });
      prevGscData = prev.map(s => ({
        date: s.date.toISOString().split("T")[0], clicks: s.organicClicks,
        impressions: s.impressions, position: s.avgPosition, ctr: s.ctr,
      }));
    }
  }
  if (connectedSources.ga4) {
    const prev = await prisma.metricSnapshot.findMany({
      where: { brandId, source: "ga4", date: { gte: compStart, lte: compEnd } },
      orderBy: { date: "asc" },
    });
    prevGa4Data = prev.map(s => ({
      date: s.date.toISOString().split("T")[0], sessions: s.sessions,
      users: s.users, conversions: s.conversions, revenue: s.revenue,
      pageviews: s.pageviews, bounceRate: s.bounceRate, avgSessionSec: s.avgSessionSec,
    }));
  }

  // ── Year-over-year comparison (same period, one year ago) ──
  const yoyStart = new Date(startDate); yoyStart.setFullYear(yoyStart.getFullYear() - 1);
  const yoyEnd = new Date(endDate); yoyEnd.setFullYear(yoyEnd.getFullYear() - 1);
  let yoyGscData: GscPoint[] = [];
  let yoyGa4Data: Ga4Point[] = [];
  if (connectedSources.gsc) {
    const yoy = await prisma.metricSnapshot.findMany({
      where: { brandId, source: "gsc", date: { gte: yoyStart, lte: yoyEnd } },
      orderBy: { date: "asc" },
    });
    yoyGscData = yoy.map(s => ({
      date: s.date.toISOString().split("T")[0], clicks: s.organicClicks,
      impressions: s.impressions, position: s.avgPosition, ctr: s.ctr,
    }));
  }
  if (connectedSources.ga4) {
    const yoy = await prisma.metricSnapshot.findMany({
      where: { brandId, source: "ga4", date: { gte: yoyStart, lte: yoyEnd } },
      orderBy: { date: "asc" },
    });
    yoyGa4Data = yoy.map(s => ({
      date: s.date.toISOString().split("T")[0], sessions: s.sessions,
      users: s.users, conversions: s.conversions, revenue: s.revenue,
      pageviews: s.pageviews, bounceRate: s.bounceRate, avgSessionSec: s.avgSessionSec,
    }));
  }

  // ── Top pages from GSC (for content findings + top-pages chart) ──
  type GscPageRow = { pageUrl: string; clicks: number; impressions: number; ctr: number; position: number };
  let topPages: GscPageRow[] = [];
  let prevTopPages: GscPageRow[] = [];
  if (connectedSources.gsc) {
    const pageRows = await prisma.gscPage.findMany({
      where: { brandId, date: { gte: gscStartDate, lte: endDate } },
      orderBy: { clicks: "desc" },
    });
    // Aggregate by pageUrl
    const pageMap = new Map<string, GscPageRow>();
    for (const r of pageRows) {
      const existing = pageMap.get(r.pageUrl);
      if (existing) {
        existing.clicks += r.clicks;
        existing.impressions += r.impressions;
        existing.position = (existing.position + r.position) / 2;
        existing.ctr = existing.impressions > 0 ? existing.clicks / existing.impressions : 0;
      } else {
        pageMap.set(r.pageUrl, { pageUrl: r.pageUrl, clicks: r.clicks, impressions: r.impressions, ctr: r.ctr, position: r.position });
      }
    }
    topPages = [...pageMap.values()].sort((a, b) => b.clicks - a.clicks).slice(0, 10);

    // Previous period top pages for change indicators
    const prevPageRows = await prisma.gscPage.findMany({
      where: { brandId, date: { gte: compStart, lte: compEnd } },
    });
    const prevMap = new Map<string, GscPageRow>();
    for (const r of prevPageRows) {
      const existing = prevMap.get(r.pageUrl);
      if (existing) {
        existing.clicks += r.clicks;
        existing.impressions += r.impressions;
      } else {
        prevMap.set(r.pageUrl, { pageUrl: r.pageUrl, clicks: r.clicks, impressions: r.impressions, ctr: r.ctr, position: r.position });
      }
    }
    prevTopPages = [...prevMap.values()];
  }

  // ── External Research ──
  let research: ResearchContext | undefined;
  if (enableResearch) {
    try {
      const topics = buildResearchTopics(brand.domain, period, "seasonal_performance", industry);
      research = await runResearch({ domain: brand.domain, industry, period, topics, uploadedNotes });
      if (research.evidence.length > 0) {
        sources.push({
          id: "research", type: "web_research", label: "Web Research",
          status: research.stats.queriesSucceeded > 0 ? "available" : "partial",
          recordCount: research.stats.evidenceGathered,
        });
      }
    } catch {
      // Research failed — continue without it, record as caveat below
      research = undefined;
    }
  }

  // ── Build Analysis ──
  const kpis = buildKpis(gscData, ga4Data, prevGscData, prevGa4Data, yoyGscData, yoyGa4Data, latestAudit, daySpan);
  const findings = buildFindings(gscData, ga4Data, prevGscData, prevGa4Data, yoyGscData, yoyGa4Data, latestAudit, topPages);
  const charts = buildCharts(gscData, ga4Data, prevGscData, prevGa4Data, period, topPages, prevTopPages);
  const tables = buildTables(gscData, ga4Data, topPages);
  const predictions = buildPredictions(gscData, ga4Data, yoyGscData, daySpan);
  const recommendations = buildRecommendations(findings, latestAudit);
  const caveats = buildCaveats(sources, gscData, ga4Data);

  // ── Enrich with research evidence ──
  if (research && research.evidence.length > 0) {
    enrichWithResearch(findings, recommendations, research);
  }

  const timeline = buildTimeline(findings, research);

  const criticalFindings = findings.filter(f => f.severity === "critical" || f.severity === "high");
  const opportunityFindings = findings.filter(f => f.category === "opportunity");
  const summary = buildSummary(brand.domain, period, kpis, findings, criticalFindings.length, opportunityFindings.length);
  const narrative = buildNarrative(brand.domain, period, kpis, findings, recommendations, caveats, research, predictions);

  // ── First-Party Data Engine context enrichment ──
  // Append industry benchmarks, recommendation success rates, and client memory
  // to the narrative so the analysis references cross-customer intelligence.
  try {
    const benchmarks = industry ? await getIndustryBenchmarks(industry) : {};
    const successRates = industry ? await getRecommendationSuccessRates(industry) : [];
    const memoryCtx = await getMemoryContext(brandId, 15);
    const contextParts: string[] = [];
    const bmKeys = Object.keys(benchmarks);
    if (bmKeys.length > 0) {
      contextParts.push(`Industry benchmarks (${industry}, ${benchmarks[bmKeys[0]]?.sampleSize ?? 0}+ brands): ` + bmKeys.slice(0, 8).map((k) => `${k.replace(/_/g, " ")}=${benchmarks[k].value}`).join(", "));
    }
    if (successRates.length > 0) {
      contextParts.push(`Recommendation success rates: ` + successRates.slice(0, 5).map((r) => `${r.type}: ${Math.round((r.improved / Math.max(r.total, 1)) * 100)}% success, avg +${r.avgImpact}%`).join("; "));
    }
    if (memoryCtx && !memoryCtx.startsWith("No previous")) {
      contextParts.push(`Previous interactions:\n${memoryCtx}`);
    }
    // Add enrichment data from uploaded documents
    const enrichment = await getEnrichmentContext(brandId);
    if (enrichment.hasEnrichmentData) {
      contextParts.push(formatEnrichmentForPrompt(enrichment));
    }

    if (contextParts.length > 0) {
      (narrative as Record<string, unknown>).dataEngineContext = contextParts.join("\n\n");
    }
  } catch { /* best effort — don't block analysis on context fetch failure */ }

  // Add manual caveats if provided
  if (manualCaveats && manualCaveats.length > 0) {
    for (const caveatText of manualCaveats) {
      caveats.push({
        type: "external_factor", severity: "info",
        title: "User-provided context",
        description: caveatText,
        affectedSections: ["findings", "predictions"],
      });
    }
  }

  // ── AI-powered analysis (enhances algorithmic results) ──
  // Claude interprets the data holistically; algorithmic results are the fallback.
  let aiSummary = summary;
  let aiFindings = findings;
  let aiRecs = recommendations;
  let aiPredictions = predictions;
  let aiNarrative = narrative;

  try {
    const dataPackage = await buildAnalysisDataPackage(brandId, daySpan);
    const aiResult = await runAiAnalysis(dataPackage);
    if (aiResult) {
      aiSummary = aiResult.executiveSummary || summary;
      if (aiResult.findings?.length >= 3) aiFindings = aiResult.findings;
      if (aiResult.recommendations?.length >= 3) aiRecs = aiResult.recommendations;
      if (aiResult.predictions?.length >= 1) aiPredictions = aiResult.predictions;
      // Enhance KPI commentary from AI
      if (aiResult.kpiCommentary) {
        for (const kpi of kpis) {
          const key = kpi.label.toLowerCase().replace(/\s+/g, "");
          const commentary = aiResult.kpiCommentary[key] || aiResult.kpiCommentary[kpi.label] || Object.values(aiResult.kpiCommentary).find((v) => typeof v === "string" && v.toLowerCase().includes(kpi.label.toLowerCase()));
          if (typeof commentary === "string" && commentary.length > 20) kpi.commentary = commentary;
        }
      }
      // Merge narrative
      aiNarrative = {
        executiveSummary: aiResult.executiveSummary || narrative.executiveSummary,
        detailedReport: [aiResult.strategicOutlook, aiResult.competitiveInsight, aiResult.behavioralInsight].filter(Boolean).join("\n\n") || narrative.detailedReport,
        methodology: narrative.methodology,
      };
      if ((narrative as Record<string, unknown>).dataEngineContext) {
        (aiNarrative as Record<string, unknown>).dataEngineContext = (narrative as Record<string, unknown>).dataEngineContext;
      }
    }
  } catch (err) {
    console.error("[analysis-engine] AI analysis failed, using algorithmic fallback", err);
    // Algorithmic results remain as-is — no changes needed
  }

  return {
    id,
    brandId,
    type: "seasonal_performance",
    status: "completed",
    createdAt: new Date().toISOString(),
    completedAt: new Date().toISOString(),
    branding,
    prompt,
    manualCaveats,
    uploadedNotes,
    title: `Seasonal Performance Analysis`,
    subtitle: `${branding.clientName} · ${period.start} to ${period.end}`,
    summary: aiSummary,
    period: { start: period.start, end: period.end, label: `${daySpan} days` },
    comparisonPeriod: { start: compStart.toISOString().split("T")[0], end: compEnd.toISOString().split("T")[0], label: `Previous ${daySpan} days` },
    sources,
    kpis,
    findings: aiFindings,
    charts,
    tables,
    timeline,
    predictions: aiPredictions,
    recommendations: aiRecs,
    caveats,
    research,
    narrative: aiNarrative,
  };
}

// ─── KPI Builder ────────────────────────────────────────────────────────────

type GscPoint = { date: string; clicks: number; impressions: number; position: number; ctr: number };
type Ga4Point = { date: string; sessions: number; users: number; conversions: number; revenue: number; pageviews: number; bounceRate: number; avgSessionSec: number };

function sum<T>(arr: T[], key: keyof T): number {
  return arr.reduce((s, d) => s + ((d[key] as unknown as number) ?? 0), 0);
}
function avg<T>(arr: T[], key: keyof T): number {
  if (arr.length === 0) return 0;
  return sum(arr, key) / arr.length;
}

/** Cap for percentage-change display to avoid absurd numbers when prev ~= 0 */
const MAX_PCT_CHANGE = 1000;

function pctChange(current: number, previous: number): number | null {
  if (previous <= 0) return null;
  const raw = ((current - previous) / previous) * 100;
  // Cap absurd values (happens when previous is tiny, e.g. 1 click → 3000 clicks)
  if (raw > MAX_PCT_CHANGE) return MAX_PCT_CHANGE;
  if (raw < -MAX_PCT_CHANGE) return -MAX_PCT_CHANGE;
  return Math.round(raw * 10) / 10;
}

// Per-metric minimum thresholds for meaningful % comparison
const NEW_DATA_THRESHOLDS: Record<string, number> = {
  clicks: 50, impressions: 500, ctr: 50, position: 500,
  sessions: 100, conversions: 10, revenue: 100, pageviews: 200,
  users: 100, bounceRate: 100, avgSessionSec: 100, default: 50,
};

function changeObj(current: number, previous: number, label: string, metricKey?: string): { value: number; direction: "up" | "down" | "flat"; label: string } | undefined {
  if (previous <= 0 && current > 0) {
    return { value: 0, direction: "up", label: `New ${label.replace("vs previous", "this period").replace("YoY", "this year")}` };
  }
  const p = pctChange(current, previous);
  if (p === null) return undefined;

  // Per-metric threshold: extreme change + tiny previous = "New data"
  const threshold = NEW_DATA_THRESHOLDS[metricKey || "default"] ?? 50;
  if (Math.abs(p) >= 500 && previous < threshold) {
    return { value: 0, direction: "up", label: `New data ${label}` };
  }
  // Show >1000% / <-1000% for capped values
  if (p === MAX_PCT_CHANGE) {
    return { value: p, direction: "up", label: `>${MAX_PCT_CHANGE}% ${label}` };
  }
  if (p === -MAX_PCT_CHANGE) {
    return { value: p, direction: "down", label: `<-${MAX_PCT_CHANGE}% ${label}` };
  }
  return {
    value: p,
    direction: p > 0 ? "up" : p < 0 ? "down" : "flat",
    label: `${p > 0 ? "+" : ""}${p}% ${label}`,
  };
}

/** Detect trend direction by comparing first-half vs second-half average */
function detectTrend(values: number[]): "improving" | "declining" | "stable" | "volatile" {
  if (values.length < 4) return "stable";
  const mid = Math.floor(values.length / 2);
  const firstHalf = values.slice(0, mid);
  const secondHalf = values.slice(mid);
  const firstAvg = firstHalf.reduce((s, v) => s + v, 0) / firstHalf.length;
  const secondAvg = secondHalf.reduce((s, v) => s + v, 0) / secondHalf.length;
  if (firstAvg === 0) return secondAvg > 0 ? "improving" : "stable";
  const delta = (secondAvg - firstAvg) / firstAvg;
  // Volatility check: if values swing >50% of mean on any adjacent day
  const mean = values.reduce((s, v) => s + v, 0) / values.length;
  if (mean > 0) {
    let swings = 0;
    for (let i = 1; i < values.length; i++) {
      if (Math.abs(values[i] - values[i - 1]) / mean > 0.5) swings++;
    }
    if (swings >= Math.max(2, Math.floor(values.length / 4))) return "volatile";
  }
  if (delta > 0.1) return "improving";
  if (delta < -0.1) return "declining";
  return "stable";
}

function formatNumber(n: number): string {
  if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M";
  if (n >= 10_000) return Math.round(n / 1000) + "k";
  if (n >= 1_000) return (n / 1000).toFixed(1) + "k";
  return Math.round(n).toLocaleString();
}

function buildKpis(
  gsc: GscPoint[],
  ga4: Ga4Point[],
  prevGsc: GscPoint[], prevGa4: Ga4Point[],
  yoyGsc: GscPoint[], yoyGa4: Ga4Point[],
  audit: { crawlHealth: number; errorCount: number; warningCount: number; pagesScanned: number } | null,
  daySpan: number,
): KpiItem[] {
  const kpis: KpiItem[] = [];

  // Data coverage: how many days of the requested range have GSC data
  const gscCoverage = { daysWithData: gsc.length, totalDays: daySpan, firstDataDate: gsc[0]?.date };
  const ga4Coverage = { daysWithData: ga4.length, totalDays: daySpan, firstDataDate: ga4[0]?.date };
  const gscPartial = gsc.length > 0 && gsc.length < daySpan * 0.8; // < 80% coverage

  // For apples-to-apples comparison: if current period has N days of data,
  // only use the last N days of the previous period too
  const adjustedPrevGsc = gscPartial && prevGsc.length > gsc.length ? prevGsc.slice(prevGsc.length - gsc.length) : prevGsc;
  const adjustedPrevGa4 = ga4.length > 0 && ga4.length < daySpan * 0.8 && prevGa4.length > ga4.length ? prevGa4.slice(prevGa4.length - ga4.length) : prevGa4;

  // ── GSC KPIs ──
  if (gsc.length > 0) {
    // Organic Clicks
    const clicks = sum(gsc, "clicks");
    const prevClicks = sum(adjustedPrevGsc, "clicks");
    const yoyClicks = sum(yoyGsc, "clicks");
    const clicksChange = changeObj(clicks, prevClicks, "vs previous period", "clicks");
    const clicksYoy = yoyGsc.length > 0 ? changeObj(clicks, yoyClicks, "YoY", "clicks") : undefined;
    const realGscPoints = gsc.filter(d => d.clicks > 0 || d.impressions > 0);
    const clicksTrend = realGscPoints.length >= 14 ? detectTrend(realGscPoints.map(d => d.clicks)) : undefined;
    let clicksCommentary = "";
    if (clicksChange && clicksChange.value < -10) {
      clicksCommentary = `Clicks are down materially vs the previous period. Investigate keyword position shifts and any recent technical changes.`;
    } else if (clicksChange && clicksChange.value > 10) {
      clicksCommentary = `Strong click growth indicates improved visibility or CTR — worth identifying which pages drove the lift.`;
    } else if (clicksTrend === "declining") {
      clicksCommentary = `Clicks are trending downward within the period even though the period-over-period change is flat.`;
    }
    kpis.push({
      label: "Organic Clicks",
      value: formatNumber(clicks),
      rawValue: clicks,
      previousValue: formatNumber(prevClicks),
      source: "GSC",
      category: "search",
      change: clicksChange,
      yoyChange: clicksYoy,
      trend: clicksTrend,
      status: clicksChange && clicksChange.value >= 0 ? "good" : clicksChange && clicksChange.value > -10 ? "warning" : "critical",
      commentary: gscPartial ? `Based on ${gsc.length} of ${daySpan} days of data (GSC data starts ${gscCoverage.firstDataDate}).${clicksCommentary ? " " + clicksCommentary : ""}` : clicksCommentary || undefined,
      dataCoverage: gscCoverage,
    });

    // Impressions
    const imps = sum(gsc, "impressions");
    const prevImps = sum(adjustedPrevGsc, "impressions");
    const yoyImps = sum(yoyGsc, "impressions");
    const impsChange = changeObj(imps, prevImps, "vs previous period", "impressions");
    const impsYoy = yoyGsc.length > 0 ? changeObj(imps, yoyImps, "YoY", "impressions") : undefined;
    const impsTrend = realGscPoints.length >= 14 ? detectTrend(realGscPoints.map(d => d.impressions)) : undefined;
    let impsCommentary = "";
    if (impsChange && clicksChange && impsChange.value > 10 && clicksChange.value < 0) {
      impsCommentary = `Impressions growing while clicks decline suggests keyword visibility is expanding but CTR is suffering — likely a ranking drop for high-value queries or weak SERP snippets.`;
    } else if (impsChange && impsChange.value > 15) {
      impsCommentary = `Impressions are expanding, which typically precedes click growth if CTR holds.`;
    }
    kpis.push({
      label: "Impressions",
      value: formatNumber(imps),
      rawValue: imps,
      previousValue: formatNumber(prevImps),
      source: "GSC",
      category: "search",
      change: impsChange,
      yoyChange: impsYoy,
      trend: impsTrend,
      status: "neutral",
      commentary: impsCommentary || undefined,
      dataCoverage: gscCoverage,
    });

    // Average Position
    const avgPos = avg(gsc, "position");
    const prevAvgPos = adjustedPrevGsc.length > 0 ? avg(adjustedPrevGsc, "position") : 0;
    const posDelta = prevAvgPos > 0 ? Math.round((prevAvgPos - avgPos) * 10) / 10 : null; // inverted: lower is better
    kpis.push({
      label: "Avg Position",
      value: avgPos.toFixed(1),
      rawValue: avgPos,
      previousValue: prevAvgPos > 0 ? prevAvgPos.toFixed(1) : undefined,
      source: "GSC",
      category: "search",
      change: posDelta !== null ? {
        value: posDelta,
        direction: posDelta > 0 ? "up" : posDelta < 0 ? "down" : "flat",
        label: `${posDelta > 0 ? "+" : ""}${posDelta} positions`,
      } : undefined,
      trend: detectTrend(gsc.map(d => -d.position)), // invert so lower = improving
      status: avgPos <= 10 ? "good" : avgPos <= 20 ? "warning" : "critical",
      commentary: avgPos > 10 ? `Average position outside the top 10 means most queries render on page 2 or lower — significant click volume is being left on the table.` : undefined,
      dataCoverage: gscCoverage,
    });

    // CTR
    const avgCtr = avg(gsc, "ctr");
    const prevAvgCtr = adjustedPrevGsc.length > 0 ? avg(adjustedPrevGsc, "ctr") : 0;
    const ctrChange = prevAvgCtr > 0 ? changeObj(avgCtr, prevAvgCtr, "vs previous", "ctr") : undefined;
    kpis.push({
      label: "CTR",
      value: `${(avgCtr * 100).toFixed(2)}%`,
      rawValue: avgCtr,
      previousValue: prevAvgCtr > 0 ? `${(prevAvgCtr * 100).toFixed(2)}%` : undefined,
      source: "GSC",
      category: "search",
      change: ctrChange,
      trend: detectTrend(gsc.map(d => d.ctr)),
      status: avgCtr >= 0.03 ? "good" : avgCtr >= 0.015 ? "warning" : "critical",
      commentary: avgCtr < 0.02 ? `CTR below 2% is weak at the current average position — test stronger title tags and meta descriptions on top pages.` : undefined,
      dataCoverage: gscCoverage,
    });
  }

  // ── GA4 KPIs ──
  if (ga4.length > 0) {
    // Sessions
    const sessions = sum(ga4, "sessions");
    const prevSessions = sum(adjustedPrevGa4, "sessions");
    const yoySessions = sum(yoyGa4, "sessions");
    const sessionsChange = changeObj(sessions, prevSessions, "vs previous period", "sessions");
    const sessionsYoy = yoyGa4.length > 0 ? changeObj(sessions, yoySessions, "YoY", "sessions") : undefined;
    const sessionsTrend = detectTrend(ga4.map(d => d.sessions));
    kpis.push({
      label: "Sessions",
      value: formatNumber(sessions),
      rawValue: sessions,
      previousValue: formatNumber(prevSessions),
      source: "GA4",
      category: "engagement",
      change: sessionsChange,
      yoyChange: sessionsYoy,
      trend: sessionsTrend,
      status: sessionsChange && sessionsChange.value >= 0 ? "good" : sessionsChange && sessionsChange.value > -10 ? "warning" : "critical",
      commentary: sessionsChange && sessionsChange.value < -15 ? `Session volume is contracting faster than a soft market can explain — check for traffic-source attribution changes or tracking gaps.` : undefined,
    });

    // Conversions
    const conversions = sum(ga4, "conversions");
    const prevConversions = sum(adjustedPrevGa4, "conversions");
    const yoyConversions = sum(yoyGa4, "conversions");
    const convRate = sessions > 0 ? conversions / sessions : 0;
    const prevConvRate = prevSessions > 0 ? prevConversions / prevSessions : 0;
    kpis.push({
      label: "Conversions",
      value: conversions.toLocaleString(),
      rawValue: conversions,
      previousValue: prevConversions.toLocaleString(),
      source: "GA4",
      category: "conversion",
      change: changeObj(conversions, prevConversions, "vs previous", "conversions"),
      yoyChange: yoyGa4.length > 0 ? changeObj(conversions, yoyConversions, "YoY", "conversions") : undefined,
      trend: detectTrend(ga4.map(d => d.conversions)),
      status: conversions > 0 && conversions >= prevConversions ? "good" : conversions > 0 ? "warning" : "neutral",
      commentary: convRate > 0 && prevConvRate > 0 && convRate < prevConvRate * 0.9
        ? `Conversion rate fell from ${(prevConvRate * 100).toFixed(2)}% to ${(convRate * 100).toFixed(2)}% — traffic quality or on-page experience may have changed.`
        : undefined,
    });

    // Bounce Rate
    const bounceRate = avg(ga4, "bounceRate");
    const prevBounceRate = prevGa4.length > 0 ? avg(prevGa4, "bounceRate") : 0;
    if (bounceRate > 0 || prevBounceRate > 0) {
      const bounceDelta = prevBounceRate > 0 ? Math.round((bounceRate - prevBounceRate) * 1000) / 10 : null;
      kpis.push({
        label: "Bounce Rate",
        value: `${(bounceRate * 100).toFixed(1)}%`,
        rawValue: bounceRate,
        previousValue: prevBounceRate > 0 ? `${(prevBounceRate * 100).toFixed(1)}%` : undefined,
        source: "GA4",
        category: "engagement",
        change: bounceDelta !== null ? {
          value: bounceDelta,
          direction: bounceDelta > 0 ? "up" : bounceDelta < 0 ? "down" : "flat",
          label: `${bounceDelta > 0 ? "+" : ""}${bounceDelta}pp vs previous`,
        } : undefined,
        trend: detectTrend(ga4.map(d => -d.bounceRate)), // invert: lower is better
        status: bounceRate < 0.4 ? "good" : bounceRate < 0.6 ? "warning" : "critical",
        commentary: bounceRate > 0.6 ? `High bounce rate suggests landing-page mismatch with user intent or slow load times.` : undefined,
      });
    }

    // Pages per Session
    const totalPageviews = sum(ga4, "pageviews");
    const pps = sessions > 0 ? totalPageviews / sessions : 0;
    const prevTotalPv = sum(adjustedPrevGa4, "pageviews");
    const prevPps = prevSessions > 0 ? prevTotalPv / prevSessions : 0;
    if (pps > 0 || prevPps > 0) {
      kpis.push({
        label: "Pages / Session",
        value: pps.toFixed(2),
        rawValue: pps,
        previousValue: prevPps > 0 ? prevPps.toFixed(2) : undefined,
        source: "GA4",
        category: "engagement",
        change: prevPps > 0 ? changeObj(pps, prevPps, "vs previous", "sessions") : undefined,
        status: pps >= 2 ? "good" : pps >= 1.5 ? "warning" : "neutral",
        commentary: pps < 1.5 && pps > 0 ? `Low pages-per-session indicates visitors aren't exploring beyond their entry point — review internal linking and content depth.` : undefined,
      });
    }

    // Avg Session Duration (seconds)
    const avgDur = avg(ga4, "avgSessionSec");
    const prevAvgDur = prevGa4.length > 0 ? avg(prevGa4, "avgSessionSec") : 0;
    if (avgDur > 0 || prevAvgDur > 0) {
      const mins = Math.floor(avgDur / 60);
      const secs = Math.round(avgDur % 60);
      kpis.push({
        label: "Avg Session",
        value: `${mins}m ${secs}s`,
        rawValue: avgDur,
        previousValue: prevAvgDur > 0 ? `${Math.floor(prevAvgDur / 60)}m ${Math.round(prevAvgDur % 60)}s` : undefined,
        source: "GA4",
        category: "engagement",
        change: prevAvgDur > 0 ? changeObj(avgDur, prevAvgDur, "vs previous", "avgSessionSec") : undefined,
        status: avgDur >= 120 ? "good" : avgDur >= 60 ? "warning" : "neutral",
      });
    }
  }

  // ── Technical KPI ──
  if (audit) {
    kpis.push({
      label: "Crawl Health",
      value: `${audit.crawlHealth}%`,
      rawValue: audit.crawlHealth,
      source: "Technical Audit",
      category: "technical",
      status: audit.crawlHealth >= 80 ? "good" : audit.crawlHealth >= 50 ? "warning" : "critical",
      commentary: audit.crawlHealth < 70
        ? `${audit.errorCount} critical issues and ${audit.warningCount} warnings are dragging crawl health below 70%. Technical fixes are the fastest way to unlock ranking gains.`
        : undefined,
    });
  }

  return kpis;
}

// ─── Findings Builder ───────────────────────────────────────────────────────

type GscPageRow = { pageUrl: string; clicks: number; impressions: number; ctr: number; position: number };

function buildFindings(
  gsc: GscPoint[],
  ga4: Ga4Point[],
  prevGsc: GscPoint[], prevGa4: Ga4Point[],
  yoyGsc: GscPoint[], yoyGa4: Ga4Point[],
  audit: { crawlHealth: number; errorCount: number; warningCount: number } | null,
  topPages: GscPageRow[],
): Finding[] {
  const findings: Finding[] = [];

  // ── Performance findings from GSC ──
  if (gsc.length > 0 && prevGsc.length > 0) {
    const clicks = sum(gsc, "clicks");
    const prevClicks = sum(prevGsc, "clicks");
    const pct = pctChange(clicks, prevClicks) ?? 0;
    const impressions = sum(gsc, "impressions");
    const prevImpressions = sum(prevGsc, "impressions");
    const impPct = pctChange(impressions, prevImpressions) ?? 0;

    // Click drop
    if (pct < -10) {
      findings.push({
        id: "f_clicks_drop", type: "anomaly",
        severity: Math.abs(pct) > 25 ? "critical" : Math.abs(pct) > 15 ? "high" : "medium",
        category: "performance",
        title: `Organic clicks dropped ${Math.abs(pct).toFixed(1)}% period-over-period`,
        description: `Clicks fell from ${prevClicks.toLocaleString()} to ${clicks.toLocaleString()}. ${impPct > 0 ? `Impressions grew ${impPct.toFixed(1)}% over the same window, which means the CTR on appearing queries has weakened — rankings may have shifted for high-intent keywords.` : `Impressions are also ${impPct < 0 ? `down ${Math.abs(impPct).toFixed(1)}%` : "flat"}, suggesting the drop is driven by visibility loss rather than CTR decay.`}`,
        evidence: `GSC: ${prevClicks.toLocaleString()} → ${clicks.toLocaleString()} clicks · Impressions ${prevImpressions.toLocaleString()} → ${impressions.toLocaleString()}`,
        impact: "Lower traffic volume directly reduces lead flow and organic revenue contribution.",
        impactLevel: Math.abs(pct) > 25 ? "high" : "medium",
        affectedMetric: "Organic Clicks",
        recommendedAction: "Audit position changes on top-clicked queries and pages; compare against recent Google updates.",
      });
    } else if (pct > 15) {
      findings.push({
        id: "f_clicks_growth", type: "trend", severity: "info",
        category: "performance",
        title: `Organic clicks up ${pct.toFixed(1)}% period-over-period`,
        description: `Clicks expanded from ${prevClicks.toLocaleString()} to ${clicks.toLocaleString()}. ${impPct > 0 ? `Impressions grew ${impPct.toFixed(1)}% alongside clicks — healthy proportional growth across visibility and click capture.` : `Impressions are flat/down, meaning CTR improved — existing rankings are converting better to clicks.`}`,
        evidence: `GSC: ${prevClicks.toLocaleString()} → ${clicks.toLocaleString()} clicks`,
        impact: "Incremental traffic growth translates into more inbound leads when the funnel is stable.",
        impactLevel: "medium",
        affectedMetric: "Organic Clicks",
        recommendedAction: "Identify the top 5 pages driving this lift and replicate their structure on adjacent pages.",
      });
    }

    // YoY comparison
    if (yoyGsc.length > 0) {
      const yoyClicks = sum(yoyGsc, "clicks");
      const yoyPct = pctChange(clicks, yoyClicks);
      if (yoyPct !== null && Math.abs(yoyPct) > 15) {
        findings.push({
          id: "f_clicks_yoy", type: yoyPct > 0 ? "trend" : "risk",
          severity: yoyPct < -25 ? "high" : "medium",
          category: "performance",
          title: `Year-over-year clicks ${yoyPct > 0 ? "up" : "down"} ${Math.abs(yoyPct).toFixed(1)}%`,
          description: `Compared to the same period last year (${yoyClicks.toLocaleString()} clicks), current volume is ${clicks.toLocaleString()}. ${yoyPct < 0 ? "This signals a longer-term erosion, not just a period-over-period blip — investigate structural ranking or content-quality changes." : "This confirms a sustained growth trajectory beyond short-term fluctuations."}`,
          evidence: `YoY GSC: ${yoyClicks.toLocaleString()} → ${clicks.toLocaleString()} clicks`,
          impactLevel: Math.abs(yoyPct) > 25 ? "high" : "medium",
          affectedMetric: "Organic Clicks",
        });
      }
    }

    // Average position finding
    const avgPos = avg(gsc, "position");
    const prevAvgPos = prevGsc.length > 0 ? avg(prevGsc, "position") : 0;
    const posDelta = prevAvgPos > 0 ? avgPos - prevAvgPos : 0;
    if (Math.abs(posDelta) > 1 && prevAvgPos > 0) {
      findings.push({
        id: "f_position_shift", type: posDelta < 0 ? "trend" : "risk",
        severity: Math.abs(posDelta) > 3 ? "high" : "medium",
        category: "performance",
        title: `Average position ${posDelta < 0 ? "improved" : "declined"} by ${Math.abs(posDelta).toFixed(1)} spots`,
        description: `Average SERP position moved from ${prevAvgPos.toFixed(1)} to ${avgPos.toFixed(1)}. ${posDelta < 0 ? "Moving up the SERP is the highest-leverage way to grow clicks without new content." : "A position drop typically precedes a click drop — act quickly to identify which queries regressed."}`,
        evidence: `GSC avg position: ${prevAvgPos.toFixed(1)} → ${avgPos.toFixed(1)}`,
        impactLevel: Math.abs(posDelta) > 3 ? "high" : "medium",
        affectedMetric: "Avg Position",
      });
    }

    // CTR finding
    const avgCtr = avg(gsc, "ctr");
    if (avgCtr > 0 && avgCtr < 0.02) {
      findings.push({
        id: "f_weak_ctr", type: "opportunity", severity: "medium",
        category: "content",
        title: `CTR of ${(avgCtr * 100).toFixed(2)}% is below 2%`,
        description: `At this CTR level, most of the impressions you're earning aren't converting to clicks. This usually indicates weak title tags, generic meta descriptions, or SERP features (featured snippets, People Also Ask) absorbing attention.`,
        evidence: `GSC average CTR: ${(avgCtr * 100).toFixed(2)}%`,
        impact: "Doubling CTR from 2% to 4% would double click volume without any ranking improvement.",
        impactLevel: "high",
        affectedMetric: "CTR",
        recommendedAction: "Rewrite title tags on top-10 impression pages to include action words, numbers, and year stamps.",
      });
    }
  }

  // ── GA4 engagement findings ──
  if (ga4.length > 0 && prevGa4.length > 0) {
    const sessions = sum(ga4, "sessions");
    const prevSessions = sum(prevGa4, "sessions");
    const sessPct = pctChange(sessions, prevSessions) ?? 0;
    if (sessPct < -10) {
      findings.push({
        id: "f_sessions_drop", type: "anomaly",
        severity: Math.abs(sessPct) > 25 ? "critical" : "high",
        category: "performance",
        title: `Sessions dropped ${Math.abs(sessPct).toFixed(1)}%`,
        description: `Total sessions fell from ${prevSessions.toLocaleString()} to ${sessions.toLocaleString()} across all traffic sources. Since this is a multi-channel metric, the cause could be organic, paid, direct, or referral — cross-reference GSC clicks to isolate the organic contribution.`,
        evidence: `GA4: ${prevSessions.toLocaleString()} → ${sessions.toLocaleString()} sessions`,
        impact: "Any sustained session decline shrinks the top of the funnel proportionally.",
        impactLevel: "high",
        affectedMetric: "Sessions",
      });
    }

    // Bounce rate finding
    const bounceRate = avg(ga4, "bounceRate");
    const prevBounceRate = prevGa4.length > 0 ? avg(prevGa4, "bounceRate") : 0;
    if (bounceRate > 0.6) {
      findings.push({
        id: "f_high_bounce", type: "risk", severity: bounceRate > 0.75 ? "high" : "medium",
        category: "content",
        title: `Bounce rate at ${(bounceRate * 100).toFixed(1)}%`,
        description: `Over half of visitors leave without interacting. ${prevBounceRate > 0 && bounceRate > prevBounceRate + 0.05 ? `This is a ${((bounceRate - prevBounceRate) * 100).toFixed(1)}pp increase from the previous period, suggesting a recent content or page-experience change.` : "This usually means landing pages don't match query intent or page speed is degrading the first impression."}`,
        evidence: `GA4 bounce rate: ${(bounceRate * 100).toFixed(1)}%`,
        impactLevel: "medium",
        affectedMetric: "Bounce Rate",
        recommendedAction: "Run a landing-page audit on the top 5 entry pages; compare intent vs delivered content.",
      });
    }

    // Conversion rate finding
    const conversions = sum(ga4, "conversions");
    const convRate = sessions > 0 ? conversions / sessions : 0;
    const prevConversions = sum(prevGa4, "conversions");
    const prevConvRate = prevSessions > 0 ? prevConversions / prevSessions : 0;
    if (prevConvRate > 0 && convRate < prevConvRate * 0.85) {
      findings.push({
        id: "f_conv_decay", type: "risk", severity: "high",
        category: "performance",
        title: `Conversion rate fell from ${(prevConvRate * 100).toFixed(2)}% to ${(convRate * 100).toFixed(2)}%`,
        description: `Despite ${sessPct >= 0 ? `session volume holding steady` : `session volume also dropping`}, the fraction of visitors who convert has weakened. This usually points to traffic-quality changes (wrong keywords now driving traffic) or a form/checkout issue introduced recently.`,
        evidence: `GA4: ${prevConversions} conversions from ${prevSessions} sessions → ${conversions} from ${sessions}`,
        impact: "Direct revenue impact: every percentage point of conversion rate change moves the top line immediately.",
        impactLevel: "high",
        affectedMetric: "Conversions",
      });
    }
  }

  // ── Technical findings from audit ──
  if (audit) {
    if (audit.crawlHealth < 70) {
      findings.push({
        id: "f_crawl_health", type: "risk",
        severity: audit.crawlHealth < 50 ? "critical" : "high",
        category: "technical",
        title: `Crawl health at ${audit.crawlHealth}/100`,
        description: `${audit.errorCount} critical errors and ${audit.warningCount} warnings are impairing crawl efficiency. Technical debt at this level has a compounding effect — each unresolved issue makes it harder for new content to rank.`,
        evidence: `Technical Audit: ${audit.errorCount} errors, ${audit.warningCount} warnings`,
        impact: "Technical fixes are usually the highest-leverage SEO improvements because they benefit the entire site at once.",
        impactLevel: "high",
        affectedMetric: "Crawl Health",
        recommendedAction: "Prioritise the top 3 critical errors by affected-URL count in the Technical Audit.",
      });
    }
    if (audit.errorCount > 0) {
      findings.push({
        id: "f_technical_errors", type: "risk",
        severity: audit.errorCount > 20 ? "high" : "medium",
        category: "technical",
        title: `${audit.errorCount} critical technical error${audit.errorCount === 1 ? "" : "s"} detected`,
        description: `The latest crawl identified ${audit.errorCount} high-severity issue${audit.errorCount === 1 ? "" : "s"} (broken links, missing metadata, redirect chains, or crawl blockers). Each of these has a direct impact on Google's ability to index and rank pages.`,
        evidence: `Technical Audit: ${audit.errorCount} errors`,
        impactLevel: audit.errorCount > 20 ? "high" : "medium",
        affectedMetric: "Crawl Health",
        recommendedAction: "Fix critical errors first — they block rankings; warnings can wait.",
      });
    }
  }

  // ── Content / opportunity findings from top pages ──
  if (topPages.length > 0) {
    // Ranking-5-to-15 opportunity pages
    const opportunityPages = topPages.filter(p => p.position >= 5 && p.position <= 15 && p.impressions > 50);
    if (opportunityPages.length >= 2) {
      findings.push({
        id: "f_opportunity_pages", type: "opportunity", severity: "medium",
        category: "opportunity",
        title: `${opportunityPages.length} pages ranking in positions 5–15 with strong impressions`,
        description: `These pages are earning visibility but sitting below the top 3 — where ~60% of clicks go. Small ranking improvements on these pages typically yield the fastest click lift available in any SEO program. Top candidates: ${opportunityPages.slice(0, 3).map(p => new URL(p.pageUrl, "https://example.com").pathname).join(", ")}.`,
        evidence: `GSC: ${opportunityPages.length} pages with position 5–15 and ${opportunityPages.reduce((s, p) => s + p.impressions, 0).toLocaleString()} total impressions`,
        impact: "Moving a single page from position 8 to position 3 typically 4–6x its click volume.",
        impactLevel: "high",
        affectedMetric: "Avg Position",
        recommendedAction: "Refresh on-page content, strengthen internal links, and add schema to each opportunity page.",
      });
    }

    // Underperforming top pages (high impressions, low CTR)
    const weakCtrPages = topPages.filter(p => p.impressions > 100 && p.ctr < 0.015);
    if (weakCtrPages.length >= 1) {
      findings.push({
        id: "f_weak_ctr_pages", type: "opportunity", severity: "medium",
        category: "content",
        title: `${weakCtrPages.length} high-impression page${weakCtrPages.length === 1 ? "" : "s"} with CTR under 1.5%`,
        description: `These pages appear in search but fail to earn clicks. The imbalance between impressions and clicks usually means the title/description doesn't match query intent, or a competitor is winning the SERP with better snippets.`,
        evidence: `GSC: ${weakCtrPages.reduce((s, p) => s + p.impressions, 0).toLocaleString()} wasted impressions across ${weakCtrPages.length} page${weakCtrPages.length === 1 ? "" : "s"}`,
        impact: "Each 1pp of CTR improvement on these pages adds ~1 click per 100 impressions.",
        impactLevel: "medium",
        affectedMetric: "CTR",
        recommendedAction: "Rewrite title tags first — they're the single biggest CTR lever.",
      });
    }
  }

  // ── Always-on finding: data coverage ──
  // Tell the user exactly what depth of data the analysis is built on, and what
  // they'll unlock with more history. This is valuable even (especially) when
  // other findings are sparse.
  const gscDays = gsc.length;
  const ga4Days = ga4.length;
  const maxDays = Math.max(gscDays, ga4Days);
  if (maxDays < 28) {
    findings.push({
      id: "f_data_coverage", type: "insight",
      severity: maxDays < 7 ? "medium" : "low",
      category: "performance",
      title: maxDays === 0
        ? "No time-series data yet"
        : `Analysis based on ${maxDays} day${maxDays === 1 ? "" : "s"} of data`,
      description: maxDays === 0
        ? "Connected sources haven't synced any daily data yet. Analysis is limited to audit-based findings until the first sync completes."
        : `Current depth: ${gscDays > 0 ? `${gscDays} days GSC` : "no GSC"}${gscDays > 0 && ga4Days > 0 ? " · " : ""}${ga4Days > 0 ? `${ga4Days} days GA4` : ga4Days === 0 && gscDays > 0 ? "" : "no GA4"}. As more data syncs, you'll unlock trend analysis, YoY comparisons, seasonal insights, and predictive forecasting. Check back in a week for a much richer analysis.`,
      evidence: `${gscDays} GSC daily snapshots, ${ga4Days} GA4 daily snapshots`,
      impactLevel: "medium",
      recommendedAction: "Let the daily sync run for 7–14 more days to unlock trend detection, then re-run the analysis.",
    });
  }

  // ── Always-on technical findings (audit-based, no time-series needed) ──
  if (audit) {
    if (audit.errorCount > 0) {
      findings.push({
        id: "f_audit_errors_always", type: "risk",
        severity: audit.errorCount > 20 ? "high" : audit.errorCount > 5 ? "medium" : "low",
        category: "technical",
        title: `${audit.errorCount} technical error${audit.errorCount === 1 ? "" : "s"} from latest audit`,
        description: `The latest crawl identified ${audit.errorCount} high-severity issue${audit.errorCount === 1 ? "" : "s"} and ${audit.warningCount} warning${audit.warningCount === 1 ? "" : "s"}. These are the fastest wins in SEO — every fix compounds across the whole site.`,
        evidence: `Technical Audit: ${audit.errorCount} errors, ${audit.warningCount} warnings`,
        impactLevel: audit.errorCount > 20 ? "high" : "medium",
        affectedMetric: "Crawl Health",
        recommendedAction: "Fix critical errors first, then warnings. Re-run the audit after each batch.",
      });
    } else if (audit.crawlHealth >= 80) {
      findings.push({
        id: "f_audit_clean", type: "opportunity",
        severity: "info",
        category: "technical",
        title: `Technical health is strong (${audit.crawlHealth}/100)`,
        description: "No critical technical issues detected. A clean technical foundation means SEO investment in content and links compounds without being held back by crawl problems.",
        evidence: `Technical Audit: ${audit.crawlHealth}% crawl health`,
        impactLevel: "low",
      });
    }
  } else {
    // No audit at all — that's itself a finding
    findings.push({
      id: "f_audit_missing", type: "opportunity",
      severity: "medium",
      category: "technical",
      title: "No technical audit has been run yet",
      description: "A technical audit is the single fastest way to identify SEO blockers. Run one to discover broken links, missing meta descriptions, heading issues, and crawl inefficiencies that may be holding back rankings.",
      evidence: "No completed TechnicalAudit record for this brand",
      impactLevel: "high",
      recommendedAction: "Go to Technical Audit and click Run Audit.",
    });
  }

  // ── Always-on positive finding when performance is truly stable ──
  if (findings.length < 3) {
    findings.push({
      id: "f_stable", type: "insight", severity: "info",
      category: "performance",
      title: "Performance is stable across all tracked metrics",
      description: "No material anomalies, drops, or urgent issues detected within the analysis thresholds. Stable periods are the right time to invest in content, technical upgrades, and internal linking — you won't be fighting fires.",
      evidence: "All tracked metrics within expected ranges",
      impactLevel: "low",
    });
  }

  return findings;
}

// ─── Chart Builder ──────────────────────────────────────────────────────────

/** Detect anomalies in a time series — return dates where value swings >40% from the running mean */
function detectAnomalies(data: GscPoint[] | Ga4Point[], key: string): Array<{ x: string; label: string; color: string }> {
  // Filter to only dates with real data (not zero-filled gaps)
  const realData = (data as Array<Record<string, unknown>>).filter(d => Number(d[key] ?? 0) > 0);
  if (realData.length < 7) return [];
  const anomalies: Array<{ x: string; label: string; color: string }> = [];
  const values = realData.map(d => Number(d[key] ?? 0));
  const mean = values.reduce((s, v) => s + v, 0) / values.length;
  if (mean === 0) return [];
  for (let i = 1; i < values.length; i++) {
    const delta = values[i] - values[i - 1];
    const relDelta = Math.abs(delta) / mean;
    // Only annotate meaningful changes between two real data points
    if (relDelta > 0.4 && values[i - 1] > 0 && values[i] > 0) {
      const date = String(realData[i].date);
      anomalies.push({
        x: date,
        label: delta > 0 ? `Spike: +${Math.round(relDelta * 100)}%` : `Dip: -${Math.round(relDelta * 100)}%`,
        color: delta > 0 ? "#10b981" : "#ef4444",
      });
    }
  }
  return anomalies.slice(0, 5);
}

function buildCharts(
  gsc: GscPoint[],
  ga4: Ga4Point[],
  prevGsc: GscPoint[], prevGa4: Ga4Point[],
  period: { start: string; end: string },
  topPages: GscPageRow[],
  prevTopPages: GscPageRow[],
): ChartSpec[] {
  const charts: ChartSpec[] = [];

  // ── 1. Organic Search Performance (clicks + impressions) with annotations ──
  // Filter out zero-filled dates (days with no real GSC data)
  const realGsc = gsc.filter(d => d.clicks > 0 || d.impressions > 0);
  if (realGsc.length > 0) {
    const annotations = detectAnomalies(realGsc, "clicks");
    const firstDate = realGsc[0].date;
    const lastDate = realGsc[realGsc.length - 1].date;
    const dataNote = realGsc.length < gsc.length ? ` · GSC data from ${firstDate}` : "";
    charts.push({
      id: "chart_gsc", type: realGsc.length >= 3 ? "composed" : "bar",
      title: "Organic Search Performance",
      subtitle: `${realGsc.length} days of data · ${firstDate} to ${lastDate}${annotations.length > 0 ? ` · ${annotations.length} event${annotations.length === 1 ? "" : "s"}` : ""}${dataNote}`,
      xAxis: { label: "Date", key: "date" },
      yAxes: [
        { label: "Clicks", key: "clicks", color: "#3b82f6", type: "bar" },
        { label: "Impressions", key: "impressions", color: "#94a3b8", type: "line" },
      ],
      data: realGsc.map(d => ({ date: d.date, clicks: d.clicks, impressions: d.impressions })),
      annotations,
      source: "GSC",
    });
  }

  // ── 2. Traffic & Conversions ──
  if (ga4.length > 0) {
    const annotations = detectAnomalies(ga4, "sessions");
    charts.push({
      id: "chart_ga4", type: "line",
      title: "Traffic & Conversions",
      subtitle: `${period.start} to ${period.end} · GA4 daily view`,
      xAxis: { label: "Date", key: "date" },
      yAxes: [
        { label: "Sessions", key: "sessions", color: "#8b5cf6" },
        { label: "Conversions", key: "conversions", color: "#10b981" },
      ],
      data: ga4.map(d => ({ date: d.date, sessions: d.sessions, conversions: d.conversions })),
      annotations,
      source: "GA4",
    });
  }

  // ── 3. Weekly search performance ──
  if (gsc.length >= 7) {
    const currentWeekly = aggregateWeeklyValues(gsc, "clicks");
    const currentWeeklyImp = aggregateWeeklyValues(gsc, "impressions");

    // Filter out weeks with no meaningful data (< 5 clicks AND < 50 impressions)
    const meaningfulWeeks: Array<{ week: string; current: number; previous: number }> = [];
    const prevWeekly = prevGsc.length >= 7 ? aggregateWeeklyValues(prevGsc, "clicks") : [];
    const hasPrevData = prevWeekly.some(v => v > 0);

    for (let i = 0; i < currentWeekly.length; i++) {
      if (currentWeekly[i] >= 5 || (currentWeeklyImp[i] ?? 0) >= 50) {
        meaningfulWeeks.push({
          week: `Week ${meaningfulWeeks.length + 1}`,
          current: currentWeekly[i],
          previous: hasPrevData ? (prevWeekly[i] ?? 0) : 0,
        });
      }
    }

    if (meaningfulWeeks.length >= 2) {
      const yAxes: ChartSpec["yAxes"] = [{ label: "Current", key: "current", color: "#3b82f6", type: "bar" as const }];
      if (hasPrevData) yAxes.push({ label: "Previous", key: "previous", color: "#cbd5e1", type: "bar" as const });

      charts.push({
        id: "chart_wow", type: "bar",
        title: "Weekly Search Performance",
        subtitle: hasPrevData ? "Current period vs previous period (organic clicks)" : `Organic clicks per week (${meaningfulWeeks.length} weeks with data)`,
        xAxis: { label: "Week", key: "week" },
        yAxes,
        data: meaningfulWeeks,
        source: "GSC",
      });
    }
  }

  // ── 4. Top Pages by Clicks with change indicators ──
  if (topPages.length > 0) {
    // Build a map of previous clicks by page url
    const prevClicksMap = new Map<string, number>();
    for (const p of prevTopPages) {
      prevClicksMap.set(p.pageUrl, (prevClicksMap.get(p.pageUrl) ?? 0) + p.clicks);
    }

    const topPagesData = topPages.slice(0, 8).map(p => {
      let path: string;
      try { path = new URL(p.pageUrl).pathname || "/"; } catch { path = p.pageUrl; }
      const shortPath = path.length > 30 ? path.slice(0, 27) + "…" : path;
      const prevClicks = prevClicksMap.get(p.pageUrl) ?? 0;
      return {
        page: shortPath,
        clicks: p.clicks,
        previous: prevClicks,
      };
    });

    const hasPrevPages = topPagesData.some(d => d.previous > 0);
    const topPagesYAxes: ChartSpec["yAxes"] = [{ label: "Current", key: "clicks", color: "#3b82f6", type: "bar" as const }];
    if (hasPrevPages) topPagesYAxes.push({ label: "Previous", key: "previous", color: "#cbd5e1", type: "bar" as const });

    charts.push({
      id: "chart_top_pages", type: "bar",
      title: "Top Pages by Organic Clicks",
      subtitle: hasPrevPages ? "Current period vs prior period" : "Current period",
      xAxis: { label: "Page", key: "page" },
      yAxes: topPagesYAxes,
      data: topPagesData,
      source: "GSC",
    });
  }

  // ── 5. CTR distribution chart (only pages with 50+ impressions) ──
  const ctrQualified = topPages.filter(p => p.impressions >= 50);
  if (ctrQualified.length >= 3) {
    const ctrData = ctrQualified.slice(0, 10).map((p, i) => {
      let path: string;
      try { path = new URL(p.pageUrl).pathname || "/"; } catch { path = p.pageUrl; }
      const ctrPct = p.impressions > 0 ? (p.clicks / p.impressions) * 100 : 0;
      return {
        page: path.length > 25 ? path.slice(0, 22) + "…" + i : path,
        ctr: Math.round(ctrPct * 10) / 10,
        position: Math.round(p.position * 10) / 10,
      };
    });
    charts.push({
      id: "chart_ctr_position", type: "bar",
      title: "CTR by Top Page",
      subtitle: "Click-through rate (pages with 50+ impressions only)",
      xAxis: { label: "Page", key: "page" },
      yAxes: [
        { label: "CTR %", key: "ctr", color: "#8b5cf6", type: "bar" },
      ],
      data: ctrData,
      source: "GSC",
    });
  }

  return charts;
}

function aggregateWeeklyValues(data: GscPoint[] | Ga4Point[], key: string): number[] {
  const weeks: number[] = [];
  for (let i = 0; i < data.length; i += 7) {
    const weekSlice = data.slice(i, i + 7);
    weeks.push(weekSlice.reduce((s, d) => s + (((d as unknown as Record<string, number>)[key]) ?? 0), 0));
  }
  return weeks;
}

// ─── Table Builder ──────────────────────────────────────────────────────────

function buildTables(
  gsc: GscPoint[],
  _ga4: Ga4Point[],
  topPages: GscPageRow[],
): TableSpec[] {
  const tables: TableSpec[] = [];

  // Weekly search performance
  if (gsc.length > 0) {
    const weeks = aggregateWeekly(gsc, ["clicks", "impressions"]);
    if (weeks.length > 1) {
      tables.push({
        id: "table_weekly_gsc", title: "Weekly Search Performance",
        columns: [
          { key: "week", label: "Week", align: "left" },
          { key: "clicks", label: "Clicks", align: "right", format: "number" },
          { key: "impressions", label: "Impressions", align: "right", format: "number" },
        ],
        rows: weeks, source: "GSC",
      });
    }
  }

  // Top pages table
  if (topPages.length > 0) {
    tables.push({
      id: "table_top_pages",
      title: "Top Pages by Organic Clicks",
      columns: [
        { key: "page", label: "Page", align: "left" },
        { key: "clicks", label: "Clicks", align: "right", format: "number" },
        { key: "impressions", label: "Impressions", align: "right", format: "number" },
        { key: "ctr", label: "CTR", align: "right", format: "percent" },
        { key: "position", label: "Avg Pos", align: "right", format: "number" },
      ],
      rows: topPages.slice(0, 10).map(p => {
        let path: string;
        try { path = new URL(p.pageUrl).pathname || "/"; } catch { path = p.pageUrl; }
        const ctrPct = p.impressions >= 50 ? Math.round((p.clicks / p.impressions) * 1000) / 10 : 0;
        return {
          page: path,
          clicks: p.clicks,
          impressions: p.impressions,
          ctr: ctrPct,
          position: Math.round(p.position * 10) / 10,
        };
      }),
      source: "GSC",
      sortBy: "clicks",
      sortDir: "desc",
    });
  }

  return tables;
}

function aggregateWeekly(data: Array<Record<string, string | number>>, sumKeys: string[]): Array<Record<string, string | number>> {
  const weeks: Map<string, Record<string, number>> = new Map();
  for (const row of data) {
    const d = new Date(row.date as string);
    const weekStart = new Date(d);
    weekStart.setDate(d.getDate() - d.getDay());
    const key = weekStart.toISOString().split("T")[0];
    const existing = weeks.get(key) ?? {};
    for (const k of sumKeys) existing[k] = (existing[k] ?? 0) + (row[k] as number ?? 0);
    weeks.set(key, existing);
  }
  return [...weeks.entries()].map(([week, vals]) => ({ week: `Week of ${week}`, ...vals }));
}

// ─── Timeline Builder ───────────────────────────────────────────────────────

function buildTimeline(findings: Finding[], research?: ResearchContext): TimelineEvent[] {
  const events: TimelineEvent[] = findings
    .filter(f => f.type === "anomaly" || f.type === "risk")
    .map(f => ({
      date: f.affectedPeriod ?? new Date().toISOString().split("T")[0],
      type: f.type === "anomaly" ? "anomaly" as const : "event" as const,
      title: f.title,
      description: f.description,
      impact: f.severity === "critical" || f.severity === "high" ? "negative" as const : "neutral" as const,
      source: f.evidence,
    }));

  // Add external events from research (algorithm updates, industry events)
  if (research) {
    const timelineEvidence = research.evidence.filter(
      e => e.sectionMapping.includes("timeline") && e.relevance === "high"
    );
    for (const ev of timelineEvidence.slice(0, 3)) {
      events.push({
        date: ev.dateCaptured.split("T")[0],
        type: "external",
        title: ev.title,
        description: ev.snippet.slice(0, 200),
        impact: "neutral",
        source: ev.sourceName + (ev.sourceUrl ? ` (${ev.sourceUrl})` : ""),
      });
    }
  }

  return events;
}

// ─── Research Enrichment ───────────────────────────────────────────────────

/**
 * Attach citation references from research evidence to findings and recommendations.
 * Matches evidence to sections based on sectionMapping and relevance.
 */
function enrichWithResearch(
  findings: Finding[],
  recommendations: AnalysisRecommendation[],
  research: ResearchContext,
): void {
  const findingEvidence = research.evidence.filter(e => e.sectionMapping.includes("findings"));
  const recEvidence = research.evidence.filter(e => e.sectionMapping.includes("recommendations"));

  // Attach top relevant evidence to findings
  if (findingEvidence.length > 0 && findings.length > 0) {
    // Distribute evidence across findings — high-relevance evidence goes to critical/high findings first
    const sorted = [...findingEvidence].sort((a, b) =>
      a.relevance === "high" && b.relevance !== "high" ? -1 : b.relevance === "high" && a.relevance !== "high" ? 1 : 0
    );
    for (const ev of sorted.slice(0, 6)) {
      // Find the best matching finding
      const target = findings.find(f =>
        f.severity !== "info" && (!f.citations || f.citations.length < 2)
      ) ?? findings[0];
      if (!target) continue;
      const citation: CitationRef = { evidenceId: ev.id, label: ev.citationLabel, url: ev.sourceUrl };
      if (!target.citations) target.citations = [];
      target.citations.push(citation);
    }
  }

  // Attach evidence to recommendations
  if (recEvidence.length > 0 && recommendations.length > 0) {
    for (const ev of recEvidence.slice(0, 4)) {
      const target = recommendations.find(r =>
        !r.citations || r.citations.length < 2
      ) ?? recommendations[0];
      if (!target) continue;
      const citation: CitationRef = { evidenceId: ev.id, label: ev.citationLabel, url: ev.sourceUrl };
      if (!target.citations) target.citations = [];
      target.citations.push(citation);
    }
  }
}

// ─── Predictions Builder ────────────────────────────────────────────────────

function buildPredictions(
  gsc: GscPoint[],
  ga4: Ga4Point[],
  yoyGsc: GscPoint[],
  daySpan: number,
): Prediction[] {
  const predictions: Prediction[] = [];

  // ── 1. Traffic trajectory (next 30 days) ──
  if (gsc.length >= 7) {
    const recentClicks = gsc.slice(-7).reduce((s, d) => s + d.clicks, 0);
    const earlierClicks = gsc.length >= 14 ? gsc.slice(-14, -7).reduce((s, d) => s + d.clicks, 0) : recentClicks;
    const weeklyGrowthRate = earlierClicks > 0 ? (recentClicks - earlierClicks) / earlierClicks : 0;
    // Apply growth rate over next 4 weeks, compounding
    const projected = Math.round(recentClicks * 4 * (1 + weeklyGrowthRate * 0.5));
    const currentMonthly = Math.round((gsc.reduce((s, d) => s + d.clicks, 0) / gsc.length) * 30);

    const direction = projected > currentMonthly ? "up" : projected < currentMonthly ? "down" : "flat";
    const outlook = weeklyGrowthRate > 0.05
      ? "Growth momentum"
      : weeklyGrowthRate < -0.05
        ? "At risk"
        : "Stable";

    predictions.push({
      metric: "Monthly Organic Clicks",
      current: currentMonthly,
      predicted: projected,
      confidence: gsc.length >= 28 ? "medium" : "low",
      timeframe: "Next 30 days",
      predictionType: "trajectory",
      outlook,
      basis: `Extrapolated from ${gsc.length}-day trend. Recent 7-day volume: ${recentClicks.toLocaleString()}; ${gsc.length >= 14 ? `previous 7-day volume: ${earlierClicks.toLocaleString()}` : "single-week sample only"}.`,
      caveat: `${gsc.length < 28 ? "Limited data window — " : ""}Does not account for algorithm updates, seasonality, or competitor moves. Directional only.`,
    });
  }

  // ── 2. Seasonal prediction (if YoY data exists) ──
  if (yoyGsc.length >= 14 && gsc.length >= 7) {
    const currentDaily = gsc.reduce((s, d) => s + d.clicks, 0) / gsc.length;
    const yoyDaily = yoyGsc.reduce((s, d) => s + d.clicks, 0) / yoyGsc.length;
    const yoyRatio = yoyDaily > 0 ? currentDaily / yoyDaily : 1;

    // Next 90-day projection using YoY pattern
    const yoyProjected = Math.round(yoyDaily * yoyRatio * 90);
    const currentProjected90 = Math.round(currentDaily * 90);

    predictions.push({
      metric: "90-Day Organic Clicks (Seasonal)",
      current: currentProjected90,
      predicted: yoyProjected,
      confidence: yoyRatio > 0.8 && yoyRatio < 1.3 ? "medium" : "low",
      timeframe: "Next 90 days",
      predictionType: "seasonal",
      outlook: yoyRatio > 1.1 ? "Seasonal uplift likely" : yoyRatio < 0.9 ? "Seasonal headwind" : "Seasonal plateau",
      basis: `YoY data shows ${yoyDaily.toFixed(0)} clicks/day in the same period last year vs ${currentDaily.toFixed(0)} currently. Projection applies the YoY ratio (${yoyRatio.toFixed(2)}x) to the next 90 days.`,
      caveat: "Assumes seasonal patterns repeat. Major algorithm updates or business changes since last year are not factored in.",
    });
  }

  // ── 3. Risk prediction (based on trend direction + volatility) ──
  if (gsc.length >= 14) {
    const trendDirection = detectTrend(gsc.map(d => d.clicks));
    if (trendDirection === "declining" || trendDirection === "volatile") {
      const currentWeek = gsc.slice(-7).reduce((s, d) => s + d.clicks, 0);
      const projected30Risk = trendDirection === "declining"
        ? Math.round(currentWeek * 4 * 0.85) // 15% further decline
        : Math.round(currentWeek * 4);

      predictions.push({
        metric: "Traffic Risk Exposure",
        current: currentWeek * 4,
        predicted: projected30Risk,
        confidence: "medium",
        timeframe: "Next 30 days",
        predictionType: "risk",
        outlook: trendDirection === "declining" ? "Downside risk" : "Volatility risk",
        basis: trendDirection === "declining"
          ? "Within-period trend shows sustained decline. Without intervention, expect continued downward pressure on click volume."
          : "Within-period volatility suggests ranking or crawl instability. Actual next-month volume has a wider confidence interval than usual.",
        caveat: "Risk predictions are directional; exact magnitude depends on whether underlying causes are addressed.",
      });
    }
  }

  // ── 4. Conversion trajectory (if GA4 data sufficient) ──
  if (ga4.length >= 14) {
    const recentConv = ga4.slice(-7).reduce((s, d) => s + d.conversions, 0);
    const currentMonthlyConv = Math.round((ga4.reduce((s, d) => s + d.conversions, 0) / ga4.length) * 30);
    const projectedConv = Math.round(recentConv * 4);
    if (currentMonthlyConv > 0 || projectedConv > 0) {
      predictions.push({
        metric: "Monthly Conversions",
        current: currentMonthlyConv,
        predicted: projectedConv,
        confidence: "medium",
        timeframe: "Next 30 days",
        predictionType: "trajectory",
        outlook: projectedConv > currentMonthlyConv ? "Growing" : projectedConv < currentMonthlyConv * 0.9 ? "At risk" : "Stable",
        basis: `Based on last 7-day conversion volume (${recentConv}) vs the full-period daily average.`,
        caveat: "Assumes session volume holds. Large traffic swings will move this projection proportionally.",
      });
    }
  }

  return predictions;
}

// ─── Recommendations Builder ────────────────────────────────────────────────

/** Map severity + effort to priority label */
function priorityLabelFor(priority: "critical" | "high" | "medium" | "low"): "P1" | "P2" | "P3" | "P4" {
  if (priority === "critical") return "P1";
  if (priority === "high") return "P2";
  if (priority === "medium") return "P3";
  return "P4";
}

function effortLabelFor(effort: "low" | "medium" | "high"): "quick-win" | "moderate" | "major" {
  if (effort === "low") return "quick-win";
  if (effort === "medium") return "moderate";
  return "major";
}

function buildRecommendations(
  findings: Finding[],
  audit: { errorCount: number; warningCount: number } | null,
): AnalysisRecommendation[] {
  const recs: AnalysisRecommendation[] = [];

  // ── Recommendation from each actionable finding ──
  for (const f of findings) {
    if (f.severity === "info") continue;

    // Technical errors
    if (f.id === "f_crawl_health" || f.id === "f_technical_errors") {
      recs.push({
        priority: "critical",
        priorityLabel: "P1",
        category: "technical",
        title: "Resolve critical technical errors first",
        description: "Technical debt compounds. Each unresolved critical error blocks crawl efficiency across the entire site and makes new content harder to rank. Fixing errors before warnings is the fastest way to unlock the rest of your SEO investment.",
        expectedImpact: "Improved crawl efficiency, faster indexing of new content, and a better foundation for all other SEO work.",
        effort: "medium",
        effortLabel: "moderate",
        relatedFinding: f.id,
        steps: [
          "Open the Technical Audit and sort issues by severity descending.",
          "Fix any crawl blockers (robots.txt, noindex, blocked resources) first.",
          "Address broken links, redirect chains, and 4xx/5xx errors next.",
          "Re-run the audit after each batch of fixes to confirm resolution.",
        ],
      });
      continue;
    }

    // Click drop investigation
    if (f.id === "f_clicks_drop" || f.id === "f_sessions_drop") {
      recs.push({
        priority: "critical",
        priorityLabel: "P1",
        category: "performance",
        title: `Diagnose the ${f.affectedMetric?.toLowerCase() ?? "traffic"} drop immediately`,
        description: "Traffic drops can be ranking losses, algorithm updates, tracking issues, or seasonal. Isolate the cause before prescribing a fix — blindly adding content or links can make the wrong problem worse.",
        expectedImpact: "Stopping the bleed and ideally recovering the lost traffic within 30–60 days.",
        effort: "medium",
        effortLabel: "moderate",
        relatedFinding: f.id,
        steps: [
          "Compare week-by-week traffic against the previous period to isolate when the drop started.",
          "Check GSC for query-level position changes on top-clicked pages.",
          "Verify nothing has changed in your tracking setup (GA4, site tag, filters).",
          "Cross-reference the drop date against Google's algorithm update timeline.",
          "Identify the top 10 pages that lost the most clicks and audit them for recent content or on-page changes.",
        ],
      });
      continue;
    }

    // CTR opportunity
    if (f.id === "f_weak_ctr" || f.id === "f_weak_ctr_pages") {
      recs.push({
        priority: "high",
        priorityLabel: "P2",
        category: "content",
        title: "Rewrite title tags on top-impression pages",
        description: "Title tags are the single highest-leverage CTR lever in SEO. A well-crafted title can double click-through without any ranking improvement. Focus on pages that already earn impressions — the traffic is there, you're just not capturing it.",
        expectedImpact: "Each 1pp of CTR improvement yields approximately 1 additional click per 100 impressions — on high-impression pages this compounds quickly.",
        effort: "low",
        effortLabel: "quick-win",
        relatedFinding: f.id,
        steps: [
          "Pull the top 20 pages by impressions from GSC.",
          "For each page, compare the current title tag against the winning SERP competitor.",
          "Rewrite to include a number, action verb, year, or brand differentiator.",
          "Keep titles under 60 characters to avoid truncation.",
          "Monitor CTR weekly after publishing — expect changes to show in 2–4 weeks.",
        ],
      });
      continue;
    }

    // Opportunity pages (position 5-15)
    if (f.id === "f_opportunity_pages") {
      recs.push({
        priority: "high",
        priorityLabel: "P2",
        category: "opportunity",
        title: "Push opportunity pages (positions 5–15) into the top 3",
        description: "Pages ranking in the second half of page 1 are earning impressions but capturing only a fraction of the available clicks. Moving them into the top 3 is the fastest organic growth lever you have — the content already ranks, you just need to strengthen its signals.",
        expectedImpact: "Moving a single page from position 8 to position 3 typically 4–6x its click volume.",
        effort: "medium",
        effortLabel: "moderate",
        relatedFinding: f.id,
        steps: [
          "List every opportunity page from the finding.",
          "For each, identify the target query and the 1–2 competing pages in the top 3.",
          "Audit the top-ranking page's content depth, heading structure, and internal link profile.",
          "Update your page to match or exceed each criterion.",
          "Build 3–5 new internal links pointing to the page with exact-match anchor text where natural.",
          "Consider adding schema markup (FAQ, HowTo, Article) if missing.",
        ],
      });
      continue;
    }

    // Position drop
    if (f.id === "f_position_shift") {
      recs.push({
        priority: "high",
        priorityLabel: "P2",
        category: "performance",
        title: "Identify and reclaim lost rankings",
        description: "Position drops usually precede click drops. Acting before the click data bottoms out gives you the best chance to reverse the trend while the pages still have ranking momentum.",
        expectedImpact: "Recovering even half the lost positions should stabilise click volume.",
        effort: "medium",
        effortLabel: "moderate",
        relatedFinding: f.id,
        steps: [
          "In GSC, sort by position change descending to find queries that lost the most positions.",
          "Cluster lost queries by page to find the 5 worst-affected pages.",
          "Review each page for recent content changes, removed sections, or competitor updates.",
          "Refresh the content, republish, and request re-indexing via GSC.",
        ],
      });
      continue;
    }

    // Bounce rate / engagement
    if (f.id === "f_high_bounce") {
      recs.push({
        priority: "medium",
        priorityLabel: "P3",
        category: "content",
        title: "Improve landing-page intent match and page speed",
        description: "High bounce rate means visitors are arriving and immediately leaving. This is usually a mismatch between query intent and page content, or a slow first paint that loses users before they engage.",
        expectedImpact: "Reducing bounce rate by 10pp typically correlates with a 15–20% lift in conversions for content-driven pages.",
        effort: "medium",
        effortLabel: "moderate",
        relatedFinding: f.id,
        steps: [
          "Run PageSpeed Insights on the top 5 entry pages and fix any issues that hurt LCP or FID.",
          "Compare the H1 and opening paragraph of each page against the top ranking query.",
          "Move the answer to the query above the fold.",
          "Add clear, contextually relevant internal links to keep visitors exploring.",
        ],
      });
      continue;
    }

    // Conversion rate decay
    if (f.id === "f_conv_decay") {
      recs.push({
        priority: "critical",
        priorityLabel: "P1",
        category: "performance",
        title: "Isolate the conversion-rate decay",
        description: "A dropping conversion rate with stable sessions means the traffic mix or the conversion surface changed. This is a revenue-impacting issue that needs immediate triage, not a content fix.",
        expectedImpact: "Recovering the lost conversion rate would restore the revenue gap directly.",
        effort: "low",
        effortLabel: "quick-win",
        relatedFinding: f.id,
        steps: [
          "Verify the conversion event is firing correctly on all key pages.",
          "Check whether traffic source mix has shifted (organic → paid, or new referrals dominating).",
          "Review any recent changes to forms, checkout, CTAs, or landing pages.",
          "Run a 3-day A/B test reverting any recent changes on the highest-traffic converting page.",
        ],
      });
      continue;
    }

    // Generic fallback per-finding rec
    if (f.category && f.type !== "insight") {
      recs.push({
        priority: (f.severity === "critical" || f.severity === "high" ? f.severity : "medium") as "critical" | "high" | "medium",
        priorityLabel: priorityLabelFor((f.severity === "critical" || f.severity === "high" ? f.severity : "medium") as "critical" | "high" | "medium"),
        category: f.category,
        title: `Address: ${f.title}`,
        description: f.recommendedAction ?? `${f.description} ${f.impact ?? ""}`.trim(),
        expectedImpact: f.impact ?? "Addressing this finding should stabilise or improve the affected metric.",
        effort: "medium",
        effortLabel: "moderate",
        relatedFinding: f.id,
      });
    }
  }

  // ── Baseline recommendations if audit exists but no specific findings triggered ──
  if (audit && audit.errorCount > 0 && !recs.some(r => r.category === "technical")) {
    recs.push({
      priority: "high",
      priorityLabel: "P2",
      category: "technical",
      title: `Fix ${audit.errorCount} technical error${audit.errorCount === 1 ? "" : "s"}`,
      description: "Resolving critical technical issues improves crawl efficiency and search visibility across the entire site.",
      expectedImpact: "Faster indexing, better crawl budget utilisation, higher overall domain authority.",
      effort: "medium",
      effortLabel: "moderate",
      steps: [
        "Open Technical Audit and review critical errors.",
        "Fix in order of affected-URL count.",
        "Re-run the audit to confirm each resolution.",
      ],
    });
  }

  // Ensure we always return at least 5 recommendations when data is sparse
  if (recs.length < 5) {
    const alwaysOn: AnalysisRecommendation[] = [
      {
        priority: "medium",
        priorityLabel: "P3",
        category: "content",
        title: "Refresh top-performing evergreen content",
        description: "Pages that have historically driven traffic lose freshness signals over time. A quarterly refresh — updated statistics, added sections, new year in titles — is one of the lowest-effort ways to preserve rankings.",
        expectedImpact: "Refreshed content typically regains 10–25% of lost traffic within 4–6 weeks.",
        effort: "low",
        effortLabel: "quick-win",
        steps: [
          "List the top 10 organic pages by clicks.",
          "For each, update statistics, add a new H2 covering a recent development, and update the year in the title if applicable.",
          "Republish with an updated `datePublished` or `dateModified`.",
        ],
      },
      {
        priority: "medium",
        priorityLabel: "P3",
        category: "content",
        title: "Publish content targeting opportunity keywords",
        description: "Identify keywords where you rank 11–30 (page 2–3) and publish supporting content that can push existing pages into the top 10.",
        expectedImpact: "New content clusters typically move associated target pages 3–5 positions within 8–12 weeks.",
        effort: "high",
        effortLabel: "major",
        steps: [
          "Export keywords from GSC with position 11–30 and >50 impressions.",
          "Cluster by topic and identify which could be supported by a new cluster page.",
          "Publish 2–3 supporting articles per cluster.",
          "Internally link from new articles to the target ranking page.",
        ],
      },
      {
        priority: "low",
        priorityLabel: "P4",
        category: "technical",
        title: "Audit internal linking structure",
        description: "Strong internal linking distributes authority to the pages you want to rank. Most sites have orphan pages or deep-linked content that would rank higher with better internal links.",
        expectedImpact: "Improved internal linking typically lifts page 2 content into page 1 for 10–20% of affected queries.",
        effort: "medium",
        effortLabel: "moderate",
        steps: [
          "Identify orphan pages (no internal links pointing to them).",
          "Add contextual internal links from high-authority pages to orphan/weak pages.",
          "Use exact-match or partial-match anchor text where natural.",
        ],
      },
    ];
    for (const rec of alwaysOn) {
      if (recs.length >= 5) break;
      if (!recs.some(r => r.title === rec.title)) recs.push(rec);
    }
  }

  // Sort by priority (critical → low)
  const order: Record<string, number> = { critical: 0, high: 1, medium: 2, low: 3 };
  recs.sort((a, b) => (order[a.priority] ?? 9) - (order[b.priority] ?? 9));

  return recs;
}

// ─── Caveats Builder ────────────────────────────────────────────────────────

function buildCaveats(
  sources: AnalysisSource[],
  gsc: Array<unknown>,
  ga4: Array<unknown>
): Caveat[] {
  const caveats: Caveat[] = [];

  const missingSources = sources.filter(s => s.status === "missing");
  if (missingSources.length > 0) {
    caveats.push({
      type: "data_gap", severity: "warning",
      title: `${missingSources.length} data source${missingSources.length > 1 ? "s" : ""} not connected`,
      description: `${missingSources.map(s => s.label).join(", ")} ${missingSources.length > 1 ? "are" : "is"} not connected. Analysis is based on available sources only.`,
      affectedSections: ["kpis", "charts", "findings"],
    });
  }

  const awaitingSources = sources.filter(s => s.status === "partial" && (s.recordCount ?? 0) === 0);
  if (awaitingSources.length > 0) {
    caveats.push({
      type: "data_gap", severity: "info",
      title: `${awaitingSources.map(s => s.label).join(", ")} connected but awaiting data`,
      description: `${awaitingSources.map(s => s.label).join(" and ")} ${awaitingSources.length > 1 ? "are" : "is"} connected but no data has synced yet for the selected period. Data will appear in future analyses once syncing completes.`,
      affectedSections: ["kpis", "charts", "findings"],
    });
  }

  if (gsc.length > 0 && gsc.length < 14) {
    caveats.push({
      type: "confidence",
      severity: gsc.length < 7 ? "info" : "info",
      title: "Analysis is still warming up",
      description: `Analysis is based on ${gsc.length} day${gsc.length === 1 ? "" : "s"} of search data. As more data syncs over the coming days, you'll unlock trend analysis, seasonal comparisons, predictive forecasting, and deeper insights. Check back in a week for a much richer analysis.`,
      affectedSections: ["findings", "predictions"],
    });
  }

  return caveats;
}

// ─── Summary Builder (top-of-report one-liner) ─────────────────────────────

/**
 * Build the short top-of-report summary (one-liner).
 * This must read like a senior SEO strategist would summarise the finding to
 * a client — not a bare list of numbers. It leads with the insight (what it
 * means), then references the specific metric that backs it.
 */
function buildSummary(
  domain: string,
  period: { start: string; end: string },
  kpis: KpiItem[],
  findings: Finding[],
  criticalCount: number,
  opportunityCount: number,
): string {
  const clicksKpi = kpis.find(k => k.label === "Organic Clicks");
  const impsKpi = kpis.find(k => k.label === "Impressions");
  const sessionsKpi = kpis.find(k => k.label === "Sessions");
  const ctrKpi = kpis.find(k => k.label === "CTR");

  // Limited-data fallback: still sound insightful and forward-looking
  if (kpis.length === 0) {
    return `Analysis completed for ${domain} (${period.start} → ${period.end}). Connect Google Search Console and Analytics to unlock the full strategic view — once data starts flowing you'll get trend analysis, YoY comparisons, and prioritised recommendations.`;
  }

  // Build the strategic lead sentence — never just report numbers
  const clicks = clicksKpi?.change;
  const imps = impsKpi?.change;

  // Case 1: Impressions growing faster than clicks → visibility/CTR gap (most common insight)
  if (clicks && imps && imps.direction === "up" && imps.value > Math.abs(clicks.value)) {
    return `${domain} is expanding its search visibility faster than its click capture — impressions ${imps.value > 0 ? "grew" : "shifted"} ${Math.abs(imps.value)}% while clicks ${clicks.direction === "up" ? `only grew ${clicks.value}%` : clicks.direction === "down" ? `fell ${Math.abs(clicks.value)}%` : "held flat"} over ${period.start} to ${period.end}${ctrKpi ? `, suggesting CTR optimisation (currently ${ctrKpi.value}) is the single highest-leverage lever right now` : ""}.`;
  }

  // Case 2: Clear growth signal
  if (clicks && clicks.direction === "up" && clicks.value >= 5) {
    return `${domain} is in a growth phase — organic clicks are up ${clicks.value}% to ${clicksKpi!.value} over ${period.start} to ${period.end}${imps && imps.direction === "up" ? `, supported by a ${imps.value}% rise in impressions` : ""}${opportunityCount > 0 ? `, and ${opportunityCount} opportunit${opportunityCount === 1 ? "y" : "ies"} for additional lift ${opportunityCount === 1 ? "is" : "are"} visible in the data` : ""}.`;
  }

  // Case 3: Clear decline signal
  if (clicks && clicks.direction === "down" && Math.abs(clicks.value) >= 5) {
    return `${domain} is facing a traffic contraction — organic clicks fell ${Math.abs(clicks.value)}% to ${clicksKpi!.value} over ${period.start} to ${period.end}${criticalCount > 0 ? `, with ${criticalCount} critical finding${criticalCount === 1 ? "" : "s"} requiring immediate attention` : ""}. The priority this week is diagnosing which queries lost position and whether the drop is algorithmic, technical, or content-driven.`;
  }

  // Case 4: Sessions but no GSC detail
  if (!clicksKpi && sessionsKpi?.change) {
    const dir = sessionsKpi.change.direction;
    return `${domain} recorded ${sessionsKpi.value} sessions over ${period.start} to ${period.end}${dir !== "flat" ? `, ${dir === "up" ? "up" : "down"} ${Math.abs(sessionsKpi.change.value)}% vs the previous period` : ""}. Connect Search Console to unlock query-level visibility and deeper SEO insight.`;
  }

  // Case 5: Stable period — still make it sound strategic
  if (criticalCount === 0 && opportunityCount > 0) {
    return `${domain} is in a stable period with ${opportunityCount} growth opportunit${opportunityCount === 1 ? "y" : "ies"} available in the data — the right conditions to invest in content and technical upgrades rather than firefighting.`;
  }

  if (criticalCount > 0) {
    return `${domain} has ${criticalCount} critical finding${criticalCount === 1 ? "" : "s"} that should be addressed this week — the fastest way to stabilise performance and unlock the remaining ${findings.length - criticalCount} supporting insight${findings.length - criticalCount === 1 ? "" : "s"} below.`;
  }

  // Default: stable with no drama
  return `${domain} is performing within expected ranges for ${period.start} to ${period.end}${clicksKpi ? ` (${clicksKpi.value} organic clicks)` : ""}. Stable periods are the right time to compound gains through content refreshes, internal linking, and technical cleanup.`;
}

// ─── Narrative Builder ──────────────────────────────────────────────────────

function buildNarrative(
  domain: string,
  period: { start: string; end: string },
  kpis: KpiItem[],
  findings: Finding[],
  recs: AnalysisRecommendation[],
  caveats: Caveat[],
  research?: ResearchContext,
  predictions?: Prediction[],
): { executiveSummary: string; detailedReport: string; methodology: string } {
  // ── Executive summary (3–5 sentences, strategic, specific, forward-looking) ──
  const clicksKpi = kpis.find(k => k.label === "Organic Clicks");
  const impsKpi = kpis.find(k => k.label === "Impressions");
  const sessionsKpi = kpis.find(k => k.label === "Sessions");
  const convKpi = kpis.find(k => k.label === "Conversions");
  const ctrKpi = kpis.find(k => k.label === "CTR");
  const posKpi = kpis.find(k => k.label === "Avg Position");

  const criticalFindings = findings.filter(f => f.severity === "critical" || f.severity === "high");
  const opportunityFindings = findings.filter(f => f.category === "opportunity");

  const lines: string[] = [];

  // Line 1: Lead with the most important takeaway
  if (clicksKpi?.change) {
    const dir = clicksKpi.change.direction;
    if (dir === "up") {
      lines.push(`${domain} grew organic clicks by ${Math.abs(clicksKpi.change.value)}% to ${clicksKpi.value} over ${period.start} to ${period.end}${clicksKpi.yoyChange ? `, a ${clicksKpi.yoyChange.value > 0 ? "lift" : "contraction"} of ${Math.abs(clicksKpi.yoyChange.value)}% year-over-year` : ""}.`);
    } else if (dir === "down") {
      lines.push(`${domain} saw organic clicks decline ${Math.abs(clicksKpi.change.value)}% to ${clicksKpi.value} over ${period.start} to ${period.end}${clicksKpi.yoyChange ? `, ${clicksKpi.yoyChange.value > 0 ? "still up" : "compounding a"} ${Math.abs(clicksKpi.yoyChange.value)}% YoY ${clicksKpi.yoyChange.value > 0 ? "gain" : "decline"}` : ""}.`);
    } else {
      lines.push(`${domain} held organic clicks flat at ${clicksKpi.value} over ${period.start} to ${period.end}.`);
    }
  } else if (sessionsKpi?.change) {
    lines.push(`${domain} recorded ${sessionsKpi.value} sessions over ${period.start} to ${period.end}${sessionsKpi.change.direction !== "flat" ? `, ${sessionsKpi.change.direction === "up" ? "up" : "down"} ${Math.abs(sessionsKpi.change.value)}% period-over-period` : ""}.`);
  } else {
    lines.push(`${domain} analysis for ${period.start} to ${period.end} — ${findings.length} finding${findings.length === 1 ? "" : "s"} identified across ${kpis.length} tracked metrics.`);
  }

  // Line 2: Explain the "why" — reference impressions + position + CTR
  if (impsKpi?.change && clicksKpi?.change) {
    if (impsKpi.change.value > 5 && clicksKpi.change.value < 0) {
      lines.push(`Impressions grew ${impsKpi.change.value}% while clicks declined — the visibility engine is working, but CTR has weakened${posKpi ? ` (average position ${posKpi.value})` : ""}. This is a signal that title tags and meta descriptions aren't capturing the expanding SERP footprint.`);
    } else if (impsKpi.change.value > 5 && clicksKpi.change.value > 5) {
      lines.push(`The growth is healthy — impressions expanded ${impsKpi.change.value}% alongside clicks, meaning both visibility and click capture improved in parallel${posKpi?.change && posKpi.change.value < 0 ? ` and average position improved ${Math.abs(posKpi.change.value)} spots` : ""}.`);
    } else if (impsKpi.change.value < -5 && clicksKpi.change.value < 0) {
      lines.push(`Both impressions and clicks contracted together, suggesting ranking losses on high-value queries rather than a CTR issue${posKpi?.change && posKpi.change.value > 0 ? ` — average position slipped ${posKpi.change.value} spots` : ""}.`);
    } else if (ctrKpi && ctrKpi.rawValue !== undefined && ctrKpi.rawValue < 0.02) {
      lines.push(`CTR of ${ctrKpi.value} is below the 2% threshold where most sites operate — even modest title and snippet optimisation could materially lift click volume without any ranking gains.`);
    }
  }

  // Line 3: Call out the most important finding
  if (criticalFindings.length > 0) {
    const topFinding = criticalFindings[0];
    lines.push(`The most urgent issue is ${topFinding.title.toLowerCase()}${topFinding.impact ? ` — ${topFinding.impact.toLowerCase()}` : ""}.`);
  } else if (findings.length > 0 && findings[0].severity !== "info") {
    const topFinding = findings[0];
    lines.push(`${topFinding.title}${topFinding.description ? ` — ${topFinding.description.split(".")[0]}.` : "."}`);
  }

  // Line 4: Opportunity or strategic context
  if (opportunityFindings.length > 0) {
    lines.push(`On the growth side, ${opportunityFindings.length} opportunit${opportunityFindings.length === 1 ? "y" : "ies"} ${opportunityFindings.length === 1 ? "is" : "are"} visible in the data — ${opportunityFindings[0].title.toLowerCase()} offers the most immediate lift.`);
  }

  // Line 5: Forward-looking statement
  const trajectoryPred = predictions?.find(p => p.predictionType === "trajectory");
  if (trajectoryPred) {
    const outlook = trajectoryPred.outlook ?? (trajectoryPred.predicted > trajectoryPred.current ? "growing" : "contracting");
    lines.push(`Looking ahead, the ${trajectoryPred.timeframe.toLowerCase()} trajectory points to ${trajectoryPred.predicted.toLocaleString()} ${trajectoryPred.metric.toLowerCase()} (${outlook.toLowerCase()})${criticalFindings.length > 0 ? ", contingent on addressing the critical findings above" : ""}.`);
  } else if (criticalFindings.length > 0) {
    lines.push(`Addressing the ${criticalFindings.length} critical finding${criticalFindings.length === 1 ? "" : "s"} should stabilise the trajectory within 30–60 days.`);
  } else {
    lines.push(`The current trajectory is ${findings.some(f => f.severity === "critical") ? "at risk" : "stable"}; the recommendations section below prioritises the highest-leverage next moves.`);
  }

  const executiveSummary = lines.join(" ");

  // ── Detailed report ──
  const kpiSummary = kpis.map(k => `- **${k.label}**: ${k.value}${k.change ? ` (${k.change.label})` : ""}${k.commentary ? `\n  _${k.commentary}_` : ""}`).join("\n");
  const findingSummary = findings.map(f => {
    const citationText = f.citations && f.citations.length > 0 ? ` ${f.citations.map(c => c.label).join(" ")}` : "";
    return `- **[${f.severity.toUpperCase()}] ${f.title}**: ${f.description}${citationText}${f.recommendedAction ? `\n  _Action: ${f.recommendedAction}_` : ""}`;
  }).join("\n");
  const recSummary = recs.map(r => {
    const citationText = r.citations && r.citations.length > 0 ? ` ${r.citations.map(c => c.label).join(" ")}` : "";
    const stepsText = r.steps && r.steps.length > 0 ? "\n  Steps:\n" + r.steps.map(s => `  1. ${s}`).join("\n") : "";
    return `- **[${r.priorityLabel ?? r.priority.toUpperCase()}] ${r.title}**: ${r.description}${citationText}${stepsText}`;
  }).join("\n");
  const caveatSummary = caveats.map(c => `- ${c.title}: ${c.description}`).join("\n");

  // Sources section
  let sourcesSection = "";
  if (research && research.evidence.length > 0) {
    const webEvidence = research.evidence.filter(e => e.sourceUrl);
    if (webEvidence.length > 0) {
      sourcesSection = "\n\n## External Sources\n" + webEvidence.map(e =>
        `- ${e.citationLabel}: ${e.title} — ${e.sourceUrl}`
      ).join("\n");
    }
  }

  const methodologyBase = "This analysis compares the selected period against the equivalent previous period and (where available) the same period one year prior. Metrics are sourced from connected integrations (Google Search Console, Google Analytics 4) and the latest completed technical audit. Findings are generated by cross-referencing period-over-period changes, within-period trend direction, average position shifts, CTR thresholds, and top-page opportunity analysis. Recommendations are derived from findings and prioritised by impact/effort ratio.";
  const researchMethodology = research && research.stats.queriesSucceeded > 0
    ? ` External research was conducted via ${research.stats.queriesSucceeded} web search quer${research.stats.queriesSucceeded === 1 ? "y" : "ies"}, yielding ${research.stats.evidenceGathered} evidence item(s). Research evidence is used for context and correlation, not as a primary data source.`
    : "";

  return {
    executiveSummary,
    detailedReport: `# Performance Analysis: ${domain}\n## Period: ${period.start} to ${period.end}\n\n## Executive Summary\n${executiveSummary}\n\n## Key Metrics\n${kpiSummary}\n\n## Findings\n${findingSummary || "No significant findings."}\n\n## Recommendations\n${recSummary || "No recommendations at this time."}\n\n## Caveats\n${caveatSummary || "No data caveats."}${sourcesSection}`,
    methodology: methodologyBase + researchMethodology,
  };
}
