/**
 * Brand Report Generator
 * ────────────────────────
 * Produces a structured, client-ready BrandReport by querying every
 * connected data source for a brand and computing three buckets of
 * insight: what's working, what's NOT working, and what needs
 * optimisation.
 *
 * Data sources consulted (each is best-effort — missing data degrades
 * the corresponding section rather than failing the whole report):
 *
 *   1. AI Strategist analysis     → AnalysisJob.result (latest completed)
 *   2. Google Search Console       → GscPage, GscQuery, MetricSnapshot(gsc)
 *   3. Google Analytics 4          → MetricSnapshot(ga4)
 *   4. Technical Audit             → TechnicalAudit + TechnicalIssue
 *   5. Page Lifecycle              → PageRecord + PageStatusChange + PageDailyMetric
 *
 * Wins / declines / opportunities heuristics are pure functions over
 * the raw data — deterministic so the same inputs always yield the
 * same report. All percentage changes are current-vs-previous-period
 * (same length, immediately preceding).
 */

import { prisma } from "@/lib/prisma";
import type { Analysis, Finding, Prediction, AnalysisRecommendation } from "./analysis-schema";

// ═══════════════════════════════════════════════════════════════════════════
// Types
// ═══════════════════════════════════════════════════════════════════════════

export type ReportSectionKey =
  | "executive_summary"
  | "wins"
  | "declines"
  | "opportunities"
  | "ai_insights"
  | "appendix";

export interface ReportPage {
  url: string;
  clicks: number;
  impressions: number;
  ctr: number;        // percentage (0-100)
  position: number;
  previousClicks?: number;
  previousPosition?: number;
  clicksChangePct?: number;
  positionChange?: number;
  isNew?: boolean;
}

export interface ReportKeyword {
  query: string;
  clicks: number;
  impressions: number;
  ctr: number;
  position: number;
  previousClicks?: number;
  previousPosition?: number;
  positionChange?: number;
  clicksChangePct?: number;
}

export interface ReportKpi {
  label: string;
  value: number;
  unit?: string;
  previousValue?: number;
  changePct?: number | null;
  direction?: "up" | "down" | "flat";
}

export interface BrandReport {
  generatedAt: string;
  dateRange: { start: string; end: string; days: number };
  previousDateRange: { start: string; end: string };
  brand: { id: string; name: string; domain: string };
  sources: {
    gsc: boolean;
    ga4: boolean;
    audit: boolean;
    siteTag: boolean;
    analysis: boolean;
  };

  executiveSummary: {
    narrative: string;
    healthScore: number | null;
    kpis: ReportKpi[];
    topWin: string | null;
    topConcern: string | null;
    topOpportunity: string | null;
  };

  wins: {
    topPages: ReportPage[];           // top 10 by clicks
    topKeywords: ReportKeyword[];     // top 10 by clicks
    growingPages: ReportPage[];       // clicks up >20%
    improvingKeywords: ReportKeyword[]; // position up ≥3 spots
    newPages: Array<{ url: string; firstSeenAt: string; clicks: number }>;
    conversionWin: { current: number; previous: number; changePct: number | null } | null;
  };

  declines: {
    decliningPages: ReportPage[];         // clicks down >20%
    decliningKeywords: ReportKeyword[];   // position down ≥3 spots
    technicalIssues: {
      crawlHealth: number;
      errorCount: number;
      warningCount: number;
      criticalIssues: Array<{ title: string; category: string; affected: number; pageUrl: string | null }>;
    } | null;
    pageHealthIssues: {
      pagesWent404: Array<{ url: string; detectedAt: string }>;
      lowCtrHighImpression: ReportPage[];       // >100 imp AND CTR < 1%
    };
    performanceDrops: Array<{ label: string; value: number; changePct: number; direction: "down" }>;
  };

  opportunities: {
    ctrOptimization: Array<{
      url: string;
      position: number;
      currentCtr: number;
      expectedCtr: number;
      impressions: number;
      estimatedClickGain: number;
    }>;
    strikingDistance: Array<{
      // Pages/keywords in positions 4-10 with high impressions — small
      // position boost translates to big click gains.
      target: string;               // page URL or keyword
      type: "page" | "keyword";
      position: number;
      impressions: number;
      currentClicks: number;
      estimatedClicksAtTop3: number;
    }>;
    contentRefresh: Array<{
      url: string;
      currentClicks: number;
      previousClicks: number;
      changePct: number;
      impressions: number;
    }>;
    technicalFixes: Array<{ title: string; category: string; priority: string; affected: number }>;
  };

  aiInsights: {
    available: boolean;
    analysisId: string | null;
    findings: Finding[];
    predictions: Prediction[];
    recommendations: AnalysisRecommendation[];
  };

  appendix: {
    topPages: ReportPage[];       // top 50
    topKeywords: ReportKeyword[]; // top 50
    allTechnicalIssues: Array<{ title: string; severity: string; category: string; affected: number }>;
    statusChanges: Array<{ url: string; fromStatus: number; toStatus: number; detectedAt: string }>;
  };
}

// ═══════════════════════════════════════════════════════════════════════════
// Constants
// ═══════════════════════════════════════════════════════════════════════════

/**
 * Expected CTR by search position — industry averages used to flag
 * pages that are ranking well but underperforming on clicks. Derived
 * from Advanced Web Ranking's click-through-rate study. Values are
 * percentages.
 */
const EXPECTED_CTR_BY_POSITION: Record<number, number> = {
  1: 28.5, 2: 15.7, 3: 11.0, 4: 8.0, 5: 7.2,
  6: 5.1, 7: 4.0, 8: 3.2, 9: 2.8, 10: 2.5,
};

const GROWTH_THRESHOLD = 0.20;  // 20% change to flag as growth/decline
const POSITION_MOVE_THRESHOLD = 3; // rank spots required to flag as improving/declining

// ═══════════════════════════════════════════════════════════════════════════
// Entry point
// ═══════════════════════════════════════════════════════════════════════════

export interface GenerateReportOptions {
  brandId: string;
  dateRange: number;  // days (7, 28, 60, 90, ...)
  sections?: ReportSectionKey[];
}

export async function generateBrandReport(opts: GenerateReportOptions): Promise<BrandReport> {
  const { brandId, dateRange } = opts;

  // Date arithmetic: current period is [end-days, end], previous is the
  // same-length window immediately preceding it.
  const end = new Date();
  const start = new Date(end.getTime() - dateRange * 86400000);
  const prevEnd = new Date(start.getTime() - 86400000);
  const prevStart = new Date(prevEnd.getTime() - dateRange * 86400000);

  const brand = await prisma.brand.findUnique({
    where: { id: brandId },
    select: { id: true, name: true, domain: true },
  });
  if (!brand) throw new Error(`Brand ${brandId} not found`);

  // ── Fetch everything in parallel ──────────────────────────────────────
  const [
    currentGscPages, previousGscPages,
    currentGscQueries, previousGscQueries,
    currentGaSnapshots, previousGaSnapshots,
    currentGscSnapshots, previousGscSnapshots,
    latestAudit,
    newPageRecords,
    statusChanges,
    latestAnalysis,
  ] = await Promise.all([
    prisma.gscPage.findMany({ where: { brandId, date: { gte: start, lte: end } } }),
    prisma.gscPage.findMany({ where: { brandId, date: { gte: prevStart, lte: prevEnd } } }),
    prisma.gscQuery.findMany({ where: { brandId, date: { gte: start, lte: end } } }),
    prisma.gscQuery.findMany({ where: { brandId, date: { gte: prevStart, lte: prevEnd } } }),
    prisma.metricSnapshot.findMany({ where: { brandId, source: "ga4", date: { gte: start, lte: end } } }),
    prisma.metricSnapshot.findMany({ where: { brandId, source: "ga4", date: { gte: prevStart, lte: prevEnd } } }),
    prisma.metricSnapshot.findMany({ where: { brandId, source: "gsc", date: { gte: start, lte: end } } }),
    prisma.metricSnapshot.findMany({ where: { brandId, source: "gsc", date: { gte: prevStart, lte: prevEnd } } }),
    prisma.technicalAudit.findFirst({
      where: { brandId, status: "completed" },
      orderBy: { createdAt: "desc" },
      include: { issues: true },
    }),
    prisma.pageRecord.findMany({
      where: { brandId, firstSeenAt: { gte: start, lte: end } },
      orderBy: { firstSeenAt: "desc" },
      take: 50,
    }),
    prisma.pageStatusChange.findMany({
      where: { page: { brandId }, detectedAt: { gte: start, lte: end }, toStatus: 404 },
      orderBy: { detectedAt: "desc" },
      take: 50,
      include: { page: { select: { url: true } } },
    }),
    prisma.analysisJob.findFirst({
      where: { brandId, status: "completed", result: { not: { equals: null } } },
      orderBy: { createdAt: "desc" },
    }),
  ]);

  // ── Aggregate GSC page data across the period by URL ──────────────────
  const currentPagesByUrl = aggregateGscPages(currentGscPages);
  const previousPagesByUrl = aggregateGscPages(previousGscPages);
  const currentKeywordsByQuery = aggregateGscQueries(currentGscQueries);
  const previousKeywordsByQuery = aggregateGscQueries(previousGscQueries);

  // ── Merge current + previous into per-URL rows with deltas ────────────
  const mergedPages: ReportPage[] = [];
  for (const [url, curr] of currentPagesByUrl.entries()) {
    const prev = previousPagesByUrl.get(url);
    mergedPages.push({
      url,
      clicks: curr.clicks,
      impressions: curr.impressions,
      ctr: curr.impressions > 0 ? (curr.clicks / curr.impressions) * 100 : 0,
      position: curr.position,
      previousClicks: prev?.clicks,
      previousPosition: prev?.position,
      clicksChangePct: prev && prev.clicks > 0
        ? ((curr.clicks - prev.clicks) / prev.clicks) * 100
        : undefined,
      positionChange: prev ? prev.position - curr.position : undefined, // positive = improved
      isNew: newPageRecords.some((p) => p.url === url || p.fullUrl === url),
    });
  }

  const mergedKeywords: ReportKeyword[] = [];
  for (const [query, curr] of currentKeywordsByQuery.entries()) {
    const prev = previousKeywordsByQuery.get(query);
    mergedKeywords.push({
      query,
      clicks: curr.clicks,
      impressions: curr.impressions,
      ctr: curr.impressions > 0 ? (curr.clicks / curr.impressions) * 100 : 0,
      position: curr.position,
      previousClicks: prev?.clicks,
      previousPosition: prev?.position,
      positionChange: prev ? prev.position - curr.position : undefined,
      clicksChangePct: prev && prev.clicks > 0
        ? ((curr.clicks - prev.clicks) / prev.clicks) * 100
        : undefined,
    });
  }

  // ── Compute totals + KPIs ──────────────────────────────────────────────
  const gaSum = sumGa(currentGaSnapshots);
  const gaPrev = sumGa(previousGaSnapshots);
  const gscSum = sumGsc(currentGscSnapshots);
  const gscPrev = sumGsc(previousGscSnapshots);

  const kpis: ReportKpi[] = [];
  if (currentGaSnapshots.length > 0) {
    kpis.push(kpi("Sessions", gaSum.sessions, gaPrev.sessions));
    kpis.push(kpi("Users", gaSum.users, gaPrev.users));
    kpis.push(kpi("Conversions", gaSum.conversions, gaPrev.conversions));
  }
  if (currentGscSnapshots.length > 0 || currentGscPages.length > 0) {
    const clicks = currentGscSnapshots.length > 0 ? gscSum.organicClicks : mergedPages.reduce((s, p) => s + p.clicks, 0);
    const prevClicks = previousGscSnapshots.length > 0 ? gscPrev.organicClicks : Array.from(previousPagesByUrl.values()).reduce((s, p) => s + p.clicks, 0);
    const impressions = currentGscSnapshots.length > 0 ? gscSum.impressions : mergedPages.reduce((s, p) => s + p.impressions, 0);
    const prevImpressions = previousGscSnapshots.length > 0 ? gscPrev.impressions : Array.from(previousPagesByUrl.values()).reduce((s, p) => s + p.impressions, 0);
    kpis.push(kpi("Organic Clicks", clicks, prevClicks));
    kpis.push(kpi("Impressions", impressions, prevImpressions));
  }

  // ── Wins ───────────────────────────────────────────────────────────────
  const topPages = [...mergedPages].sort((a, b) => b.clicks - a.clicks).slice(0, 10);
  const topKeywords = [...mergedKeywords].sort((a, b) => b.clicks - a.clicks).slice(0, 10);
  const growingPages = mergedPages
    .filter((p) => p.clicksChangePct !== undefined && p.clicksChangePct > GROWTH_THRESHOLD * 100 && p.clicks >= 5)
    .sort((a, b) => (b.clicksChangePct ?? 0) - (a.clicksChangePct ?? 0))
    .slice(0, 10);
  const improvingKeywords = mergedKeywords
    .filter((k) => k.positionChange !== undefined && k.positionChange >= POSITION_MOVE_THRESHOLD)
    .sort((a, b) => (b.positionChange ?? 0) - (a.positionChange ?? 0))
    .slice(0, 10);
  const newPages = newPageRecords
    .filter((p) => p.totalClicks > 0)
    .map((p) => ({ url: p.url, firstSeenAt: p.firstSeenAt.toISOString(), clicks: p.totalClicks }))
    .slice(0, 10);

  const conversionWin = currentGaSnapshots.length > 0 ? {
    current: gaSum.conversions,
    previous: gaPrev.conversions,
    changePct: gaPrev.conversions > 0 ? ((gaSum.conversions - gaPrev.conversions) / gaPrev.conversions) * 100 : null,
  } : null;

  // ── Declines ───────────────────────────────────────────────────────────
  const decliningPages = mergedPages
    .filter((p) => p.clicksChangePct !== undefined && p.clicksChangePct < -GROWTH_THRESHOLD * 100 && (p.previousClicks ?? 0) >= 10)
    .sort((a, b) => (a.clicksChangePct ?? 0) - (b.clicksChangePct ?? 0))
    .slice(0, 10);
  const decliningKeywords = mergedKeywords
    .filter((k) => k.positionChange !== undefined && k.positionChange <= -POSITION_MOVE_THRESHOLD)
    .sort((a, b) => (a.positionChange ?? 0) - (b.positionChange ?? 0)) // most negative first
    .slice(0, 10);

  const technicalIssuesSummary = latestAudit ? {
    crawlHealth: latestAudit.crawlHealth,
    errorCount: latestAudit.errorCount,
    warningCount: latestAudit.warningCount,
    criticalIssues: latestAudit.issues
      .filter((i) => i.severity === "critical" || i.severity === "high")
      .slice(0, 10)
      .map((i) => ({
        title: i.title,
        category: i.category,
        affected: i.affected,
        pageUrl: i.pageUrl,
      })),
  } : null;

  const lowCtrHighImpression = mergedPages
    .filter((p) => p.impressions > 100 && p.ctr < 1 && p.position <= 20)
    .sort((a, b) => b.impressions - a.impressions)
    .slice(0, 10);

  const pagesWent404 = statusChanges.map((c) => ({
    url: c.page.url,
    detectedAt: c.detectedAt.toISOString(),
  }));

  // Performance drops — surface any KPI that declined substantially
  const performanceDrops: Array<{ label: string; value: number; changePct: number; direction: "down" }> = [];
  for (const k of kpis) {
    if (k.changePct !== undefined && k.changePct !== null && k.changePct < -GROWTH_THRESHOLD * 100) {
      performanceDrops.push({ label: k.label, value: k.value, changePct: k.changePct, direction: "down" });
    }
  }

  // ── Opportunities ──────────────────────────────────────────────────────
  const ctrOptimization = mergedPages
    .filter((p) => p.position >= 1 && p.position <= 5 && p.impressions >= 100)
    .map((p) => {
      const expected = EXPECTED_CTR_BY_POSITION[Math.round(p.position)] ?? 5;
      const gap = expected - p.ctr;
      return gap > 2 ? {
        url: p.url,
        position: p.position,
        currentCtr: p.ctr,
        expectedCtr: expected,
        impressions: p.impressions,
        estimatedClickGain: Math.round((gap / 100) * p.impressions),
      } : null;
    })
    .filter((x): x is NonNullable<typeof x> => x !== null)
    .sort((a, b) => b.estimatedClickGain - a.estimatedClickGain)
    .slice(0, 10);

  const strikingDistance: BrandReport["opportunities"]["strikingDistance"] = [];
  for (const p of mergedPages) {
    if (p.position >= 4 && p.position <= 10 && p.impressions >= 100) {
      const top3Ctr = 18; // rough average of positions 1-3
      const estClicks = Math.round((top3Ctr / 100) * p.impressions);
      if (estClicks > p.clicks + 5) {
        strikingDistance.push({
          target: p.url,
          type: "page",
          position: p.position,
          impressions: p.impressions,
          currentClicks: p.clicks,
          estimatedClicksAtTop3: estClicks,
        });
      }
    }
  }
  for (const k of mergedKeywords) {
    if (k.position >= 4 && k.position <= 10 && k.impressions >= 100) {
      const top3Ctr = 18;
      const estClicks = Math.round((top3Ctr / 100) * k.impressions);
      if (estClicks > k.clicks + 5) {
        strikingDistance.push({
          target: k.query,
          type: "keyword",
          position: k.position,
          impressions: k.impressions,
          currentClicks: k.clicks,
          estimatedClicksAtTop3: estClicks,
        });
      }
    }
  }
  strikingDistance.sort((a, b) => (b.estimatedClicksAtTop3 - b.currentClicks) - (a.estimatedClicksAtTop3 - a.currentClicks));
  strikingDistance.splice(15);

  const contentRefresh = mergedPages
    .filter((p) =>
      p.clicksChangePct !== undefined &&
      p.clicksChangePct < -GROWTH_THRESHOLD * 100 &&
      p.impressions > 50 &&
      (p.previousClicks ?? 0) > 10,
    )
    .slice(0, 10)
    .map((p) => ({
      url: p.url,
      currentClicks: p.clicks,
      previousClicks: p.previousClicks ?? 0,
      changePct: p.clicksChangePct ?? 0,
      impressions: p.impressions,
    }));

  const technicalFixes = latestAudit
    ? latestAudit.issues
        .filter((i) => i.status === "open" && (i.severity === "critical" || i.severity === "high"))
        .slice(0, 10)
        .map((i) => ({
          title: i.title,
          category: i.category,
          priority: i.severity,
          affected: i.affected,
        }))
    : [];

  // ── AI Insights ────────────────────────────────────────────────────────
  const analysisResult = latestAnalysis?.result ? (latestAnalysis.result as unknown as Analysis) : null;
  const aiInsights: BrandReport["aiInsights"] = {
    available: !!analysisResult,
    analysisId: latestAnalysis?.id ?? null,
    findings: analysisResult?.findings ?? [],
    predictions: analysisResult?.predictions ?? [],
    recommendations: analysisResult?.recommendations ?? [],
  };

  // ── Executive summary narrative ────────────────────────────────────────
  const healthScore = latestAudit?.crawlHealth ?? null;
  const topWin = buildTopWin(topPages, growingPages, improvingKeywords, conversionWin);
  const topConcern = buildTopConcern(decliningPages, technicalIssuesSummary, pagesWent404, performanceDrops);
  const topOpportunity = buildTopOpportunity(ctrOptimization, strikingDistance);

  const narrative = buildNarrative({
    brand,
    dateRange,
    kpis,
    topWin,
    topConcern,
    topOpportunity,
    hasGa4: currentGaSnapshots.length > 0,
    hasGsc: currentGscPages.length > 0 || currentGscSnapshots.length > 0,
    hasAudit: !!latestAudit,
  });

  // ── Appendix ───────────────────────────────────────────────────────────
  const appendixTopPages = [...mergedPages].sort((a, b) => b.clicks - a.clicks).slice(0, 50);
  const appendixTopKeywords = [...mergedKeywords].sort((a, b) => b.clicks - a.clicks).slice(0, 50);
  const appendixAllIssues = latestAudit
    ? latestAudit.issues.map((i) => ({
        title: i.title,
        severity: i.severity,
        category: i.category,
        affected: i.affected,
      }))
    : [];
  const appendixStatusChanges: BrandReport["appendix"]["statusChanges"] = [];
  // Pull ALL status changes in the period (not just 404s) for the appendix
  const allStatusChanges = await prisma.pageStatusChange.findMany({
    where: { page: { brandId }, detectedAt: { gte: start, lte: end } },
    orderBy: { detectedAt: "desc" },
    take: 100,
    include: { page: { select: { url: true } } },
  });
  for (const c of allStatusChanges) {
    appendixStatusChanges.push({
      url: c.page.url,
      fromStatus: c.fromStatus,
      toStatus: c.toStatus,
      detectedAt: c.detectedAt.toISOString(),
    });
  }

  return {
    generatedAt: new Date().toISOString(),
    dateRange: {
      start: start.toISOString().split("T")[0],
      end: end.toISOString().split("T")[0],
      days: dateRange,
    },
    previousDateRange: {
      start: prevStart.toISOString().split("T")[0],
      end: prevEnd.toISOString().split("T")[0],
    },
    brand,
    sources: {
      gsc: currentGscPages.length > 0 || currentGscSnapshots.length > 0,
      ga4: currentGaSnapshots.length > 0,
      audit: !!latestAudit,
      siteTag: newPageRecords.length > 0 || statusChanges.length > 0,
      analysis: !!analysisResult,
    },
    executiveSummary: {
      narrative,
      healthScore,
      kpis,
      topWin,
      topConcern,
      topOpportunity,
    },
    wins: {
      topPages,
      topKeywords,
      growingPages,
      improvingKeywords,
      newPages,
      conversionWin,
    },
    declines: {
      decliningPages,
      decliningKeywords,
      technicalIssues: technicalIssuesSummary,
      pageHealthIssues: {
        pagesWent404,
        lowCtrHighImpression,
      },
      performanceDrops,
    },
    opportunities: {
      ctrOptimization,
      strikingDistance,
      contentRefresh,
      technicalFixes,
    },
    aiInsights,
    appendix: {
      topPages: appendixTopPages,
      topKeywords: appendixTopKeywords,
      allTechnicalIssues: appendixAllIssues,
      statusChanges: appendixStatusChanges,
    },
  };
}

// ═══════════════════════════════════════════════════════════════════════════
// Aggregation helpers
// ═══════════════════════════════════════════════════════════════════════════

interface PageAgg { clicks: number; impressions: number; position: number; _posWeight: number; _count: number }

function aggregateGscPages(rows: Array<{ pageUrl: string; clicks: number; impressions: number; position: number }>): Map<string, PageAgg> {
  const map = new Map<string, PageAgg>();
  for (const r of rows) {
    const existing = map.get(r.pageUrl);
    if (existing) {
      existing.clicks += r.clicks;
      existing.impressions += r.impressions;
      existing._posWeight += r.position * r.impressions;
      existing._count += r.impressions;
    } else {
      map.set(r.pageUrl, {
        clicks: r.clicks,
        impressions: r.impressions,
        position: r.position,
        _posWeight: r.position * r.impressions,
        _count: r.impressions,
      });
    }
  }
  // Finalise impression-weighted average position
  for (const v of map.values()) {
    v.position = v._count > 0 ? v._posWeight / v._count : v.position;
  }
  return map;
}

function aggregateGscQueries(rows: Array<{ query: string; clicks: number; impressions: number; position: number }>): Map<string, PageAgg> {
  const map = new Map<string, PageAgg>();
  for (const r of rows) {
    const existing = map.get(r.query);
    if (existing) {
      existing.clicks += r.clicks;
      existing.impressions += r.impressions;
      existing._posWeight += r.position * r.impressions;
      existing._count += r.impressions;
    } else {
      map.set(r.query, {
        clicks: r.clicks,
        impressions: r.impressions,
        position: r.position,
        _posWeight: r.position * r.impressions,
        _count: r.impressions,
      });
    }
  }
  for (const v of map.values()) {
    v.position = v._count > 0 ? v._posWeight / v._count : v.position;
  }
  return map;
}

function sumGa(rows: Array<{ sessions: number; users: number; conversions: number; revenue: number; pageviews: number }>) {
  return rows.reduce((acc, r) => ({
    sessions: acc.sessions + r.sessions,
    users: acc.users + r.users,
    conversions: acc.conversions + r.conversions,
    revenue: acc.revenue + r.revenue,
    pageviews: acc.pageviews + r.pageviews,
  }), { sessions: 0, users: 0, conversions: 0, revenue: 0, pageviews: 0 });
}

function sumGsc(rows: Array<{ organicClicks: number; impressions: number }>) {
  return rows.reduce((acc, r) => ({
    organicClicks: acc.organicClicks + r.organicClicks,
    impressions: acc.impressions + r.impressions,
  }), { organicClicks: 0, impressions: 0 });
}

function kpi(label: string, value: number, previousValue: number): ReportKpi {
  const changePct = previousValue > 0 ? ((value - previousValue) / previousValue) * 100 : null;
  const direction: "up" | "down" | "flat" =
    changePct === null ? "flat" : changePct > 0 ? "up" : changePct < 0 ? "down" : "flat";
  return { label, value, previousValue, changePct, direction };
}

// ═══════════════════════════════════════════════════════════════════════════
// Narrative builders
// ═══════════════════════════════════════════════════════════════════════════

function buildTopWin(
  topPages: ReportPage[],
  growingPages: ReportPage[],
  improvingKeywords: ReportKeyword[],
  conversionWin: { current: number; previous: number; changePct: number | null } | null,
): string | null {
  if (conversionWin && conversionWin.changePct !== null && conversionWin.changePct > 10) {
    return `Conversions grew ${conversionWin.changePct.toFixed(1)}% period-over-period (${conversionWin.previous} → ${conversionWin.current}).`;
  }
  if (growingPages.length > 0) {
    const top = growingPages[0];
    return `${top.url} grew ${top.clicksChangePct!.toFixed(1)}% in organic clicks — the biggest page-level win this period.`;
  }
  if (improvingKeywords.length > 0) {
    const top = improvingKeywords[0];
    return `"${top.query}" jumped ${top.positionChange!.toFixed(0)} positions in search rankings.`;
  }
  if (topPages.length > 0) {
    return `${topPages[0].url} is the top performer with ${topPages[0].clicks.toLocaleString()} clicks.`;
  }
  return null;
}

function buildTopConcern(
  decliningPages: ReportPage[],
  audit: { errorCount: number; warningCount: number; criticalIssues: Array<{ title: string }> } | null,
  pagesWent404: Array<{ url: string }>,
  performanceDrops: Array<{ label: string; changePct: number }>,
): string | null {
  if (decliningPages.length > 0) {
    const worst = decliningPages[0];
    return `${worst.url} lost ${Math.abs(worst.clicksChangePct!).toFixed(1)}% of its organic traffic.`;
  }
  if (pagesWent404.length > 0) {
    return `${pagesWent404.length} page${pagesWent404.length !== 1 ? "s" : ""} went 404 during this period.`;
  }
  if (audit && audit.errorCount > 0) {
    return `Technical audit found ${audit.errorCount} error${audit.errorCount !== 1 ? "s" : ""} that need attention.`;
  }
  if (performanceDrops.length > 0) {
    const d = performanceDrops[0];
    return `${d.label} dropped ${Math.abs(d.changePct).toFixed(1)}% vs the previous period.`;
  }
  return null;
}

function buildTopOpportunity(
  ctrOpp: Array<{ url: string; estimatedClickGain: number }>,
  striking: Array<{ target: string; type: string; estimatedClicksAtTop3: number; currentClicks: number }>,
): string | null {
  const bestCtr = ctrOpp[0];
  const bestStriking = striking[0];
  if (bestCtr && bestCtr.estimatedClickGain >= 50) {
    return `Optimising the title + meta description on ${bestCtr.url} could recover roughly ${bestCtr.estimatedClickGain.toLocaleString()} monthly clicks.`;
  }
  if (bestStriking) {
    const delta = bestStriking.estimatedClicksAtTop3 - bestStriking.currentClicks;
    return `${bestStriking.type === "page" ? bestStriking.target : `"${bestStriking.target}"`} is in striking distance — a top-3 ranking would add ~${delta.toLocaleString()} clicks.`;
  }
  if (bestCtr) {
    return `Several pages are ranking well but under-performing on click-through rate. Metadata tuning could unlock easy wins.`;
  }
  return null;
}

function buildNarrative(ctx: {
  brand: { name: string; domain: string };
  dateRange: number;
  kpis: ReportKpi[];
  topWin: string | null;
  topConcern: string | null;
  topOpportunity: string | null;
  hasGa4: boolean;
  hasGsc: boolean;
  hasAudit: boolean;
}): string {
  const parts: string[] = [];
  const sourceList = [ctx.hasGa4 && "GA4", ctx.hasGsc && "GSC", ctx.hasAudit && "Technical Audit"].filter(Boolean).join(", ") || "available data";
  parts.push(`Over the last ${ctx.dateRange} days, ${ctx.brand.name} (${ctx.brand.domain}) was analysed across ${sourceList}.`);

  const sessions = ctx.kpis.find((k) => k.label === "Sessions");
  const clicks = ctx.kpis.find((k) => k.label === "Organic Clicks");
  if (sessions) {
    const dir = (sessions.changePct ?? 0) > 0 ? "up" : (sessions.changePct ?? 0) < 0 ? "down" : "flat";
    parts.push(`Sessions ${dir === "up" ? "grew" : dir === "down" ? "declined" : "held steady"} at ${sessions.value.toLocaleString()}${sessions.changePct !== null && sessions.changePct !== undefined ? ` (${sessions.changePct >= 0 ? "+" : ""}${sessions.changePct.toFixed(1)}%)` : ""}.`);
  }
  if (clicks) {
    parts.push(`Organic clicks from search totaled ${clicks.value.toLocaleString()}${clicks.changePct !== null && clicks.changePct !== undefined ? ` (${clicks.changePct >= 0 ? "+" : ""}${clicks.changePct.toFixed(1)}% vs prior period)` : ""}.`);
  }
  if (ctx.topWin) parts.push(`Top win: ${ctx.topWin}`);
  if (ctx.topConcern) parts.push(`Top concern: ${ctx.topConcern}`);
  if (ctx.topOpportunity) parts.push(`Top opportunity: ${ctx.topOpportunity}`);
  return parts.join(" ");
}
