import { prisma } from "@/lib/prisma";
import { detectTrackedSignals } from "@/lib/services/site-tag-analytics";
import { getSiteTagStatus } from "@/lib/services/site-tag-status";
import { analyzeContentGaps } from "@/lib/services/competitor-analysis";
import { getSourceFreshness } from "@/lib/services/ga4-freshness";
import type { SignalSeverity, SignalType } from "@/lib/services/signal-detection";

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

export interface Signal {
  id: string;
  title: string;
  description: string;
  category: "issue" | "win" | "opportunity" | "revenue" | "ai" | "execution";
  priority: "critical" | "high" | "medium" | "low";
  status: "new" | "viewed" | "actioned";
  timestamp: string;
  relatedPage?: string;
  relatedChannel?: string;
  source: string;
  /** IDs of underlying TechnicalIssue records (for technical_audit signals) */
  issueIds?: string[];
  /** AI-generated recommended next step (set by signal_ai_* event types). */
  suggestedAction?: string;
  /** Competitor this signal refers to (for competitor-regional AI signals). */
  competitor?: string;
  /** Derived revenue-impact bucket. Not all signals have one — only
   *  those whose category or content materially affects revenue
   *  (conversion drops, traffic anomalies, revenue-category signals,
   *  high-priority issues on high-traffic pages). Set at server-
   *  generation time via deriveRevenueImpact() so every client sees
   *  the same classification without re-computing. */
  revenueImpact?: "high" | "medium" | "low";
}

/** Derive a revenue-impact bucket for a signal. Rules (applied in
 *  order, first match wins):
 *    - category === "revenue" + priority critical/high → "high"
 *    - category === "revenue"                          → "medium"
 *    - category === "issue" + priority critical        → "high"
 *    - category === "issue" + priority high            → "medium"
 *    - category === "opportunity" + priority high      → "medium"
 *    - signal type contains conversion/traffic/revenue terms        → "high"
 *    - otherwise                                                    → undefined
 *  Returning undefined means "not revenue-relevant enough to badge" —
 *  the UI only renders the badge when this is set. Prevents every
 *  signal from sporting a useless "Low Revenue Impact" pill. */
export function deriveRevenueImpact(s: Pick<Signal, "category" | "priority" | "title" | "description">): Signal["revenueImpact"] {
  if (s.category === "revenue") {
    return s.priority === "critical" || s.priority === "high" ? "high" : "medium";
  }
  if (s.category === "issue") {
    if (s.priority === "critical") return "high";
    if (s.priority === "high") return "medium";
  }
  if (s.category === "opportunity" && s.priority === "high") return "medium";
  const text = `${s.title} ${s.description}`.toLowerCase();
  if (/conversion|revenue|checkout|cart|purchase|booking/.test(text)) {
    return s.priority === "critical" || s.priority === "high" ? "high" : "low";
  }
  if (/traffic|sessions|impression|click.*drop/.test(text)) {
    return s.priority === "critical" ? "high" : s.priority === "high" ? "medium" : "low";
  }
  return undefined;
}

/**
 * Generate signals from all platform data for a brand.
 * Scans existing backend data and creates signal items.
 */
/** How long a derived-signal dismissal stays effective before the
 *  signal is allowed to re-surface. Synthetic signals are
 *  recomputed on every GET, so a "permanent" dismissal would mean
 *  a user who dismisses "3 dead pages" on Tuesday never gets the
 *  alert again when the count climbs to 15 on Friday. 14 days is
 *  long enough to acknowledge + act, short enough that a
 *  persisting problem gets re-surfaced. */
const DISMISSAL_TTL_DAYS = 14;

export async function generateSignals(brandId: string): Promise<Signal[]> {
  const signals: Signal[] = [];

  const generators = [
    generateTechnicalAuditSignals,
    generatePerformanceAuditSignals,
    generateDeadPageSignals,
    generateConversionSignals,
    generateExecutionSignals,
    generateAiVisibilitySignals,
    generateSiteTagSignals,
    generatePageLifecycleSignals,
    generateRankTrackingSignals,
    generateCompetitorSignals,
    generateDetectedSignals,
    generateAiRecommendationSignals,
    generateDataFreshnessSignals,
  ];
  await Promise.allSettled(
    generators.map((fn) =>
      fn(brandId, signals).catch((err) => {
        console.error(`[signals] ${fn.name} failed:`, err instanceof Error ? err.message : String(err));
      }),
    ),
  );

  // Filter dismissed synthetic signals. Detection signals already
  // self-filter via their SiteEvent.eventData.status="resolved" check
  // in generateDetectedSignals, so they don't need to appear in
  // the dismissedIds set here. Only synthetic ones (tech_*, dead_*,
  // conv_*, etc.) rely on this post-filter.
  const ttlCutoff = new Date(Date.now() - DISMISSAL_TTL_DAYS * 86_400_000);
  const dismissedRows = await prisma.siteEvent.findMany({
    where: {
      brandId,
      eventType: "signal_dismissed",
      timestamp: { gte: ttlCutoff },
    },
    select: { eventData: true },
    take: 500, // plenty — a brand that dismisses 500 signals in 14d has a different problem
  }).catch(() => []);
  const dismissedIds = new Set<string>();
  for (const row of dismissedRows) {
    const data = row.eventData as { dismissedSignalId?: unknown } | null;
    const id = data?.dismissedSignalId;
    if (typeof id === "string") dismissedIds.add(id);
  }
  const filtered = dismissedIds.size > 0
    ? signals.filter((s) => !dismissedIds.has(s.id))
    : signals;

  // Sort: new first, then by priority
  const priorityOrder: Record<string, number> = { critical: 0, high: 1, medium: 2, low: 3 };
  const statusOrder: Record<string, number> = { new: 0, viewed: 1, actioned: 2 };
  filtered.sort((a, b) =>
    statusOrder[a.status] - statusOrder[b.status] ||
    priorityOrder[a.priority] - priorityOrder[b.priority]
  );

  // Tag each signal with its revenueImpact bucket so the client can
  // render a "High / Medium / Low Revenue Impact" badge without
  // re-running the derivation on its side. Done after sorting so
  // the loop mutates in-place; undefined impacts just stay undefined.
  for (const s of filtered) {
    s.revenueImpact = deriveRevenueImpact(s);
  }

  return filtered;
}

// ─── Technical Audit → Signals ───────────────────────────────────────────────

async function generateTechnicalAuditSignals(brandId: string, signals: Signal[]) {
  const brand = await prisma.brand.findUnique({
    where: { id: brandId },
    select: { domain: true },
  });
  if (!brand?.domain) return;

  const audit = await prisma.technicalAudit.findFirst({
    where: { brandId, status: "completed" },
    orderBy: { createdAt: "desc" },
    include: { issues: { where: { status: "open" }, orderBy: { severity: "asc" } } },
  });
  if (!audit || audit.pagesScanned === 0) return;

  // Critical issues
  const criticalIssues = audit.issues.filter((i) => i.severity === "critical");
  if (criticalIssues.length > 0) {
    signals.push({
      id: `tech_critical_${audit.id}`,
      title: `${criticalIssues.length} critical technical issues on ${brand.domain}`,
      description: criticalIssues.map((i) => `${i.title} (${i.affected} pages)`).join(". "),
      category: "issue",
      priority: "critical",
      status: "new",
      timestamp: timeAgo(audit.createdAt),
      source: "technical_audit",
      issueIds: criticalIssues.map((i) => i.id),
    });
  }

  // High severity issues
  const highIssues = audit.issues.filter((i) => i.severity === "high");
  if (highIssues.length > 0) {
    signals.push({
      id: `tech_high_${audit.id}`,
      title: `${highIssues.length} high-priority issues on ${brand.domain}`,
      description: highIssues.slice(0, 3).map((i) => i.title).join(", ") + (highIssues.length > 3 ? ` and ${highIssues.length - 3} more` : ""),
      category: "issue",
      priority: "high",
      status: "new",
      timestamp: timeAgo(audit.createdAt),
      source: "technical_audit",
      issueIds: highIssues.map((i) => i.id),
    });
  }

  // Low crawl health
  if (audit.crawlHealth < 60) {
    signals.push({
      id: `tech_health_${audit.id}`,
      title: `Crawl health score is ${audit.crawlHealth}/100 for ${brand.domain}`,
      description: `Site health is below acceptable threshold. ${audit.errorCount} errors and ${audit.warningCount} warnings found across ${audit.pagesScanned} pages.`,
      category: "issue",
      priority: audit.crawlHealth < 40 ? "critical" : "high",
      status: "new",
      timestamp: timeAgo(audit.createdAt),
      source: "technical_audit",
    });
  }
}

// ─── Dead Pages → Signals ────────────────────────────────────────────────────

async function generateDeadPageSignals(brandId: string, signals: Signal[]) {
  const deadPages = await prisma.deadPage.findMany({
    where: { brandId, status: "open" },
    include: { sources: true },
  });

  if (deadPages.length === 0) return;

  const highPriority = deadPages.filter((d) => d.priority === "high");
  const totalSources = deadPages.reduce((s, d) => s + d.sources.length, 0);

  signals.push({
    id: `dead_count_${brandId}`,
    title: `${deadPages.length} dead page${deadPages.length !== 1 ? "s" : ""} returning 404`,
    description: `${totalSources} internal link${totalSources !== 1 ? "s" : ""} point to dead URLs. ${highPriority.length} high priority.`,
    category: "issue",
    priority: highPriority.length > 0 ? "high" : "medium",
    status: "new",
    timestamp: timeAgo(deadPages[0].discoveredAt),
    source: "dead_pages",
  });
}

// ─── Conversions → Signals ───────────────────────────────────────────────────

async function generateConversionSignals(brandId: string, signals: Signal[]) {
  // Only generate conversion/traffic signals when a real integration is connected
  const connectedIntegration = await prisma.brandIntegration.findFirst({
    where: { brandId, connected: true, integrationId: { in: ["ga4", "gsc"] } },
    select: { integrationId: true },
  });
  if (!connectedIntegration) return;

  const now = new Date();
  const thirtyDaysAgo = new Date(now.getTime() - 30 * 86400000);
  const sixtyDaysAgo = new Date(now.getTime() - 60 * 86400000);

  // Only use real integration data, not seed/manual
  const realSources = ["ga4", "gsc"];
  const current = await prisma.metricSnapshot.findMany({
    where: { brandId, date: { gte: thirtyDaysAgo, lte: now }, source: { in: realSources } },
  });
  const previous = await prisma.metricSnapshot.findMany({
    where: { brandId, date: { gte: sixtyDaysAgo, lt: thirtyDaysAgo }, source: { in: realSources } },
  });

  if (current.length === 0) return;

  const currentRevenue = current.reduce((s, m) => s + m.revenue, 0);
  const previousRevenue = previous.reduce((s, m) => s + m.revenue, 0);
  const currentConversions = current.reduce((s, m) => s + m.conversions, 0);
  const previousConversions = previous.reduce((s, m) => s + m.conversions, 0);

  // Revenue increase
  if (previousRevenue > 0 && currentRevenue > previousRevenue) {
    const pct = Math.round(((currentRevenue - previousRevenue) / previousRevenue) * 100);
    signals.push({
      id: `conv_rev_up_${brandId}`,
      title: `Revenue up ${pct}% this month`,
      description: `Monthly revenue increased from $${Math.round(previousRevenue / 1000)}K to $${Math.round(currentRevenue / 1000)}K. Total: ${currentConversions} conversions this period.`,
      category: "revenue",
      priority: pct >= 20 ? "high" : "medium",
      status: "new",
      timestamp: "This period",
      relatedChannel: "All Channels",
      source: "conversions",
    });
  }

  // Revenue decrease
  if (previousRevenue > 0 && currentRevenue < previousRevenue) {
    const pct = Math.round(((previousRevenue - currentRevenue) / previousRevenue) * 100);
    signals.push({
      id: `conv_rev_down_${brandId}`,
      title: `Revenue dropped ${pct}% vs previous period`,
      description: `Monthly revenue decreased from $${Math.round(previousRevenue / 1000)}K to $${Math.round(currentRevenue / 1000)}K. Review channel performance for declines.`,
      category: "revenue",
      priority: pct >= 15 ? "high" : "medium",
      status: "new",
      timestamp: "This period",
      source: "conversions",
    });
  }

}

// ─── Execution Hub → Signals ─────────────────────────────────────────────────

async function generateExecutionSignals(brandId: string, signals: Signal[]) {
  // Overdue tasks
  const overdueTasks = await prisma.task.findMany({
    where: { brandId, status: { not: "completed" }, dueDate: { lt: new Date() } },
  });

  if (overdueTasks.length > 0) {
    signals.push({
      id: `exec_overdue_${brandId}`,
      title: `${overdueTasks.length} task${overdueTasks.length > 1 ? "s" : ""} overdue`,
      description: overdueTasks.slice(0, 3).map((t) => `"${t.title}"`).join(", ") + (overdueTasks.length > 3 ? ` and ${overdueTasks.length - 3} more` : ""),
      category: "execution",
      priority: overdueTasks.length >= 3 ? "high" : "medium",
      status: "new",
      timestamp: "Now",
      source: "execution_hub",
    });
  }

  // Recently completed tasks
  const sevenDaysAgo = new Date(Date.now() - 7 * 86400000);
  const completedRecently = await prisma.task.findMany({
    where: { brandId, status: "completed", completedAt: { gte: sevenDaysAgo } },
  });

  if (completedRecently.length > 0) {
    signals.push({
      id: `exec_completed_${brandId}`,
      title: `${completedRecently.length} task${completedRecently.length > 1 ? "s" : ""} completed this week`,
      description: completedRecently.slice(0, 3).map((t) => `"${t.title}"`).join(", "),
      category: "win",
      priority: "low",
      status: "new",
      timestamp: "This week",
      source: "execution_hub",
    });
  }

  // Recent page launches
  const recentLaunches = await prisma.pageLaunch.findMany({
    where: { brandId, launchDate: { gte: sevenDaysAgo } },
    include: { task: { select: { title: true } } },
  });

  for (const launch of recentLaunches.slice(0, 2)) {
    signals.push({
      id: `exec_launch_${launch.id}`,
      title: `Page launched: ${launch.pageUrl}`,
      description: launch.task?.title ? `Completed task: "${launch.task.title}".` : "Page launched and tracking.",
      category: "execution",
      priority: "medium",
      status: "new",
      timestamp: timeAgo(launch.launchDate),
      relatedPage: launch.pageUrl,
      source: "execution_hub",
    });
  }
}

// ─── AI Visibility → Signals ─────────────────────────────────────────────────

async function generateAiVisibilitySignals(brandId: string, signals: Signal[]) {
  const sevenDaysAgo = new Date(Date.now() - 7 * 86400000);
  const fourteenDaysAgo = new Date(Date.now() - 14 * 86400000);

  const current = await prisma.aiVisibilitySnapshot.findMany({
    where: { brandId, date: { gte: sevenDaysAgo } },
  });
  const previous = await prisma.aiVisibilitySnapshot.findMany({
    where: { brandId, date: { gte: fourteenDaysAgo, lt: sevenDaysAgo } },
  });

  if (current.length === 0) return;

  // Group by platform
  const platforms = [...new Set(current.map((s) => s.platform))];

  for (const platform of platforms) {
    const currentMentions = current.filter((s) => s.platform === platform).reduce((s, i) => s + i.mentions, 0);
    const prevMentions = previous.filter((s) => s.platform === platform).reduce((s, i) => s + i.mentions, 0);
    const change = currentMentions - prevMentions;

    if (change > 0) {
      signals.push({
        id: `ai_up_${platform}_${brandId}`,
        title: `${platform} mentions increased by ${change}`,
        description: `Your brand was cited ${currentMentions} times on ${platform} this week, up from ${prevMentions}. AI visibility is improving for this platform.`,
        category: "ai",
        priority: change >= 5 ? "high" : "medium",
        status: "new",
        timestamp: "This week",
        relatedChannel: platform,
        source: "ai_visibility",
      });
    }

    if (change < 0) {
      signals.push({
        id: `ai_down_${platform}_${brandId}`,
        title: `${platform} mentions dropped by ${Math.abs(change)}`,
        description: `Your visibility on ${platform} decreased from ${prevMentions} to ${currentMentions} mentions. Review content freshness and citation patterns.`,
        category: "ai",
        priority: Math.abs(change) >= 3 ? "high" : "medium",
        status: "new",
        timestamp: "This week",
        relatedChannel: platform,
        source: "ai_visibility",
      });
    }
  }

  // Total mentions signal
  const totalCurrent = current.reduce((s, i) => s + i.mentions, 0);
  const totalPrev = previous.reduce((s, i) => s + i.mentions, 0);
  if (totalCurrent > totalPrev && totalCurrent - totalPrev >= 5) {
    signals.push({
      id: `ai_total_up_${brandId}`,
      title: `AI visibility up — ${totalCurrent - totalPrev} new mentions across all platforms`,
      description: `Total AI mentions increased from ${totalPrev} to ${totalCurrent} this week. ${platforms.length} platforms tracked.`,
      category: "win",
      priority: "medium",
      status: "new",
      timestamp: "This week",
      source: "ai_visibility",
    });
  }
}

// ─── Performance Audit → Signals ────────────────────────────────────────────

async function generatePerformanceAuditSignals(brandId: string, signals: Signal[]) {
  const audit = await prisma.performanceAudit.findFirst({
    where: { brandId, status: "completed" },
    orderBy: { createdAt: "desc" },
    include: { pages: true },
  });
  if (!audit || audit.pages.length === 0) return;

  const mobileScores = audit.pages.filter(p => p.device === "mobile");
  const failing = mobileScores.filter(p => p.performance < 50);

  if (failing.length > 0) {
    signals.push({
      id: `perf_fail_${audit.id}`,
      title: `${failing.length} page${failing.length !== 1 ? "s" : ""} failing Core Web Vitals`,
      description: `Mobile performance score below 50 on ${failing.map(p => p.pageUrl).join(", ")}. This affects search rankings and user experience.`,
      category: "issue",
      priority: "high",
      status: "new",
      timestamp: timeAgo(audit.createdAt),
      source: "performance_audit",
    });
  }

  const avgPerf = mobileScores.length > 0
    ? Math.round(mobileScores.reduce((s, p) => s + p.performance, 0) / mobileScores.length)
    : 0;
  if (avgPerf >= 90) {
    signals.push({
      id: `perf_pass_${audit.id}`,
      title: `Mobile performance score: ${avgPerf}/100`,
      description: `All Core Web Vitals passing on mobile. Good performance supports better search rankings.`,
      category: "win",
      priority: "low",
      status: "new",
      timestamp: timeAgo(audit.createdAt),
      source: "performance_audit",
    });
  }
}

// ─── Site Tag Tracked Data Signals ───────────────────────────────────────────

async function generateSiteTagSignals(brandId: string, signals: Signal[]): Promise<void> {
  // Only generate Site Tag signals when the tag has actually received events
  const tagStatus = await getSiteTagStatus(brandId);
  if (!tagStatus.hasAnyEvents) return;

  const tracked = await detectTrackedSignals(brandId);
  for (let i = 0; i < tracked.length; i++) {
    const t = tracked[i];
    signals.push({
      id: `sitetag_${t.category}_${brandId}_${i}`,
      title: t.title,
      description: t.description,
      category: t.category === "traffic" ? "issue" : t.category === "conversions" ? "revenue" : "opportunity",
      priority: t.priority === "high" ? "high" : t.priority === "medium" ? "medium" : "low",
      status: "new",
      timestamp: "Today",
      source: "site_tag",
    });
  }
}

// ─── Helper ──────────────────────────────────────────────────────────────────

// ─── Page Lifecycle → Signals ───────────────────────────────────────────────

/**
 * Generate signals from the Page Lifecycle Intelligence system:
 *   - New pages detected in the last 7 days
 *   - New pages added to the main navigation in the last 7 days
 *   - Pages that went 404 in the last 14 days (especially traffic-losers)
 *   - Pages currently redirecting (lifecycle awareness)
 */
async function generatePageLifecycleSignals(brandId: string, signals: Signal[]): Promise<void> {
  const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
  const fourteenDaysAgo = new Date(Date.now() - 14 * 24 * 60 * 60 * 1000);

  // Only generate "new page detected" signals when the Site Tag has real events.
  // Without events, PageRecord data comes from crawls — don't mislabel as Site Tag.
  const tagStatus = await getSiteTagStatus(brandId);

  // New pages — only signal when the page has SEO issues (actionable)
  const newPages = await prisma.pageRecord.findMany({
    where: { brandId, firstSeenAt: { gte: sevenDaysAgo } },
    orderBy: { firstSeenAt: "desc" },
    take: 10,
    select: { id: true, url: true, firstSeenAt: true, isInNav: true, title: true, metaDescription: true, h1: true, canonicalUrl: true },
  });
  for (const p of newPages) {
    const issues: string[] = [];
    if (!p.title) issues.push("missing title");
    if (!p.metaDescription) issues.push("missing meta description");
    if (!p.h1) issues.push("missing H1");
    if (!p.canonicalUrl) issues.push("no canonical");

    // Only create a signal if the page has issues — otherwise it's just noise
    if (issues.length === 0) continue;

    const source = tagStatus.hasAnyEvents ? "Site Tag" : "Technical audit";
    signals.push({
      id: `lifecycle_new_${p.id}`,
      title: `New page needs attention: ${p.url}`,
      description: `${source} detected this page ${timeAgo(p.firstSeenAt)} — ${issues.join(", ")}.`,
      category: "opportunity",
      priority: issues.length >= 3 ? "high" : "medium",
      status: "new",
      timestamp: timeAgo(p.firstSeenAt),
      relatedPage: p.url,
      source: "page_lifecycle",
    });
  }

  // Newly promoted to nav
  const navPromotions = await prisma.pageRecord.findMany({
    where: { brandId, isInNav: true, addedToNavAt: { gte: sevenDaysAgo } },
    orderBy: { addedToNavAt: "desc" },
    take: 5,
    select: { id: true, url: true, addedToNavAt: true, firstSeenAt: true },
  });
  for (const p of navPromotions) {
    if (!p.addedToNavAt) continue;
    // Don't double-emit if this page was also in the "new pages" list above
    // AND the nav add happened at roughly the same time as first-seen
    const isBrandNewAlsoInNav = p.firstSeenAt.getTime() >= sevenDaysAgo.getTime() &&
      Math.abs(p.addedToNavAt.getTime() - p.firstSeenAt.getTime()) < 60_000;
    if (isBrandNewAlsoInNav) continue;
    signals.push({
      id: `lifecycle_nav_${p.id}`,
      title: `New page added to navigation: ${p.url}`,
      description: `This page was promoted to the main site navigation ${timeAgo(p.addedToNavAt)}.`,
      category: "execution",
      priority: "medium",
      status: "new",
      timestamp: timeAgo(p.addedToNavAt),
      relatedPage: p.url,
      source: "page_lifecycle",
    });
  }

  // Pages that went 404 recently (traffic-losers get higher priority)
  const recent404s = await prisma.pageStatusChange.findMany({
    where: {
      toStatus: 404,
      detectedAt: { gte: fourteenDaysAgo },
      page: { brandId },
    },
    orderBy: { detectedAt: "desc" },
    take: 10,
    include: {
      page: { select: { id: true, url: true, totalClicks: true, totalImpressions: true } },
    },
  });
  for (const change of recent404s) {
    const lostClicks = change.page.totalClicks;
    const isCritical = lostClicks > 50;
    const clickCopy = lostClicks > 0
      ? ` This page was getting ${lostClicks.toLocaleString()} clicks and ${change.page.totalImpressions.toLocaleString()} impressions before it went 404.`
      : "";
    signals.push({
      id: `lifecycle_404_${change.id}`,
      title: `Page went 404: ${change.page.url}`,
      description: `Status changed from ${change.fromStatus} to 404 ${timeAgo(change.detectedAt)}.${clickCopy}`,
      category: "issue",
      priority: isCritical ? "critical" : "high",
      status: "new",
      timestamp: timeAgo(change.detectedAt),
      relatedPage: change.page.url,
      source: "page_lifecycle",
    });
  }
}

// ── Rank Tracking Signals ─────────────────────────────────────────────────
async function generateRankTrackingSignals(brandId: string, signals: Signal[]): Promise<void> {
  // Read SiteEvent rows written by the cron rank-check job within the
  // last 7 days. Each row contains the signal metadata in eventData.
  const sevenDaysAgo = new Date(Date.now() - 7 * 86400000);
  const events = await prisma.siteEvent.findMany({
    where: {
      brandId,
      eventType: { in: ["rank_entered_top3", "rank_dropped_off_page1", "rank_improved", "rank_declined"] },
      timestamp: { gte: sevenDaysAgo },
    },
    orderBy: { timestamp: "desc" },
    take: 15,
  });

  for (const ev of events) {
    const d = ev.eventData as { keyword?: string; position?: number; previousPosition?: number; priority?: string; title?: string } | null;
    if (!d?.title) continue;
    const isWarning = d.priority === "warning";
    signals.push({
      id: `rank_${ev.id}`,
      title: d.title,
      description: `Keyword "${d.keyword}" ${d.position != null ? `is now at position ${d.position}` : "is no longer ranking"}${d.previousPosition != null ? ` (was ${d.previousPosition})` : ""}.`,
      category: isWarning ? "issue" : "win",
      priority: isWarning ? "high" : "medium",
      status: "new",
      timestamp: timeAgo(ev.timestamp),
      source: "rank_tracking",
    });
  }
}

function timeAgo(date: Date): string {
  const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
  if (seconds < 60) return "Just now";
  if (seconds < 3600) return `${Math.floor(seconds / 60)} minutes ago`;
  if (seconds < 86400) return `${Math.floor(seconds / 3600)} hours ago`;
  if (seconds < 172800) return "Yesterday";
  return `${Math.floor(seconds / 86400)} days ago`;
}

// ─── Competitor Intelligence → Signals ──────────────────────────────────────

async function generateCompetitorSignals(brandId: string, signals: Signal[]) {
  try {
    // Recent new pages from competitors (last 7 days)
    const since = new Date(Date.now() - 7 * 86400000);
    const recentPages = await prisma.competitorPage.findMany({
      where: {
        competitor: { brandId, isActive: true },
        status: "new",
        firstSeenAt: { gte: since },
      },
      select: {
        url: true, title: true, firstSeenAt: true,
        competitor: { select: { id: true, domain: true } },
      },
      orderBy: { firstSeenAt: "desc" },
      take: 50,
    });

    // Group new pages by competitor
    const byCompetitor = new Map<string, typeof recentPages>();
    for (const p of recentPages) {
      const key = p.competitor.domain;
      if (!byCompetitor.has(key)) byCompetitor.set(key, []);
      byCompetitor.get(key)!.push(p);
    }

    for (const [domain, pages] of byCompetitor) {
      if (pages.length >= 1) {
        const titles = pages.slice(0, 3).map((p) => p.title || new URL(p.url).pathname).join(", ");
        signals.push({
          id: `comp-new-${domain}-${since.toISOString().slice(0, 10)}`,
          title: `${domain} published ${pages.length} new page${pages.length === 1 ? "" : "s"}`,
          description: pages.length === 1
            ? `New page: ${titles}. Check if you need content covering this topic.`
            : `Recent pages include: ${titles}. Review for content gaps and competitive threats.`,
          category: "opportunity",
          priority: pages.length >= 5 ? "high" : "medium",
          status: "new",
          timestamp: pages[0].firstSeenAt.toISOString(),
          source: "competitor-intel",
        });
      }
    }

    // Content gap signal — only generate if we have competitor page data
    const analysis = await analyzeContentGaps(brandId);
    if (analysis.gaps.length >= 5) {
      signals.push({
        id: `comp-gaps-${brandId}`,
        title: `${analysis.gaps.length} content gaps detected across competitors`,
        description: `Competitors have ${analysis.gaps.length} pages covering topics you don't have content for. Top gaps: ${analysis.gaps.slice(0, 3).map((g) => g.topic).join(", ")}.`,
        category: "opportunity",
        priority: analysis.gaps.length >= 20 ? "high" : "medium",
        status: "new",
        timestamp: new Date().toISOString(),
        source: "competitor-intel",
      });
    }
  } catch (err) {
    console.error("[signals] generateCompetitorSignals failed", err);
  }
}

// ─── Real-Time Signal Detection → Signals ───────────────────────────────────

/**
 * Pulls the last 24h of open `signal_*` SiteEvent rows written by the
 * signal-detection engine (error spikes, rage clicks, traffic anomalies,
 * conversion drops, exit intent, broken resources) and surfaces them in
 * the Signals Center alongside the existing generators.
 *
 * The detection engine produces structured payloads with title +
 * description + severity — we just map those fields onto the shared
 * Signal shape the UI already renders (category + priority + status).
 * Resolved rows are skipped here; the UI doesn't need to show them once
 * the underlying condition has cleared.
 */
async function generateDetectedSignals(brandId: string, signals: Signal[]) {
  try {
    const dayAgo = new Date(Date.now() - 86_400_000);
    const rows = await prisma.siteEvent.findMany({
      where: {
        brandId,
        timestamp: { gte: dayAgo },
        eventType: {
          in: [
            // Deterministic detectors (signal-detection.ts)
            "signal_error_spike",
            "signal_rage_click_spike",
            "signal_traffic_anomaly",
            "signal_conversion_drop",
            "signal_exit_intent_spike",
            "signal_broken_resource",
            // AI-generated signals (crons: signals-site-tag-ai,
            // signals-competitor-regional). Payload shape is documented
            // in src/lib/services/ai-signals.ts.
            "signal_ai_sitetag",
            "signal_ai_competitor",
            "signal_ai_competitive",
          ],
        },
      },
      orderBy: { timestamp: "desc" },
      take: 200,
    });

    for (const row of rows) {
      const data = (row.eventData ?? {}) as Record<string, unknown>;
      if (data.status === "resolved") continue;

      const title = typeof data.title === "string" ? data.title : row.eventType;
      const description = typeof data.description === "string" ? data.description : "";
      const severity = (typeof data.severity === "string" ? data.severity : "medium") as SignalSeverity;

      const isAiSourced = row.eventType === "signal_ai_sitetag" || row.eventType === "signal_ai_competitor" || row.eventType === "signal_ai_competitive";

      // Category: deterministic signals use categoryForDetection; AI
      // signals ship with their own category the model picked (already
      // validated against the allowed enum in callAiForSignals).
      let category: Signal["category"];
      if (isAiSourced) {
        const aiCat = typeof data.category === "string" ? data.category : "issue";
        category = (["issue", "win", "opportunity", "revenue"].includes(aiCat) ? aiCat : "issue") as Signal["category"];
      } else {
        const signalType = (row.eventType.slice("signal_".length)) as SignalType;
        category = categoryForDetection(signalType, severity);
      }

      // Source: deterministic signals are flat "site-tag"; AI signals
      // carry their originating cron ("site-tag-ai" / "competitor-
      // regional-ai") so the UI can label them distinctly.
      const source = isAiSourced
        ? (typeof data.source === "string" ? data.source : "ai")
        : "site-tag";

      signals.push({
        id: `detection-${row.id}`,
        title,
        description,
        category,
        priority: priorityForSeverity(severity),
        status: data.status === "acknowledged" ? "viewed" : "new",
        timestamp: row.timestamp.toISOString(),
        relatedPage: (typeof data.affectedUrl === "string" && data.affectedUrl) || row.pageUrl || undefined,
        source,
        suggestedAction: typeof data.suggestedAction === "string" && data.suggestedAction.length > 0
          ? data.suggestedAction
          : undefined,
        competitor: typeof data.competitorName === "string" && data.competitorName.length > 0
          ? data.competitorName
          : undefined,
      });
    }
  } catch (err) {
    console.error("[signals] generateDetectedSignals failed", err);
  }
}

/**
 * Map detection signal types onto the Signals Center category taxonomy.
 * Most behavioural alerts are "issues"; traffic surges map to
 * "opportunity" so wins don't scream alarm-red in the UI.
 */
function categoryForDetection(type: SignalType, severity: SignalSeverity): Signal["category"] {
  switch (type) {
    case "error_spike":
    case "rage_click_spike":
    case "broken_resource":
    case "exit_intent_spike":
      return "issue";
    case "conversion_drop":
      return "revenue";
    case "traffic_anomaly":
      // Drop vs surge is distinguished by severity in the detector: drops
      // emit high severity (= issue), surges emit low (= opportunity win).
      return severity === "low" || severity === "info" ? "opportunity" : "issue";
    default:
      return "issue";
  }
}

function priorityForSeverity(severity: SignalSeverity): Signal["priority"] {
  switch (severity) {
    case "critical": return "critical";
    case "high": return "high";
    case "medium": return "medium";
    case "low":
    case "info": return "low";
    default: return "medium";
  }
}

async function generateAiRecommendationSignals(brandId: string, signals: Signal[]) {
  try {
    const critical = await prisma.aiRecommendation.findMany({
      where: { brandId, status: "pending", priority: { in: ["critical", "high"] } },
      orderBy: { createdAt: "desc" },
      take: 5,
      select: { id: true, title: true, priority: true, type: true, insight: true, action: true, createdAt: true },
    });

    for (const rec of critical) {
      signals.push({
        id: `ai-rec-${rec.id}`,
        title: rec.title,
        description: rec.insight,
        category: "ai",
        priority: rec.priority as Signal["priority"],
        status: "new",
        timestamp: rec.createdAt.toISOString(),
        source: "ai-strategy-engine",
        suggestedAction: rec.action,
      });
    }

    const total = await prisma.aiRecommendation.count({
      where: { brandId, status: "pending" },
    }).catch(() => 0);

    if (total > 3) {
      signals.push({
        id: `ai-rec-summary-${brandId}`,
        title: `${total} AI recommendations pending`,
        description: `The AI Strategy Engine has ${total} actionable recommendations for improving your AI visibility and conversions.`,
        category: "opportunity",
        priority: "medium",
        status: "new",
        timestamp: new Date().toISOString(),
        source: "ai-strategy-engine",
      });
    }
  } catch { /* ignore */ }
}

async function generateDataFreshnessSignals(brandId: string, signals: Signal[]) {
  try {
    const freshness = await getSourceFreshness(brandId);
    const sources = [
      { key: "ga4", label: "GA4", lastAt: freshness.ga4.lastUpdatedAt, status: freshness.ga4.status },
      { key: "gsc", label: "GSC", lastAt: freshness.gsc.lastUpdatedAt, status: freshness.gsc.status },
      { key: "siteTag", label: "Site Tag", lastAt: freshness.siteTag.lastEventAt, status: freshness.siteTag.status },
    ];

    const week = Math.floor(Date.now() / (7 * 86_400_000));

    for (const src of sources) {
      if (src.status === "none") continue;
      if (src.status === "critical" || src.status === "stale") {
        const isCritical = src.status === "critical";
        const ago = src.lastAt
          ? `${Math.round((Date.now() - new Date(src.lastAt).getTime()) / 3_600_000)}h ago`
          : "unknown";
        signals.push({
          id: `data-freshness-${src.key}-${brandId}-w${week}`,
          title: `${src.label} data sync stalled`,
          description: `Last successful ${src.label} sync: ${ago}. Dashboard charts may show incomplete data. Click to investigate.`,
          category: "issue",
          priority: isCritical ? "high" : "medium",
          status: "new",
          timestamp: new Date().toISOString(),
          source: "data-quality",
        });
      }
    }
  } catch { /* ignore */ }
}
